前端裝逼技巧 108 式(三)—— 冇得感情的API呼叫工程師

孤篷發表於2020-12-24

系列文章釋出彙總:

文章風格所限,引用資料部分,將在對應小節末尾標出。

第三十七式:茫然一顧眼前亮,懵懂宛如在夢中 —— "123​4".length === 5 ?這一刻,我感受到了眼睛的背叛和侮辱

  • 複製以下程式碼到瀏覽器控制檯:
console.log('123​4'.length === 5); // true

12345

  哈哈,是不是有種被眼睛背叛的感覺?其實這就是所謂的零寬空格(Zero Width Space,簡稱“ZWSP”),零寬度字元是不可見的非列印字元,它用於打斷長英文單詞或長阿拉伯數字,以便於換行顯示,否則長英文單詞和長阿拉伯數字會越過盒模型的邊界,常見於富文字編輯器,用於格式隔斷。

  • 探究一下上面程式碼的玄機:
const common = '1234';
const special = '123​4';
console.log(common.length); // 4
console.log(special.length); // 5
console.log(encodeURIComponent(common)); // 1234
console.log(encodeURIComponent(special)); // 123%E2%80%8B4
// 把上面中間特殊字元部分進行解碼
console.log(decodeURIComponent('%E2%80%8B')); // (空)

const otherSpecial = '123\u200b4'; // 或者"123\u{200b}4"
console.log(otherSpecial); // 1234
console.log(otherSpecial.length, common === special, special === otherSpecial); // 5 false true
  • 在 HTML 中使用零寬度空格(在 HTML 中,零寬度空格與<wbr>等效):
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <!-- &#8203; 和 <wbr /> 是零寬空格在html中的兩種表示 -->
    <div>abc&#8203;def</div>
    <div>abc<wbr />def</div>
  </body>
</html>
ESLint 有一條禁止不規則的空白 (no-irregular-whitespace)的規則,防止程式碼裡面誤拷貝了一些諸如零寬空格類的空格,以免造成一些誤導。
擴充:我們經常在 html 中使用的&nbsp;全稱是No-Break SPace,即不間斷空格,當 HTML 有多個連續的普通空格時,瀏覽器在渲染時只會渲染一個空格,而使用這個不間斷空格,可以禁止瀏覽器合併空格。常用於富文字編輯器之中,當我們在富文字編輯器連續敲下多個空格時,最後輸出的內容便會帶有很多不間斷空格。
參考資料:常見空格一覽 - 李銀城 | 什麼是零寬度空格 | 維基百科-空格

第三十八式:如何禁止網頁複製貼上

  對於禁止網頁複製貼上,也許你並不陌生。一些網頁是直接禁止複製貼上;一些網頁,則是要求登陸後才可複製貼上;還有一些網站,複製貼上時會帶上網站的相關來源標識資訊。

  • 如何禁止網頁複製貼上
const html = document.querySelector('html');
html.oncopy = () => {
  alert('牛逼你複製我呀');
  return false;
};
html.onpaste = () => false;
  • 在複製時做些別的操作,比如跳轉登陸頁面
const html = document.querySelector('html');
html.oncopy = (e) => {
  console.log(e);
  // 比如指向百度或者登陸頁
  // window.location.href='http://www.baidu.com';
};
html.onpaste = (e) => {
  console.log(e);
};
  • 如何使用 js 設定/獲取剪貼簿內容
//設定剪下板內容
document.addEventListener('copy', () => {
  const clipboardData =
    event.clipboardData || event.originalEvent?.clipboardData;
  clipboardData?.setData('text/plain', '不管複製什麼,都是我!');
  event.preventDefault();
});

//獲取剪下板的內容
document.addEventListener('paste', () => {
  const clipboardData =
    event.clipboardData || event.originalEvent?.clipboardData;
  const text = clipboardData?.getData('text');
  console.log(text);
  event.preventDefault();
});
  • 有什麼用

    • 對於註冊輸入密碼等需要輸入兩次相同內容的場景,應該是需要禁止貼上的,這時候就可以禁止對應輸入框的複製貼上動作。
    • 登陸才能複製。很多網站上的頁面內容是不允許複製的,這樣可以防止使用者或者程式惡意的去抓取頁面資料。
參考資料:Clipboard API and events | Document.execCommand()

第三十九式:function.length指代什麼? —— 認識柯里化和JS 函式過載

  在函數語言程式設計裡,有幾個比較重要的概念:函式的合成、柯里化和函子。其中柯里化(Currying),是指把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數而且返回結果的新函式的技術。這個技術由 Christopher Strachey 以邏輯學家 Haskell Curry 命名的,但是它是 Moses Schnfinkel 和 Gottlob Frege 發明的。

  lodash 實現了_.curry函式,_.curry函式接收一個函式作為引數,返回新的柯里化(curry)函式。呼叫新的柯里化函式時,當傳遞的引數個數小於柯里化函式要求的引數時,返回一個接收剩餘引數的函式,當傳遞的引數達到柯里化函式要求時,返回結果。那麼,_.curry函式是如何判斷傳遞的引數是否到達要求的呢?我們不妨先看看下面的例子:

function func(a, b, c) {
  console.log(func.length, arguments.length);
}
func(1); // 3  1
  • 看看 MDN 的解釋:

    • length 是函式物件的一個屬性值,指該函式有多少個必須要傳入的引數,那些已定義了預設值的引數不算在內,比如 function(x = 0)的 length 是 0。即形參的數量僅包括第一個具有預設值之前的引數個數。
    • 與之對比的是, arguments.length 是函式被呼叫時實際傳參的個數。
  • 實現 lodash curry 化函式
// 模擬實現 lodash 中的 curry 方法
function curry(func) {
  return function curriedFn(...args) {
    // 判斷實參和形參的個數
    if (args.length < func.length) {
      return function () {
        return curriedFn(...args.concat(Array.from(arguments)));
      };
    }
    return func(...args);
  };
}

function getSum(a, b, c) {
  return a + b + c;
}

const curried = curry(getSum);

console.log(curried(1, 2, 3));
console.log(curried(1)(2, 3));
console.log(curried(1, 2)(3));
  • JS 函式過載

  函式過載,就是函式名稱一樣,但是允許有不同輸入,根據輸入的不同,呼叫不同的函式,返回不同的結果。JS 裡預設是沒有函式過載的,但是有了Function.length屬性和arguments.length,我們便可簡單的通過if…else或者switch來完成 JS 函式過載了。

function overLoading() {
  // 根據arguments.length,對不同的值進行不同的操作
  switch (arguments.length) {
    case 0 /*操作1的程式碼寫在這裡*/:
      break;
    case 1 /*操作2的程式碼寫在這裡*/:
      break;
    case 2: /*操作3的程式碼寫在這裡*/
  }
}

  更高階的函式過載,請參考 jQuery 之父 John Resig 的JavaScript Method Overloading, 這篇文章裡,作者巧妙地利用閉包,實現了 JS 函式的過載。

參考資料:淺談 JavaScript 函式過載 | JavaScript Method Overloading | 【譯】JavaScript 函式過載 - Fundebug | Function.length | 函數語言程式設計入門教程 - 阮一峰

第四十式:["1","7","11"].map(parseInt)為什麼會返回[1,NaN,3]?

  • map 返回 3 個引數,item,index,Array,console.log可以接收任意個引數,所以[1,7,11].map(console.log)列印:

parseInt

  • parseInt 接受兩個引數:string,radix,其中 radix 預設為 10;
  • 那麼,每次呼叫 parseInt,相當於:parseInt(item,index,Array),map 傳遞的第三個引數 Array 會被忽略。index 為 0 時,parseInt(1,0),radix 取預設值 10;parseInt(7,1)中,7 在 1 進位制中不存在,所以返回”NaN“;parseInt(11,2),2 進位制中 11 剛好是十進位制中的 3。
參考:JS 中為啥 ['1', '7', '11'].map(parseInt) 返回 [1, NaN, 3]

第四十一式:iframe 間資料傳遞,postMessage 可以是你的選擇

  平時開發中,也許我們會遇到需要在非同源站點、iframe 間傳遞資料的情況,這個時候,我們可以使用 postMessage 完成資料的傳遞。
  window.postMessage() 方法可以安全地實現跨源通訊。通常,對於兩個不同頁面的指令碼,只有當執行它們的頁面位於具有相同的協議(通常為 https),埠號(443 為 https 的預設值),以及主機 (兩個頁面的模數 Document.domain 設定為相同的值) 時,這兩個指令碼才能相互通訊(即同源)。window.postMessage() 方法提供了一種受控機制來規避此限制,只要正確的使用,這種方法就很安全。

// 頁面1 觸發事件,傳送資料
top.postMessage(data, '*');
// window  當前所在iframe
// parent  上一層iframe
// top     最外層iframe

//頁面2 監聽message事件
useEffect(() => {
  const listener = (ev) => {
    console.log(ev, ev.data);
  };
  window.addEventListener('message', listener);
  return () => {
    window.removeEventListener('message', listener);
  };
}, []);

注意:

  • postMessage第二個引數 targetOrigin 用來指定哪些視窗能接收到訊息事件,其值可以是字串"*"(表示無限制)或者一個 URI。
  • 如果你明確的知道訊息應該傳送到哪個視窗,那麼請始終提供一個有確切值的 targetOrigin,而不是"*"。
  • 不提供確切的目標將導致資料洩露到任何對資料感興趣的惡意站點。
參考資料:window.postMessage

第四十二式:薛定諤的 X —— 有趣的let x = x

  薛定諤的貓(英文名稱:Erwin Schrödinger's Cat)是奧地利著名物理學家薛定諤提出的一個思想實驗,是指將一隻貓關在裝有少量鐳和氰化物的密閉容器裡。鐳的衰變存在機率,如果鐳發生衰變,會觸發機關打碎裝有氰化物的瓶子,貓就會死;如果鐳不發生衰變,貓就存活。根據量子力學理論,由於放射性的鐳處於衰變和沒有衰變兩種狀態的疊加,貓就理應處於死貓和活貓的疊加狀態。這隻既死又活的貓就是所謂的“薛定諤貓”。

  JS 引入 let 和 const 之後,也出現了一種有趣的現象:

<!-- 可以拷貝下面的程式碼,放的一個html檔案中,然後使用瀏覽器開啟,檢視控制檯 -->
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      let x = x;
    </script>
    <script>
      x = 2;
      console.log(x);
    </script>
  </body>
</html>

specx

  上面的程式碼裡,我們在第一個 script 裡引入寫了let x = x;,就導致在其他 script 下都無法在全域性作用域下使用 x 變數了(無論是對 x 進行賦值、取值,還是宣告,都不行)。也就是說現在 x 處於一種“既被定義了,又沒被定義”的中間狀態。

  這個問題說明:如果 let x 的初始化過程失敗了,那麼:

  • x 變數就將永遠處於 created 狀態。
  • 你無法再次對 x 進行初始化(初始化只有一次機會,而那次機會你失敗了)。
  • 由於 x 無法被初始化,所以 x 永遠處在暫時死區(也就是盜夢空間裡的 limbo)!
  • 有人會覺得 JS 坑,怎麼能出現這種情況;其實問題不大,因為此時程式碼已經報錯了,後面的程式碼想執行也沒機會。
參考資料:JS 變數封禁大法:薛定諤的 X

第四十三式:聊聊前端錯誤處理

一個 React-dnd 引出的前端錯誤處理

  年初的時候,筆者曾做過一個前端錯誤處理的筆記,事情是這樣的:

  專案中某選單定義的頁面因有拖拽的需求,就引入了React DnD來完成這一工作;隨著業務的更新迭代,部分列表頁面又引入了自定義列的功能,可以通過拖動來對列進行排序,後面就發現在某些頁面上,試圖開啟自定義列的彈窗時,頁面就崩潰白屏了,控制檯會透出錯誤:'Cannot have two HTML5 backends at the same time.'。在排查問題的時候,檢視原始碼發現:

// ...
value: function setup() {
  if (this.window === undefined) {
    return;
  }
  if (this.window.__isReactDndBackendSetUp) {
    throw new Error('Cannot have two HTML5 backends at the same time.');
  }
  this.window.__isReactDndBackendSetUp = true;
  this.addEventListeners(this.window);
}
// ...

  也就是說,react-dnd-html5-backend在建立新的例項前會通過window.__isReactDndBackendSetUp的全域性變數來判斷是否已經存在一個可拖拽元件,如果有的話,就直接報錯,而由於專案裡對應元件沒有相應的錯誤處理邏輯,丟擲的 Error 異常層層上傳到 root,一直沒有被捕獲和處理,最終導致頁面崩潰。其實在當時的業務場景下,這個問題比較好解決,因為選單定義頁面沒有自定義列的需求,而其他頁面自定義列又是通過彈窗展示的,所以不要忘了給自定義列彈窗設定 destroyOnClose 屬性(關閉銷燬)即可。為了避免專案中因為一些錯誤導致系統白屏,在專案中,我們應該合理使用錯誤處理。

前端錯誤處理的方法

1、Error Boundaries

  如何使一個 React 元件變成一個“Error Boundaries”呢?只需要在元件中定義個新的生命週期函式——componentDidCatch(error, info):

error: 這是一個已經被丟擲的錯誤;info:這是一個 componentStack key。這個屬性有關於丟擲錯誤的元件堆疊資訊。
// ErrorBoundary實現
class ErrorBoundary extends React.Component {
  state = { hasError: false };

  componentDidCatch(error, info) {
    // Display fallback UI
    this.setState({ hasError: true });
    // You can also log the error to an error reporting service
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

ErrorBoundary 使用:

// ErrorBoundary使用
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>
Erro Boundaries 本質上也是一個元件,通過增加了新的生命週期函式 componentDidCatch 使其變成了一個新的元件,這個特殊元件可以捕獲其子元件樹中的 js 錯誤資訊,輸出錯誤資訊或者在報錯條件下,顯示預設錯誤頁。注意一個 Error Boundaries 只能捕獲其子元件中的 js 錯誤,而不能捕獲其元件本身的錯誤和非子元件中的 js 錯誤。

  但是 Error Boundaries 也不是萬能的,下面我們來看哪些情況下不能通過 Error Boundaries 來 catch{}錯誤:

  • 元件內部的事件處理函式,因為 Error Boundaries 處理的僅僅是 Render 中的錯誤,而 Hander Event 並不發生在 Render 過程中。
  • 非同步函式中的異常 Error Boundaries 不能 catch,比如 setTimeout 或者 setInterval 、requestAnimationFrame 等函式中的異常。
  • 伺服器端的 rendering
  • 發生在 Error Boundaries 元件本身的錯誤

2、componentDidCatch()生命週期函式:

  componentDidCatch 是一個新的生命週期函式,當元件有了這個生命週期函式,就成為了一個 Error Boundaries。

3、try/catch 模組

  Error Boundaries 僅僅丟擲了子元件的錯誤資訊,並且不能丟擲元件中的事件處理函式中的異常。(因為 Error Boundaries 僅僅能保證正確的 render,而事件處理函式並不會發生在 render 過程中),我們需要用 try/catch 來處理事件處理函式中的異常。

try/catch 只能捕獲到同步的執行時錯誤,對語法和非同步錯誤卻無能為力。

4、window.onerror

  當 JS 執行時錯誤發生時,window 會觸發一個 ErrorEvent 介面的 error 事件,並執行 window.onerror()。

在實際使用過程中,onerror 主要是來捕獲預料之外的錯誤,而 try-catch 則是用來在可預見情況下監控特定的錯誤,兩者結合使用更加高效。
/**
 * @param {String}  message    錯誤資訊
 * @param {String}  source    出錯檔案
 * @param {Number}  lineno    行號
 * @param {Number}  colno    列號
 * @param {Object}  error  Error物件(物件)
 */
window.onerror = function (message, source, lineno, colno, error) {
  console.log('捕獲到異常:', { message, source, lineno, colno, error });
  // window.onerror 函式只有在返回 true 的時候,異常才不會向上丟擲,否則即使是知道異常的發生控制檯還是會顯示 Uncaught Error: xxxxx。
  //  return true;
};

5、window.addEventListener

  主要用於靜態資源載入異常捕獲。

6、Promise Catch

  try..catch..雖然能捕獲錯誤,但是不能捕獲非同步的異常;promise碰到then,也就是resolve或者reject的時候是非同步的,所以try...catch對它是沒有用的。Promise.prototype.catch 方法是用於指定發生錯誤時的回撥函式。

7、unhandledrejection

  當 Promise 被 reject 且沒有 reject 處理器的時候,會觸發 unhandledrejection 事件;這可能發生在 window 下,但也可能發生在 Worker 中。 unhandledrejection 繼承自 PromiseRejectionEvent,而 PromiseRejectionEvent 又繼承自 Event。因此 unhandledrejection 含有 PromiseRejectionEvent 和 Event 的屬性和方法。

總結

  前端元件/專案中,需要有適當的錯誤處理過程,否則出現錯誤,層層上傳,沒有進行捕獲,就會導致頁面掛掉。

第四十四式:不做工具人 —— 使用 nodejs 根據配置自動生成檔案

  筆者在工作中有一個需求是搭建一個 BFF 層專案,實現對每一個介面的許可權控制和轉發到後端底層介面。因為 BFF 層介面邏輯較少,70%情況下都只是實現一個轉發,所以每個檔案相似度較高,但因為每個 API 需單獨控制許可權,所以 API 檔案又必須存在,所以使用 nodejs 編寫 API 自動化生成指令碼,避免進行大量的手動建立檔案和複製修改的操作,示例如下:

  • 編寫自動生成檔案的指令碼:
// auto.js
const fs = require('fs');
const path = require('path');
const config = require('./apiConfig'); // json配置檔案,格式見下面註釋內容
// config的格式如下:
// [
//     {
//         filename: 'querySupplierInfoForPage.js',
//         url: '/supplier/rest/v1/supplier/querySupplierInfoForPage',
//         comment: '分頁查詢供應商檔案-主資訊',
//     },
// ]

// 驗證數量是否一致
// 也可以在此做一些其他的驗證,需要驗證時呼叫這個函式即可
function verify() {
  console.log(
    config.length,
    fs.readdirSync(path.join(__dirname, '/server/api')).length
  );
}

// 生成檔案
function writeFileAuto(filePath, item) {
  fs.writeFileSync(
    filePath,
    `/**
* ${item.comment}
*/
const { Controller, Joi } = require('ukoa');

module.exports = class ${item.filename.split('.')[0]} extends Controller {
    init() {
        this.schema = {
            Params: Joi.object().default({}).notes('引數'),
            Action: Joi.string().required().notes('Action')
        };
    }

    // 執行函式體
    async main() {
        const { http_supply_chain } = this.ctx.galaxy;
        const [data] = await http_supply_chain("${
          item.url
        }", this.params.Params, { throw: true });
        return this.ok = data.obj;
    }
};
`
  );
}

function exec() {
  config.forEach((item) => {
    var filePath = path.join(__dirname, '/server/api/', item.filename);
    fs.exists(filePath, function (exists) {
      if (exists) {
        // 已存在的檔案就不要重複生成了,因為也許你已經對已存在的檔案做了特殊邏輯處理
        //(畢竟只有70%左右的API是純轉發,還有30%左右有自己的處理邏輯)
        console.log(`檔案${item.filename}已存在`);
      } else {
        console.log(`建立檔案:${item.filename}`);
        writeFileAuto(filePath, item);
      }
    });
  });
}

exec();
  • 執行指令碼,生成檔案如下:node auto.js
// querySupplierInfoForPage.js
/**
 * 分頁查詢供應商檔案-主資訊
 */
const { Controller, Joi } = require('ukoa');

module.exports = class querySupplierInfoForPage extends (
  Controller
) {
  init() {
    this.schema = {
      Params: Joi.object().default({}).notes('引數'),
      Action: Joi.string().required().notes('Action'),
    };
  }

  // 執行函式體
  async main() {
    const { http_supply_chain } = this.ctx.galaxy;
    const [
      data,
    ] = await http_supply_chain(
      '/supplier/rest/v1/supplier/querySupplierInfoForPage',
      this.params.Params,
      { throw: true }
    );
    return (this.ok = data.obj);
  }
};

  此處只是拋磚引玉,結合具體業務場景,也許你會為 nodejs 指令碼找到更多更好的用法,為前端賦能。

第四十五式:明明元素存在,我的document.getElementsByTagName('video')卻獲取不到?

  • 使用 Chrome 瀏覽器線上看視訊的時候,有些網站不支援倍速播放;有的網站只支援 1.5 和 2 倍速,但是自己更喜歡 1.75 倍;又或者有些網站需要會員才能倍速播放(比如某盤),一般我們可以通過安裝相應的瀏覽器外掛解決,如果不願意安裝外掛,也可以使用類似document.getElementsByTagName('video')[0].playbackRate = 1.75(1.75 倍速)的方式實現倍速播放,這個方法在大部分網站上是有效的(當然,如果知道 video 標籤的 id 或者 class,通過 id 和 class 來獲取元素會更便捷一點),經測試,playbackRate支援的最大倍速 Chrome 下是 16。同時,給playbackRate設定一個小於 1 的值,比如 0.3,可以模擬出類似鬼片的音效
  • 但是在某盤,這種方法卻失效了,因為我沒有辦法獲取到 video 元素,審查元素如下:
    videojs

  審查元素時,我們發現了#shadow-root (closed)videojs的存在。也許你還記得,在第六式中我們曾簡單探討過Web Components,其中介紹到attachShadow()方法可以開啟 Shadow DOM(這部分 DOM 預設與外部 DOM 隔離,內部任何程式碼都無法影響外部,避免樣式等的相互干擾),隱藏自定義元素的內部實現,我們外部也沒法獲取到相應元素,如下圖所以(點選圖片跳轉 Web Components 示例程式碼):

shadow

  是以,我們可以合理推斷,某盤的網頁視訊播放也使用了類似Element.attachShadow()方法進行了元素隱藏,所以我們無法通過document.getElementsByTagName('video')獲取到 video 元素。通過閱讀videojs 文件發現,可以通過相應 API 實現自定義倍速播放:

videojs.getPlayers('video-player').html5player.tech_.setPlaybackRate(1.666);
參考資料:百度網盤視訊倍速播放方法 | videojs 文件 | Element.attachShadow() | 深入理解 Shadow DOM v1

第四十六式:SQL 也可以 if else? —— 不常寫 SQL 的我神奇的知識增加了

  在刷 leetcode 的時候遇到一個 SQL 題目627. 變更性別,題目要求如下:

給定一個  salary  表,有 m = 男性 和 f = 女性 的值。交換所有的 f 和 m 值(例如,將所有 f 值更改為 m,反之亦然)。要求只使用一個更新(Update)語句,並且沒有中間的臨時表。注意,您必只能寫一個 Update 語句,請不要編寫任何 Select 語句。
  UPDATE salary
    SET
      sex = CASE sex
          WHEN 'm' THEN 'f'
          ELSE 'm'
        END;
參考資料:SQL 之 CASE WHEN 用法詳解

第四十七式:庭院深深深幾許,楊柳堆煙,簾幕無重數 —— 如何實現深拷貝?

  深拷貝,在前端面試裡似乎是一個永恆的話題了,最簡單的方法是JSON.stringify()以及JSON.parse(),但是這種方法能正確處理的物件只有 Number, String, Boolean, Array, 扁平物件,不可以拷貝 undefined , function, RegExp 等型別。還有其他一些包括擴充套件運算子、object.asign、遞迴拷貝、lodash 庫等的實現,網上有很多相關資料和實現,這裡不是我們討論的重點。這次我們來探討一個新的實現 —— MessageChannel。我們直接看程式碼:

// 建立一個obj物件,這個物件中有 undefined 和 迴圈引用
let obj = {
  a: 1,
  b: {
    c: 2,
    d: 3,
  },
  f: undefined,
};
obj.c = obj.b;
obj.e = obj.a;
obj.b.c = obj.c;
obj.b.d = obj.b;
obj.b.e = obj.b.c;

// 深拷貝方法封裝
function deepCopy(obj) {
  return new Promise((resolve) => {
    const { port1, port2 } = new MessageChannel();
    port1.postMessage(obj);
    port2.onmessage = (e) => resolve(e.data);
  });
}

// 呼叫
deepCopy(obj).then((copy) => {
  // 請記住`MessageChannel`是非同步的這個前提!
  let copyObj = copy;
  console.log(copyObj, obj);
  console.log(copyObj == obj);
});

  我們發現MessageChannelpostMessage傳遞的資料也是深拷貝的,這和web workerpostMessage一樣。而且還可以拷貝 undefined 和迴圈引用的物件。簡單說,MessageChannel建立了一個通訊的管道,這個管道有兩個埠,每個埠都可以通過postMessage傳送資料,而一個埠只要繫結了onmessage回撥方法,就可以接收從另一個埠傳過來的資料。

需要說明的一點是:MessageChannel在拷貝有函式的物件時,還是會報錯。

參考資料:MessageChannel | MessageChannel 是什麼,怎麼使用?

第四十八式:換了電腦,如何使用 VSCode 儲存外掛配置?

  也許每一個冇得感情的 API 呼叫工程師在使用 VSCode 進行開發時,都有自己的外掛、個性化配置以及程式碼片段等,使用 VSCode 不用登陸,不用註冊賬號,確實很方便,但這同時也帶來一個問題:如果你有多臺電腦,比如家裡一個、公司一個,都會用來開發;又或者,你離職入職了新的公司。此時,我們就需要從頭再次配置一遍 VSCode,包括外掛、配置、程式碼片段,如此反覆,也許真的會崩潰。其實 VSCode 提供了 setting sync 外掛,來方便我們同步外掛配置。具體使用如下:

  • 在 VSCode 中搜尋 Settings Sync 並進行安裝;
  • 安裝後,摁下 Ctrl(mac 為 command)+ Shift + P 開啟控制皮膚,搜尋 Sync,選擇 Sync: Update/Upload Settings 可以上傳你的配置,選擇 Sync: Download Settings 會下載遠端配置;
  • 如果你之前沒有使用過 Settings Sync,在上傳配置的時候,會讓你在 Github 上建立一個授權碼,允許 IDE 在你的 gist 中建立資源;下載遠端配置,你可以直接將 gist 的 id 填入。
  • 下載後等待安裝,然後重啟即可。

  如此以來,我們就可以在多臺裝置間同步配置了。

參考資料:Settings Sync | VSCode 儲存外掛配置並使用 gist 管理程式碼片段

第四十九式:防止物件被篡改,可以試試 Object.seal 和 Object.freeze

  有時候你可能怕你的物件被誤改了,所以需要把它保護起來。

  • Object.seal 防止新增和刪除屬性

  通常,一個物件是可擴充套件的(可以新增新的屬性)。使用Object.seal()方法封閉一個物件會讓這個物件變的不能新增新屬性,且所有已有屬性會變的不可配置。屬性不可配置的效果就是屬性變的不可刪除,以及一個資料屬性不能被重新定義成為訪問器屬性,或者反之。當前屬性的值只要原來是可寫的就可以改變。嘗試刪除一個密封物件的屬性或者將某個密封物件的屬性從資料屬性轉換成訪問器屬性,結果會靜默失敗或丟擲 TypeError。

資料屬性包含一個資料值的位置,在這個位置可以讀取和寫入值。訪問器屬性不包含資料值,它包含一對 getter 和 setter 函式。當讀取訪問器屬性時,會呼叫 getter 函式並返回有效值;當寫入訪問器屬性時,會呼叫 setter 函式並傳入新值,setter 函式負責處理資料。
const person = {
  name: 'jack',
};
Object.seal(person);
delete person.name;
console.log(person); // {name: "jack"}
  • Object.freeze 凍結物件

  Object.freeze() 方法可以凍結一個物件。一個被凍結的物件再也不能被修改;凍結了一個物件則不能向這個物件新增新的屬性,不能刪除已有屬性,不能修改該物件已有屬性的可列舉性、可配置性、可寫性,以及不能修改已有屬性的值。此外,凍結一個物件後該物件的原型也不能被修改。freeze() 返回和傳入的引數相同的物件。

const obj = {
  prop: 42,
};
Object.freeze(obj);
obj.prop = 33;
// Throws an error in strict mode
console.log(obj.prop);
// expected output: 42
Tips:Object.freeze淺凍結,即只凍結一層,要使物件不可變,需要遞迴凍結每個型別為物件的屬性(深凍結)。使用Object.freeze()凍結的物件中的現有屬性值是不可變的。用Object.seal()密封的物件可以改變其現有屬性值。同時可以使用 Object.isFrozenObject.isSealedObject.isExtensible 判斷當前物件的狀態。
  • Object.defineProperty 凍結單個屬性:設定 enumable/writable 為 false,那麼這個屬性將不可遍歷和寫。
參考資料:JS 高階技巧 | javascript 的資料屬性和訪問器屬性 | Object.freeze() | Object.seal() | 深入淺出 Object.defineProperty()

第五十式:不隨機的隨機數 —— 我們都知道Math.random是偽隨機的,那如何得到密碼學安全的隨機數

  在 JavaScript 中產生隨機數的方式是呼叫 Math.random,這個函式返回[0, 1)之間的數字,我們通過對Math.random的包裝處理,可以得到我們想要的各種隨機值。

  • 怎麼實現一個隨機數發生器
// from stackoverflow
// 下面的實現還是很隨機的
let seed = 1;
function random() {
  let x = Math.sin(seed++) * 10000;
  return x - Math.floor(x);
}

  隨機數發生器函式需要一個種子 seed,每次呼叫 random 函式的時候種子都會發生變化。因為random()是一個沒有輸入的函式,不管執行多少次,其執行結果都是一樣的,所以需要有一個不斷變化的入參,這個入參就叫種子,每執行一次種子就會發生一次變化。所以我們可以藉助以上思路實現自己的隨機數發生器(或許有些場合,我們不必管他是不是真的是隨機的,再或者就是要讓他不隨機呢)。

  • 為什麼說 Math.random 是不安全的呢?

  V8 原始碼顯示 Math.random 種子的可能個數為 2 ^ 64, 隨機演算法相對簡單,只是保證儘可能的隨機分佈。我們知道撲克牌有 52 張,總共有 52! = 2 ^ 226 種組合,如果隨機種子只有 2 ^ 64 種可能,那麼可能會有大量的組合無法出現。

  從 V8 裡 Math.random 的實現邏輯來看,每次會一次性產生 128 個隨機數,並放到 cache 裡面,供後續使用,當 128 個使用完了再重新生成一批隨機數。所以 Math.random 的隨機數具有可預測性,這種由演算法生成的隨機數也叫偽隨機數。只要種子確定,隨機演算法也確定,便能知道下一個隨機數是什麼。具體可參考隨機數的故事

  • Crypto.getRandomValues()

  Crypto.getRandomValues() 方法讓你可以獲取符合密碼學要求的安全的隨機值。傳入引數的陣列被隨機值填充(在加密意義上的隨機)。window.crypto.getRandomValue的實現在 Safari,Chrome 和 Opera 瀏覽器上是使用帶有 1024 位種子的ARC4流密碼。

var array = new Uint32Array(10);
window.crypto.getRandomValues(array);

console.log('Your lucky numbers:');
for (var i = 0; i < array.length; i++) {
  console.log(array[i]);
}
參考資料:隨機數的故事 | Crypto.getRandomValues() | 如何使用 window.crypto.getRandomValues 在 JavaScript 中呼叫撲克牌?

第五十一式:forEach 只是對 for 迴圈的簡單封裝?你理解的 forEach 可能並不正確

  我們先看看下面這個forEach的實現:

Array.prototype.forEachCustom = function (fn, context) {
  context = context || arguments[1];
  if (typeof fn !== 'function') {
    throw new TypeError(fn + 'is not a function');
  }

  for (let i = 0; i < this.length; i++) {
    fn.call(context, this[i], i, this);
  }
};

  我們發現,上面的程式碼實現其實只是對 for 迴圈的簡單封裝,看起來似乎沒有什麼問題,因為很多時候,forEach 方法是被用來代替 for 迴圈來完成陣列遍歷的。其實不然,我們再看看下面的測試程式碼:

//  示例1
const items = ['', 'item2', 'item3', , undefined, null, 0];
items.forEach((item) => {
  console.log(item); //  依次列印:'',item2,item3,undefined,null,0
});
items.forEachCustom((item) => {
  console.log(item); // 依次列印:'',item2,item3,undefined,undefined,null,0
});
// 示例2
let arr = new Array(8);
arr.forEach((item) => {
  console.log(item); //  無列印輸出
});
arr[1] = 9;
arr[5] = 3;
arr.forEach((item) => {
  console.log(item); //  列印輸出:9 3
});
arr.forEachCustom((item) => {
  console.log(item); // 列印輸出:undefined 9 undefined*3  3 undefined*2
});

  我們發現,forEachCustom 和原生的 forEach 在上面測試程式碼的執行結果並不相同。關於各個新特性的實現,其實我們都可以在 ECMA 文件中找到答案:

forEach

  我們可以發現,真正執行遍歷操作的是第 8 條,通過一個 while 迴圈來實現,迴圈的終止條件是前面獲取到的陣列的長度(也就是說後期改變陣列長度不會影響遍歷次數),while 迴圈裡,會先把當前遍歷項的下標轉為字串,通過 HasProperty 方法判斷陣列物件中是否有下標對應的已初始化的項,有的話,獲取對應的值,執行回撥,沒有的話,不會執行回撥函式,而是直接遍歷下一項

  如此看來,forEach 不對未初始化的值進行任何操作(稀疏陣列),所以才會出現示例 1 和示例 2 中自定義方法列印出的值和值的數量上均有差別的現象。那麼,我們只需對前面的實現稍加改造,即可實現一個自己的 forEach 方法:

Array.prototype.forEachCustom = function (fn, context) {
  context = context || arguments[1];
  if (typeof fn !== 'function') {
    throw new TypeError(fn + 'is not a function');
  }

  let len = this.length;
  let k = 0;
  while (k < len) {
    // 下面是兩種實現思路,ECMA文件使用的是HasProperty,在此,使用in應該比hasOwnProperty更確切
    // if (this.hasOwnProperty(k)) {
    //   fn.call(context, this[k], k, this);
    // };
    if (k in this) {
      fn.call(context, this[k], k, this);
    }
    k++;
  }
};

  再次執行示例 1 和示例 2 的測試用列,發現輸出和原生 forEach 一致。

  通過文件,我們還發現,在迭代前 while 迴圈的次數就已經定了,且執行了 while 迴圈,不代表就一定會執行回撥函式,我們嘗試在迭代時修改陣列:

// 示例3
var words = ['one', 'two', 'three', 'four'];
words.forEach(function (word) {
  console.log(word); // one,two,four(在迭代過程中刪除元素,導致three被跳過,因為three的下標已經變成1,而下標為1的已經被遍歷了過)
  if (word === 'two') {
    words.shift();
  }
});
words = ['one', 'two', 'three', 'four']; // 重新初始化陣列進行forEachCustom測試
words.forEachCustom(function (word) {
  console.log(word); // one,two,four
  if (word === 'two') {
    words.shift();
  }
});
// 示例4
var arr = [1, 2, 3];
arr.forEach((item) => {
  if (item == 2) {
    arr.push(4);
    arr.push(5);
  }
  console.log(item); // 1,2,3(迭代過程中在末尾增加元素,並不會使迭代次數增加)
});
arr = [1, 2, 3];
arr.forEachCustom((item) => {
  if (item == 2) {
    arr.push(4);
    arr.push(5);
  }
  console.log(item); // 1,2,3
});

  以上過程啟示我們,在工作中碰見和我們預期存在差異的問題時,我們完全可以去ECMA 官方文件中尋求答案。

這裡可以參考筆者之前的一篇文章:JavaScript 很簡單?那你理解的 forEach 真的對嗎?

第五十二式:Git 檔名大小寫敏感問題,你栽過坑嗎?

  筆者大約兩年前剛用 Mac 開發前端時曾經遇到一個坑:程式碼在本地執行 ok,但是發現 push 到 git,自動部署後報錯了,排查了很久,最後發現有個檔名沒有注意大小寫,重新命名了該檔案,但是 git 沒有識別到這個更改,導致自動部署後找不到這個檔案。解決辦法如下:

  • 檢視 git 的設定:git config –get core.ignorecase
  • git 預設是不區分大小的,因此當你修改了檔名/資料夾的大小寫後,git 並不會認為你有修改(git status 不會提示你有修改)
  • 更改設定解決:git config core.ignorecase false

  這麼以來,git 就能識別到檔名大小寫的更改了。在次建議,平時我們在使用 React 編寫專案時,檔名最好保持首字母大寫。

參考:在 Git 中當更改一個檔名為首字母大寫時

第五十三式:你看到的0.1其實並不是真的0.1 —— 老生長談的 0.1 + 0.2 !== 0.3,這次我們說點不一樣的

  0.1 + 0.2 !== 0.3是一個老生長談的問題來,想必你也明白其中的根源:JS 採用 IEEE 754 雙精度版本(64 位),並且只要採用 IEEE 754 的語言都有這樣的問題。詳情可檢視筆者之前的一篇文章0.1 + 0.2 != 0.3 背後的原理,本節我們只探討解法。

  • 既然IEEE 754存在精度問題,那為什麼 x=0.1 能得到 0.1

  因為在浮點數的儲存中, mantissa(尾數) 固定長度是 52 位,再加上省略的一位,最多可以表示的數是 2^53=9007199254740992,對應科學計數尾數是 9.007199254740992,這也是 JS 最多能表示的精度。它的長度是 16,所以可以使用 toPrecision(16) 來做精度運算,超過的精度會自動做湊整處理。於是便有:

0.10000000000000000555.toPrecision(16)
// 返回 0.1000000000000000,去掉末尾的零後正好為 0.1

// 但你看到的 `0.1` 實際上並不是 `0.1`。不信你可用更高的精度試試:
0.1.toPrecision(21) = 0.100000000000000005551

toPrecision

  • toFixed設定精確位數

  toFixed() 方法可把 Number 四捨五入為指定小數位數的數字,語法:NumberObject.toFixed(num)

// 保留兩位小數
console.log((0.1 + 0.2).toFixed(2)); // 0.30
  • Number.EPSILON

  想必你還有印象,在高中數學或者大學數學分析、數值逼近中,在證明兩個值相等的時候,我們會讓他們的差去逼近一個任意小的數。那麼,在此自然可以想到讓 0.1 + 0.2 的和減去 0.3 小於一個任意小的數,比如說我們可以通過他們差值是否小於 0.0000000001 來判斷他們是否相等。

  其實 ES6 已經在 Number 物件上面,新增一個極小的常量 Number.EPSILON。根據規則,它表示 1 與大於 1 的最小浮點數之間的差。Number.EPSILON 實際上是 JavaScript 能夠表示的最小精度。誤差如果小於這個值,就可以認為已經沒有意義了,即不存在誤差了。

console.log(0.1 + 0.2 - 0.3 < Number.EPSILON); // true
  • 轉換成整數或者字串再進行求和運算

  為了避免產生精度差異,我們要把需要計算的數字乘以 10 的 n 次冪,換算成計算機能夠精確識別的整數,然後再除以 10 的 n 次冪,大部分程式語言都是這樣處理精度差異的,我們就借用過來處理一下 JS 中的浮點數精度誤差。

傳入 n 次冪的 n 值:

formatNum = function (f, digit) {
  var m = Math.pow(10, digit);
  return parseInt(f * m, 10) / m;
};
var num1 = 0.1;
var num2 = 0.2;
console.log(num1 + num2);
console.log(formatNum(num1 + num2, 1));

自動計算 n 次冪的 n 值:

/**
 * 精確加法
 */
function add(num1, num2) {
  const num1Digits = (num1.toString().split('.')[1] || '').length;
  const num2Digits = (num2.toString().split('.')[1] || '').length;
  const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
}
add(0.1,0.2); // 0.3
  • 使用類庫:

  通常這種對精度要求高的計算都應該交給後端去計算和儲存,因為後端有成熟的庫來解決這種計算問題。前端也有幾個不錯的類庫:

參考資料:JavaScript 浮點數運算的精度問題 | JavaScript 浮點數陷阱及解法

第五十四式:發版提醒全靠吼 —— 如何純前端實現頁面檢測更新並提示?

  開發過程中,經常遇到頁面更新、版本釋出時,需要告訴使用人員重新整理頁面的情況,甚至有些運營、測試人員覺得切換一下選單再切回去就是更新了 web 頁面資源,有的分不清普通重新整理和強刷的區別,所以實現了一個頁面更新檢測功能,頁面更新了定時自動提示使用人員重新整理頁面。

  基本思路為:使用 webpack 配置打包編譯時在 js 檔名裡新增 hash,然後使用 js 向${window.location.origin}/index.html傳送請求,解析出 html 檔案裡引入的 js 檔名稱 hash,對比當前 js 的 hash 與新版本的 hash 是否一致,不一致則提示使用者更新版本。

// uploadUtils.jsx
import React from 'react';
import axios from 'axios';
import { notification, Button } from 'antd';

// 彈窗是否已展示(可以改用閉包、單例模式等實現,看起來會更有逼格一點)
let uploadNotificationShow = false;

// 關閉notification
const close = () => {
  uploadNotificationShow = false;
};

// 重新整理頁面
const onRefresh = (new_hash) => {
  close();
  // 更新localStorage版本號資訊
  window.localStorage.setItem('XXXSystemFrontVesion', new_hash);
  // 重新整理頁面
  window.location.reload(true);
};

// 展示提示彈窗
const openNotification = (new_hash) => {
  uploadNotificationShow = true;
  const btn = (
    <Button type='primary' size='small' onClick={() => onRefresh(new_hash)}>
      確認更新
    </Button>
  );
  // 這裡不自動執行更新的原因是:
  // 考慮到也許此時使用者正在使用系統甚至填寫一個很長的表單,那你直接重新整理了頁面,或許會被掐死的,哈哈
  notification.open({
    message: '版本更新提示',
    description: '檢測到系統當前版本已更新,請重新整理後使用。',
    btn,
    // duration為0時,notification不自動關閉
    duration: 0,
    onClose: close,
  });
};

// 獲取hash
export const getHash = () => {
  // 如果提示彈窗已展示,就沒必要執行接下來的檢查邏輯了
  if (!uploadNotificationShow) {
    // 在 js 中請求首頁地址,這樣不會重新整理介面,也不會跨域
    axios
      .get(`${window.location.origin}/index.html?time=${new Date().getTime()}`)
      .then((res) => {
        // 匹配index.html檔案中引入的js檔案是否變化(具體正則,視打包時的設定及檔案路徑而定)
        let new_hash = res.data && res.data.match(/\/static\/js\/main.(.*).js/);
        // console.log(res, new_hash);
        new_hash = new_hash ? new_hash[1] : null;
        // 檢視本地版本
        let old_hash = localStorage.getItem('XXXSystemFrontVesion');
        if (!old_hash) {
          // 如果本地沒有版本資訊(第一次使用系統),則直接執行一次額外的重新整理邏輯
          onRefresh(new_hash);
        } else if (new_hash && new_hash != old_hash) {
          // 本地已有版本資訊,但是和新版不同:需更新版本,彈出提示
          openNotification(new_hash);
        }
      });
  }
};

使用示例:

import { getHash } from './uploadUtils';

let timer = null;
componentDidMount() {
    getHash();
    timer = setInterval(() => {
      getHash();
      // 10分鐘檢測一次
    }, 600000)
  }

  componentWillUnmount () {
      // 頁面解除安裝時記得清除
    clearInterval(timer);
  }

  結合Console Importer直接在控制檯皮膚檢視:

uploadpage

  你也完全可以在上面的方法上更上一層樓,build 的時候,在 index.html 同級目錄下,自動生成一個 json 檔案,包含新的檔案的 hash 資訊,檢查版本的時候,就只需直接請求這個 json 檔案進行對比了,減少冗餘資料的傳遞。

參考資料:純前端實現頁面檢測更新提示

本文首發於個人部落格,歡迎指正和star

相關文章