前言
前段時間做了一個頁面,做的是個人雲盤的業務,操作功能上類似於百度網盤和windows文件管理。這個業務本身沒有稱得上是亮點的地方,但是當中有很多地方值得總結,無論是技術上還是感悟上。
我的感悟首先在產品上,作為一名前端,要不斷地站在用戶的角度上去感受它,一定有一些可以做的更友好、更人性化的地方。比如在移動復制文件/文件夾的操作中,原來只能通過右鍵菜單操作,現在可以通過鍵盤ctrl + vc/x/v,也可以直接拖動(移動)。
其次在本次編碼中,我有以下意識和習慣:
- 代碼的解耦(合理拆分:分為函數、組件/類、文件三個維度上的解耦)
- 當前技術棧下的代碼可優化點和優雅、正確的編程方式
- 代碼的復用性和可擴展性
- 過程記錄、事后總結、API文檔書寫
然后,還有幾個感悟:
- 當使用新的標准API、開源項目時,要先進行考察。考察點除了功能上能否滿足外,還要着重看成熟度與活躍度,更重要的是要看它的問題列表,有沒有得到足夠的、及時地解決和回復。
- 對於某些具體的技術問題,只要肯思考、敢啃硬骨頭,大部分問題都是能解決的
- 作為前端從業人員,位於數據鏈的最下游,受制於后端人員的時間和精力等因素,很容易受影響拖慢開發進度,所以最好還是要拓寬自己的技術棧
最后,還有一些收獲:
- 學習到的具體的技術點若干
- 技術解決方案若干
- 公共組件、公共方法的開發經驗(開始嘗試造輪子)
接下來,我把所有相關的技術點整理在這里,鞏固學習。清單如下:
- 技術點
- HTML5 Observer API
- React props派生
- 滾動事件和滾輪事件
- 事件委托的原生封裝
- 在線圖片轉化為base64編碼
- 瀏覽器(內核)及版本的判斷
- 兼容Linux、Windows、Mac的文件命名規則的方案
- React組件的props控制(破壞性魔改)
- IE中使用base64報錯“傳遞給系統調用的數據區域太少”的問題
- 技術方案
- HTML5拖拽API的兼容性處理方案
- web大文件分片上傳和斷點續傳的實現(只有思路,沒有成熟方案)
- 下載異常、錯誤的友好提示處理
- 多行文本省略號效果,在系統字體可變化的情況下,能夠合理展示的解決方案
- 關於公共組件
- 什么時候需要公共組件
- 公共組件的作用、特點
- 內部的運作方式(公共組件與外界的交流方式、內部的狀態管理)
- 公共組件是如何在業務中實現功能的
技術點
HTML5 Oberver API
HTML5 增加了一批 Oberver API ,包括 MutationObserver, PerformanceObserver, IntersectionOberver, ResizeObserver 等,目的是針對一些目標進行監控。這些 API 中只有 MutationObserver (針對DOM結構的監控)進入了正式標准,PerformaneObserver 進入候選階段,IntersectionObserver 和 ResizeObserver 目前在草案階段。所以這里講解一下 MutationObserver,它有一個構造函數 MutationObserver() 和 三個方法 disconnect()、observe()、takeRecords()
MutationObserver(callback) 構造函數,返回一個監聽DOM變化的MutationObserver對象 回調函數:當指定的被監控DOM節點發生變動時執行。有兩個參數:第一個是 MutationRecord 對象數組,即描述所有被觸發改動的對象,詳細的變動信息存儲在這些對象中。第二個是當前調用該函數的 mutationObserver 對象 .observe(target, opinions) 開始監控DOM節點 target是被監控的DOM節點 opinions可選,是一個對象,屬性有: attributeFilter 要監控的DOM屬性,若無此屬性,默認監控所有屬性。無默認值 attributeOldValue 當被監控節點的屬性改動時,將次屬性置為true將記錄任何有改動屬性的上一個值。無默認值 attributes 置為true以觀察受監視元素的屬性值變更。默認值為false characterData 置為true以觀察受監視元素的屬性值變更。默認值為false characterDataOldValue 置為true以在文本在受監視節點上發生更改時記錄節點文本的先前值。 childList 置為true以監視目標節點(如果subtree為true,則包含子孫節點)添加或刪除新的子節點。默認值為false。 subtree 置為true以擴展監視范圍到目標節點下的整個子樹的所有節點。MutationObserverInit的其他值都會作用於此子樹下的所有節點,而不僅僅只作用於目標節點。默認值為false。 .disconnect() 此方法告訴觀察者停止監控 .takeRecords() 此方法返回已檢測到但尚未由觀察者的回調函數處理的所有匹配DOM更改的列表,使變更隊列保持為空。 此方法最常見的使用場景是在斷開觀察者之前立即獲取所有未處理的更改記錄,以便在停止觀察者時可以處理任何未處理的更改。
React props 派生
何為 props 派生?比如現在有這樣的需求,子組件中來自父組件的 props 數據,並不是直接使用,而是在其基礎上進行更改過后才會使用,因此需要 props 變化時更新 state 的操作,可以通過生命周期函數實現。
在react16.4版本之前通過 componentWillReceiveProps 來實現,16.4之后還可以通過 getDerivedStateFromProps 來實現。另外,在具體情況下是否真的需要 props 派生、注意事項及可能出現的bug官網博客總結的很詳細
滾動事件和滾輪事件
滾動事件 onscroll,滾輪事件 onwheel。在PC端一般容易被認為沒什么區別,但還是有些細微的差別。無論通過何種方式(鼠標滾輪、鍵盤方向鍵、觸摸板)滾動頁面,只要有滾動發生都會觸發滾動事件。而滾輪事件無論頁面有無發生滾動,只要滾輪被觸動,都會發生該事件。大部分時候只需要滾動事件即可,個別時候滾輪事件配合使用。比如想頁面已經滾動到底部,仍在滾動滑輪時,只發生滾輪事件不發生滾動事件,有這個需求可以配合使用。注意,滾輪事件要使用onwheel,onmousewheel已被廢棄。
事件委托的原生封裝
在封裝事件委托之前,有幾個問題需要明白:
為什么需要事件委托?
- 提高頁面性能
- 有時候想要為某元素綁定監聽事件,但無法獲取其DOM元素,這個時候可以獲取其祖先元素,利用事件委托即可綁定事件監聽
jQuery不是有 on() 方法來實現事件委托嗎?為什么還要自己封裝?
進入React、Vue、Angular的前端組件化 + 前端工程化時代,我們應該改變思維,在開發中盡量不要使用jQuery。你應該首選使用React提供的事件處理機制,盡量不要使用原生JS處理事件。當你確認React的事件處理無法滿足你的需求、或者不方便實現時,可以使用addEventListener()。雖然這里封裝了 onDelegate(),但還是建議你不在萬不得已的情況下不要使用。
使用文檔
實現源碼:
import cloneDeep from "lodash/cloneDeep"; const throwError = (message) => { throw new Error(message) }; // 判斷是否是DOM元素 const isDOM = (obj) => typeof HTMLElement === 'object' ? obj instanceof HTMLElement : obj && typeof obj === 'object' && obj.nodeType === 1 && typeof obj.nodeName === 'string'; // 檢查selector的有效性 const checkSelector = (parent, selector) => { try{ parent.querySelector(selector); }catch (e) { return `參數 selector 無效` } }; // 參數檢測 const paramCheck = (type, events, parent, selector, func, data, reverseScope, capture) => { let baseMsg = `Document模塊 ${type}Delegate()方法調用錯誤:`; if (type === "on") { typeof events !== "string" && throwError(`${baseMsg}參數 events 必須是 string 類型,現在是${typeof events}!`); events.length === 0 && throwError(`${baseMsg}參數 events 不能為空!`); !isDOM(parent) && throwError(`${baseMsg}參數 parent 必須是 DOM 元素!`); typeof selector !== "string" && throwError(`${baseMsg}參數 selector 必須是 string 類型,現在是${typeof selector}!`); let selectRes = checkSelector(parent, selector); // 檢測selector的有效性 typeof selectRes === "string" && throwError(`${baseMsg}${selectRes}!`); typeof func !== "function" && throwError(`${baseMsg}參數 func 必須是 function 類型,現在是${typeof func}!`); typeof reverseScope !== "boolean" && throwError(`${baseMsg}參數 reverseScope 必須是 boolean 類型,現在是${typeof reverseScope}!`); typeof capture !== "boolean" && throwError(`${baseMsg}參數 capture 必須是 boolean 類型,現在是${typeof capture}!`); Object.prototype.toString.call(data).slice(8, -1) !== "Object" && throwError(`${baseMsg}參數 data 必須是 object 類型!`); // 判斷data數據類型 }else if(type === "off") { typeof events !== "string" && throwError(`${baseMsg}參數 events 必須是 string 類型,現在是${typeof events}!`); events.length === 0 && throwError(`${baseMsg}參數 events 不能為空!`); let selectRes = checkSelector(parent, selector); // 檢測selector的有效性 typeof selectRes === "string" && throwError(`${baseMsg}${selectRes}!`); !isDOM(parent) && throwError(`${baseMsg}參數 parent 必須是 DOM元素!`); typeof selector !== "string" && throwError(`${baseMsg}參數 selector 必須是 string 類型,現在是${typeof selector}!`); typeof func !== "function" && throwError(`${baseMsg}參數 func 必須是 function 類型,現在是${typeof func}!`); typeof reverseScope !== "boolean" && throwError(`${baseMsg}參數 reverseScope 必須是 boolean 類型,現在是${typeof reverseScope}!`); } }; let EventHandles = []; // 事件委托 const onDelegate = (events = "", parent, selector = "", func, data = {}, reverseScope = false, capture = false) => { data = cloneDeep(data); paramCheck("on", events, parent, selector, func, data, reverseScope, capture); // 參數檢測 const already = EventHandles.find(f => f.events === events && f.parent === parent && f.selector === selector && f.func === func && f.reverseScope === reverseScope); if(!already) { const handler = (e) => { let flag = false, target = e.target, selectList = Array.from(parent.querySelectorAll(selector)); while (target.tagName !== "BODY") { if (selectList.includes(target)) { let event = { delegateTarget: parent, currentTarget: target, data: data, originalEvent: e }; !reverseScope && func(event); flag = true; break; } target = target.parentNode ? target.parentNode : ""; } let event = { delegateTarget: parent, currentTarget: e.target, data, originalEvent: e }; reverseScope && !flag && func(event); }; parent.addEventListener(events, handler, capture); EventHandles.push({ events, parent, selector, func, reverseScope, handler }); } }; // 解除由onDelegate()綁定的事件監聽 const offDelegate = (events = "", parent, selector = "", func, reverseScope = false) => { paramCheck("off", events, parent, selector, func, {}, reverseScope); let hands = EventHandles.filter(f => f.events === events && f.parent === parent && f.selector === selector && f.func === func && f.reverseScope === reverseScope); hands.forEach(i => { parent.removeEventListener(events, i.handler); EventHandles.splice(EventHandles.indexOf(i), 1); }); }; export { onDelegate, offDelegate };
在線圖片轉化為base64編碼
這個需求可能不太常見
export const convertImgUrlToBase64 = (url, outputFormat) => new Promise((resolve, reject) => { let img = document.createElement("img"); img.crossOrigin = 'Anonymous'; let canvas = document.createElement('CANVAS'); let ctx = canvas.getContext('2d'); img.src = url; img.addEventListener("load", () => { canvas.height = img.height; canvas.width = img.width; ctx.drawImage(img, 0, 0); let dataURL = canvas.toDataURL(outputFormat || 'image/png'); resolve(dataURL); canvas = null; }); });
瀏覽器內核(版本)的判斷
這里判斷瀏覽器外殼沒有太大的意義,重要的是判斷內核。
export const judgeBrowserType = () => { const agent = navigator.userAgent; let browser = "", version = "-1", ver; switch (true) { case agent.includes("Opera"): // Opera瀏覽器(非Chromium內核, 老版本) browser = "opera"; ver = agent.match(/Version\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`; break; case agent.includes("Trident") || agent.includes("MSIE"): // IE瀏覽器 或 IE內核 browser = "ie"; agent.includes("MSIE") && (ver = agent.match(/MSIE\/([\d.]+)/)[1].split(".")); !agent.includes("MSIE") && (ver = ["11", "0"]); version = `${ver[0]}.${ver[1]}`; break; case agent.includes("Edge"): // Edge瀏覽器 browser = "edge"; ver = agent.match(/Edge\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`; break; case agent.includes("Firefox"): // Firefox瀏覽器 browser = "firefox"; ver = agent.match(/Firefox\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`; break; case agent.includes("Gecko") && !agent.includes("like Gecko"): // 非Firefox的Gecko內核, 無法判斷版本 browser = "firefox"; break; case agent.includes("Safari") && !agent.includes("Chrome"): // Safari瀏覽器 browser = "safari"; ver = agent.match(/Version\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`; break; case agent.includes("Chrome") && agent.includes("Safari"): // Google Chrome 或 Chromium內核 browser = "chrome"; ver = agent.match(/Chrome\/([\d.]+)/)[1].split("."); version = `${ver[0]}.${ver[1]}`; break; default: browser = "others"; break; } return { browser, version } };
兼容Windows、Mac、Linux的命名規則
各平台的文件命名規則:
- Windows
- 不能超過 255 個字符(含擴展名)或 127 個中文字符
- 文件名可以包含除 ? " " / \ < > * | : 之外的大多數字符
- 除了開頭之外任何地方都可以使用空格
- 保留大小寫格式,但不區分大小寫
- Mac
- 不能包含冒號 : 不能以句點 . 開頭
- 部分 APP 可能不允許使用斜杠 /
- Linux
- 大小寫敏感
- 不允許使用 /
- 不允許將 . 和 .. 當做文件名
// turn === "turn"表示,不合規的字符會被修改成下划線(超出長度的字符被剪掉) const nameRule_compatible = (fullName, name, turn) => { let flag = true; let errorMsg = ""; const forbid = `?"/\\<>*|:`; fullName = fullName.trim(); name = name.trimLeft(); if(fullName.length > 255) { errorMsg = getLabel(513983, '名稱不得超過255個字符'); if (turn === "turn") { name = name.substring(0, 216); fullName = `${name}.${fullName.split(".").pop()}`; } } for(let i=0; i<forbid.length; i++) { if(name.includes(forbid[i])) { errorMsg = `${getLabel(513984, '文件名不能包含下列任何字符')}: \\ / : * ? " < > | `; if (turn === "turn") { let regExp = new RegExp(`\\${forbid[i]}`, "g"); name = name.replace(regExp, "_"); fullName = fullName.replace(regExp, "_"); } } } if(name[0] === ".") { errorMsg = getLabel(513985, '不能以 . 開頭'); if (turn === "turn") { fullName = "_" + fullName.substring(1); name = "_" + name.substring(1); } } if(errorMsg) { flag = false; message.warn(errorMsg); } return [flag, fullName, name] }; export { nameRule_compatible, }
React 組件的 props 使用
這里”組件 props 使用“指的是:當使用某個組件時,無法直接接觸到其內部使用的某組件,而這時希望改變該某組件的 props 傳參。這里有兩個方法,一是獲取目標組件的ref,可以直接修改值;二是直接獲取目標組件的變量(或其父組件、祖先組件,可以順着找到目標組件)來操作。需要指出的是,第二種方法具有破壞性,可以在實在沒辦法的情況下使用。
IE 中使用 base64 時報錯
報錯“傳遞給系統調用的數據區域太小”。是由於 IE 瀏覽器中對 href、src 這些屬性對 url 長度有限制,而 base64 一般都比較長。原理上講,先將 base64 轉 blob 再生成 url,但由 blob 生成 url 這部分操作(HTML標准)的結果,在IE下會報錯。怎么解決呢?使用IE自己的API: window.navigator.msSaveOrOpenBlob(blob, fileName);
技術方案整理
HTML5 拖拽 API 的兼容處理方案(除了拖拽上傳)
本業務中的功能是:拖拽文件圖標至文件夾中,完成文件移動的功能。此功能在開發中依照 HTML5 標准 API 編寫,基於最新的穩定版Google Chrome(78),並未發現任何兼容性問題。
1.Firefox 存在打開新標簽頁的問題:拖拽釋放在目標元素上時會打開新標簽頁
解決方法:drop 事件中阻止默認行為
2.IE 某些版本有兼容性問題:
IE11:在 dataTransfer.setData() 時,鍵不能自定義,只能是標准規定的如 Text
IE10、IE9:支持 HTML5 標准,但本人未作測試
IE9 以下:不支持標准 API
3.Edge:
舊版本的 KTHML 內核:測試版本是16,遇到的問題與 IE11 相同,處理方式相同
新版本的 Chromium 內核:無兼容性問題
4.國產瀏覽器拖拽釋放會打開新標簽頁:
IE 內核:不要使用 dataTransfer 對象來傳遞數據,可以使用共享的變量(如全局變量、store、類屬性this.xxx),需要該數據去維護
Chromium 內核:原因在於 e.dataTransfer.setData() 中的 key (貌似需要使用自定義key)
5.父元素允許拖拽時,子元素想要被選中文本(子元素自動被允許拖拽):
如果子元素是 input,子元素 draggable=true,dragstart 事件阻止默認事件即可
如果子元素是普通元素,使用 mousedown/mouseup 事件 或 mouseenter/mouseleave事件 相互配合,改變父元素的 draggable 屬性
6.拖動元素在目標元素上晃悠,目標容器元素表現異常:
期望效果是在 dragenter 事件(進入目標元素時)改變背景顏色,dragleave 事件(離開目標元素時)恢復背景顏色。現實情況是:進入目標元素后離開目標元素前不斷閃爍(多次交替發生 dragenter/dragleave),並且時長無法恢復背景顏色。
原因:如果目標元素內部沒有子元素,不會出現上述異常。如果內部有多個子元素(及后代元素),那么拖動元素在目標元素上經過子元素時會有上述異常。明明是在目標元素上綁定的這兩個事件,卻在其所有的后代元素上都會觸發(並非冒泡)
解決方法:設置一個緩存變量(布爾值),標記當前是否進入/離開目標元素,排除子元素的干擾,即可。
7.拖拽下載:只有 Chrome 支持,暫沒測試 Chromium 內核其它瀏覽器
只需將 dataTransfer 對象設置 DownloadURL 即可。
Web大文件分片上傳和斷點續傳(沒有具體方案,但有整體思路)
斷點續傳必然要分片上傳,前端將文件分片上傳,后端一個一個地接收分片並存儲,當全部接收完畢后再合並。因此,在分片上傳時,需要前后端協商好文件名、任務ID、分片個數、當前分片索引等信息。
分片上傳建議一個一個地上傳(串行上傳),當用戶暫停上傳時,當前正在上傳的分片中斷,下次繼續上傳時,從此分片開始上傳。
前端的核心問題是如何實現文件分片,后端的核心問題是如何將文件合並、何時合並。
前端分片通過 HTML5 FILE API 的 slice() 方法,可將文件分片。后端在全部分片接收完畢時即可開始合並,合並思路:新建二進制文件,按順序讀取分片,將讀取的二進制流依次寫入新文件,正確命名特別是擴展名,即可完成合並。
前端實驗代碼:
function SliceUploadFile() { let fileObj = document.getElementById("file").files[0]; // js 獲取文件對象 const itemSize = 8 * 1024 * 1024; // 分片大小:8M const number = Math.ceil(fileObj.size / itemSize); // 分片數量 let prev = 0; for(let i=0; i<number; i++) { let start = prev; let end = start + itemSize; let blob = fileObj.slice(start, end); let msg = {type: "slice", name: fileObj.name, task: "fileTest", count: number, current: i}; // FormData 對象 var form = new FormData(); form.append("author", "xueba"); // 可以增加表單數據 {#console.log("msg", msg);#} form.append("msg", JSON.stringify(msg)); form.append("file", blob);// 文件對象 // jQuery ajax $.ajax({ url: "/upload/", type: "POST", async: true, // 異步上傳 data: form, contentType: false, // 必須false才會自動加上正確的Content-Type processData: false, // 必須false才會避開jQuery對 formdata 的默認處理。XMLHttpRequest會對 formdata 進行正確的處理 xhr: function () { let xhr = $.ajaxSettings.xhr(); xhr.upload.addEventListener("progress", progressSFunction, false); xhr.upload.onloadstart = (e) => { progress[0] = { last_laoded: 0, last_time: e.timeStamp, }; console.log("開始上傳",progress); }; xhr.upload.onloadend = () => { delete progress[0]; console.log("結束上傳",progress); }; return xhr; }, success: function (data) { data = JSON.parse(data); data.forEach((i) => { console.log(i.code, i.file_url); }); }, error: function () { alert("aaa上傳失敗!"); }, }); prev = end } }
后端分片上傳代碼:
try: resList, fileList = [], request.FILES.getlist("file") msg = json.loads(request.POST.get("msg")) print(f"msg: {msg['type']}, count: {msg['count']}, current: {msg['current']}") dir_path = 'static/files/{0}/{1}/{2}'.format(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d")) if os.path.exists(dir_path) is False: os.makedirs(dir_path) for file in fileList: filename = f"{msg['current']}_{msg['task']}" if msg['type'] == "slice" else file.name file_path = '%s/%s' % (dir_path, filename) file_url = '/%s/%s' % (dir_path, filename) res = {"code": 0, "file_url": ""} with open(file_path, 'wb') as f: if f == False: res['code'] = 1 for chunk in file.chunks(): # chunks()代替read(),如果文件很大,可以保證不會拖慢系統內存 f.write(chunk) res['file_url'] = file_url resList.append(res) return HttpResponse(json.dumps(resList)) except: return HttpResponse("error")
后端分片合並代碼:
def mergeFiles(): "合並分片文件" path = "../static/files/2019/11/26" fileList = [file for file in os.listdir(path)] fileList.sort(key=lambda x: int(x.split("_")[0])) maxIndex = int(fileList[-1].split("_")[0]) mergeName = "企業應用-部署介紹和nginx安裝.mp4" with open(f"{path}/{mergeName}", "wb") as f: for fileName in fileList: print("正在合並", fileName) with open(f"{path}/{fileName}", "rb") as file: # f.write(file.read()) for line in file: f.write(line)
下載異常、錯誤時的友好提示方案
在整個系統中常見文件下載,下載本身的實現也很簡單,但下載如果有異常可能會導致前端頁面報錯、白屏、錯誤頁等問題,也就是提示不友好的問題。
- 當使用 window.location.href = "" 時,一旦下載異常,頁面立馬壞掉
- 當使用 <a href="" download=""> 時,下載異常,頁面不會有問題,但會下載一個無效文件,且無法提示用戶,會讓人感覺錯愕
- 當使用 iframe 下載,可以監控 iframe 的 onload 事件,下載異常子頁面會壞掉,主頁面沒有問題,但也無法提示用戶,看起來沒有反應
經過思考,我認為這需要前后端的配合才可以做到友好提示,如下:
- 首先對下載地址發送 HEAD 請求,探測應用層面是否能走通,如果返回狀態碼 200 說明網絡是沒有問題的,開始下載
- 在 iframe 中的 a 標簽開始下載(不要 download 屬性),如果下載發生異常,首先排除網絡問題,可以確定是服務端有錯誤。這時需要服務端做異常處理,捕獲異常后響應給前端,返回提示字符串
- 前端接收到字符串,會將字符串直接呈現在 iframe 中,可以通過 onload 事件監控到,將內容讀取可以呈現給用戶
- 如果下載過程中出現網絡異常,瀏覽器會自動處理(中斷下載),頁面不會有問題
多行文本省略號效果在系統字體變化的情況下能夠合理展示的解決方案
多行文本省略號,目前 CSS 沒有正式的標准方案,webkit 內核的瀏覽器(Chromium內核【Chrome、Edge、Opera、國產瀏覽器】、Firefox68+、Safari)有非標准的 CSS 方案可以實現。但是對於低版本火狐、舊版Edge、IE、舊版Opera等,無法只通過 CSS 實現。以前的處理辦法是 overflow: hidden,再設置 max-height、固定 width。雖然沒有省略號效果,但是也能看得過去。
現在的情況不同了,系統的字體可以隨時變化:“大”、“中”、“默認”。導致在 overflow: hidden 時,max-height 的值無法固定,此方案行不通。因此,在這種情況下,經過我的摸索找到了兩種方法:
- 經過研究發現,切換系統字體時,其實是切換了一套 css 文件,通過 MutationObserver 可以監控 <head> 中 <style> 的變化,可以獲知當前用了多大的字體,然后采用對應准備好的 CSS 類。此方法需考慮瀏覽器的 Observer API 兼容性問題
- 截取文本的字節長度,超出指定長度后截取並加上“...”,此方法不存在瀏覽器兼容問題
這里重點講第一種方法的 CSS(LESS) ,可以做到在對應的系統字體下,3行以內沒有省略號,超過3行出現省略號:
LESS:
.WeaDoc-showName{ color: #333333; margin-top: 6px; letter-spacing: -0.08px; display: inline-block; position: relative; word-wrap: break-word; word-break: break-all; cursor: text; min-width: 25px; .textname{ position: relative; overflow: hidden; text-overflow: ellipsis; display: -moz-box; display: -webkit-box; -ms-box-orient: vertical; -moz-box-orient: vertical; -webkit-box-orient: vertical; -ms-line-clamp: 2; -moz-line-clamp: 2; -webkit-line-clamp: 2; } @font-size-list: 12, 14, 16; @font12-height: 38px; @font14-height: 44px; @font16-height: 51.2px; @gradient-color: white; @base-after-number: 7; .show-name-common(@height){ float: right; width: 100%; margin-left: -5px; max-height: @height + 1; } .show-before-common(@height){ content: ""; float: left; width: 5px; height: @height; } .show-after-common(@bottom, @fontSize, @backColor: white){ content: "..."; float: right; position: relative; bottom: ~"@{bottom}px"; left: 100%; width: 30px; font-size: ~"@{fontSize}px"; margin-left: -30px; padding-right: 5px; background: linear-gradient(to right, transparent, @backColor 45%, @backColor); box-sizing: content-box; text-align: right; transform: translateX(-4px); pointer-events: none; } .font-compatible-loop(1, 7); .font-compatible-loop(@i, @base) when (@i <= length(@font-size-list)) { @size: extract(@font-size-list, @i); @heightStr: "font@{size}-height"; .forLoopItem(@size, @@heightStr, @base); .font-compatible-loop(@i + 1, @base + 1); } .forLoopItem(@size, @height, @base){ &.text-@{size}{ height: @height; overflow: hidden; .textname{ .show-name-common(@height: @height); } &::before{ .show-before-common(@height: @height); } &::after{ .show-after-common(@bottom: @size + @base, @fontSize: @size); } } } }
變異后的CSS:
.WeaDoc-showName { color: #333333; margin-top: 6px; letter-spacing: -0.08px; display: inline-block; position: relative; word-wrap: break-word; word-break: break-all; cursor: text; min-width: 25px; } .WeaDoc-showName .textname { position: relative; overflow: hidden; text-overflow: ellipsis; display: -moz-box; display: -webkit-box; -ms-box-orient: vertical; -moz-box-orient: vertical; -webkit-box-orient: vertical; -ms-line-clamp: 2; -moz-line-clamp: 2; -webkit-line-clamp: 2; } .WeaDoc-showName.text-12 { height: 38px; overflow: hidden; } .WeaDoc-showName.text-12 .textname { float: right; width: 100%; margin-left: -5px; max-height: 39px; } .WeaDoc-showName.text-12::before { content: ""; float: left; width: 5px; height: 38px; } .WeaDoc-showName.text-12::after { content: "..."; float: right; position: relative; bottom: 19px; left: 100%; width: 30px; font-size: 12px; margin-left: -30px; padding-right: 5px; background: linear-gradient(to right, transparent, white 45%, white); box-sizing: content-box; text-align: right; transform: translateX(-4px); pointer-events: none; } .WeaDoc-showName.text-14 { height: 44px; overflow: hidden; } .WeaDoc-showName.text-14 .textname { float: right; width: 100%; margin-left: -5px; max-height: 45px; } .WeaDoc-showName.text-14::before { content: ""; float: left; width: 5px; height: 44px; } .WeaDoc-showName.text-14::after { content: "..."; float: right; position: relative; bottom: 22px; left: 100%; width: 30px; font-size: 14px; margin-left: -30px; padding-right: 5px; background: linear-gradient(to right, transparent, white 45%, white); box-sizing: content-box; text-align: right; transform: translateX(-4px); pointer-events: none; } .WeaDoc-showName.text-16 { height: 51.2px; overflow: hidden; } .WeaDoc-showName.text-16 .textname { float: right; width: 100%; margin-left: -5px; max-height: 52.2px; } .WeaDoc-showName.text-16::before { content: ""; float: left; width: 5px; height: 51.2px; } .WeaDoc-showName.text-16::after { content: "..."; float: right; position: relative; bottom: 25px; left: 100%; width: 30px; font-size: 16px; margin-left: -30px; padding-right: 5px; background: linear-gradient(to right, transparent, white 45%, white); box-sizing: content-box; text-align: right; transform: translateX(-4px); pointer-events: none; }
關於公共組件
組件庫也屬於公共組件的范疇,在業務中被大量復用。一般在大公司中會有自己的一套組件庫供業務開發使用,注重通用性、便捷性。但對於一個龐大的系統而言,一套組件庫不能照顧到所有的邊邊角角,有些模塊需要定制自己的公共部分、有些部分可能只在這一個模塊中被復用。在這里,我說的公共組件指的是這部分。
什么時候需要公共組件?
首先,你需要一個組件或者一些功能,卻並沒有現成的輪子。(確認組件庫中真的沒有這部分)
其次,你需要的這個組件,可能會在很多地方復用
公共組件的作用、特點
- 復用性。同一個功能可以在很多地方被復用,提升程序質量
- 通用性。可以滿足多個業務場景的需求,可以兼顧它們的需求
- 作為基礎設施提供 API。讓業務開發者更專注於業務邏輯,而不是各種細節處理(往大了說,所有的框架、庫、中間件甚至瀏覽器和操作系統不都具有這個作用么)
公共組件是如何在業務中實現功能的
功能由誰實現
首先要明白,在 React 中一個功能可能並不完全由公共組件實現、也有可能是公共組件與業務代碼相互配合實現(這樣的情況很多),因此我們在開發公共組件的時候要明白,如何權衡、如何划分最合理
- 如何在 UI 中划分
- 明確職責,哪些功能是需要由公共組件完成、哪些功能交由業務代碼完成、哪些功能最好讓兩者相互配合
公共組件內部
公共組件內部也要注意合理拆分(解耦),為了代碼具備更好的擴展性、可維護性、可讀性,分為三個維度的拆分:
- 函數的拆分
- 組件的拆分
- 文件的拆分
狀態數據的管理:絕大部分的公共組件內部都可以使用 React 自身的 API 實現狀態管理(state、hooks),如果該公共組件過於龐大,內部過於復雜,可以使用 mobx、redux 等狀態管理。無論哪種方式,這些狀態數據都是只供組件內部使用的,不能是外部使用的。
如何使用公共組件(交互方式)
對於業務組件來說,最重要的是明確一個公共組件適合在什么時候用、該如何使用(掌握 API)
屬性:大多數的 React 組件功能通過調整屬性 props 即可實現,屬性的值可以是所有類型 number/string/boolean/function/...... 。這里又分為幾種不同的方式
- 普通參數:number/string/boolean/ReactNode等值,根據需求調整功能
- 回調:function。一些行為/事件的回調,通常公共組件會在這里傳遞給業務代碼一些參數,也可以有返回值
- 受控數據:數組是常見形式。將受控數據開放給業務開發,大大提升靈活性,可以滿足個性化的需求
組件實例 ref :如果公共組件想對外提供一些方法以供調用,需要通過 ref 。這里需要說明的是,你需要考慮哪些方法暴露出去、哪些方法不暴露出去。
RenderProps:與 Props 傳 ReactNode 不同的是,RenderProps 可以在業務實現中接收來自公共組件的數據。可以寫在 Props 中,也可以寫在 Children 中