1. 程式人生 > >記一次專案總結

記一次專案總結

前言

前段時間做了一個頁面,做的是個人云盤的業務,操作功能上類似於百度網盤和windows檔案管理。這個業務本身沒有稱得上是亮點的地方,但是當中有很多地方值得總結,無論是技術上還是感悟上。

我的感悟首先在產品上,作為一名前端,要不斷地站在使用者的角度上去感受它,一定有一些可以做的更友好、更人性化的地方。比如在移動複製檔案/資料夾的操作中,原來只能通過右鍵選單操作,現在可以通過鍵盤ctrl + vc/x/v,也可以直接拖動(移動)。

其次在本次編碼中,我有以下意識和習慣:

  • 程式碼的解耦(合理拆分:分為函式、元件/類、檔案三個維度上的解耦)
  • 當前技術棧下的程式碼可優化點和優雅、正確的程式設計方式
  • 程式碼的複用性和可擴充套件性
  • 過程記錄、事後總結、API文件書寫

然後,還有幾個感悟:

  1. 當使用新的標準API、開源專案時,要先進行考察。考察點除了功能上能否滿足外,還要著重看成熟度與活躍度,更重要的是要看它的問題列表,有沒有得到足夠的、及時地解決和回覆。
  2. 對於某些具體的技術問題,只要肯思考、敢啃硬骨頭,大部分問題都是能解決的
  3. 作為前端從業人員,位於資料鏈的最下游,受制於後端人員的時間和精力等因素,很容易受影響拖慢開發進度,所以最好還是要拓寬自己的技術棧

最後,還有一些收穫:

  • 學習到的具體的技術點若干
  • 技術解決方案若干
  • 公共元件、公共方法的開發經驗(開始嘗試造輪子)

接下來,我把所有相關的技術點整理在這裡,鞏固學習。清單如下:

  • 技術點
    • 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官網部落格總結的很詳細

你可能不需要派生state

滾動事件和滾輪事件

滾動事件 onscroll,滾輪事件 onwheel。在PC端一般容易被認為沒什麼區別,但還是有些細微的差別。無論通過何種方式(滑鼠滾輪、鍵盤方向鍵、觸控板)滾動頁面,只要有滾動發生都會觸發滾動事件。而滾輪事件無論頁面有無發生滾動,只要滾輪被觸動,都會發生該事件。大部分時候只需要滾動事件即可,個別時候滾輪事件配合使用。比如想頁面已經滾動到底部,仍在滾動滑輪時,只發生滾輪事件不發生滾動事件,有這個需求可以配合使用。注意,滾輪事件要使用onwheel,onmousewheel已被廢棄。

事件委託的原生封裝

在封裝事件委託之前,有幾個問題需要明白:

為什麼需要事件委託?

  1. 提高頁面效能
  2. 有時候想要為某元素繫結監聽事件,但無法獲取其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等值,根據需求調整功能
  • 受控資料:陣列是常見形式。將受控資料開放給業務開發,大大提升靈活性,可以滿足個性化的需求
  • 回撥:function。一些行為/事件的回撥,通常公共元件會在這裡傳遞給業務程式碼一些引數,也可以有返回值

元件例項 ref :如果公共元件想對外提供一些方法以供呼叫,需要通過 ref 。這裡需要說明的是,你需要考慮哪些方法暴露出去、哪些方法不暴露出去。

&n