精通前端 polyfill ,相容各瀏覽器執行E6語法

chup發表於2019-05-07

ES6 在2015正式釋出已經多年。最新瀏覽器們逼近100% 的支援率,但為了少數使用者體驗,我們很可能需要相容IE9。Babel預設只轉換新的JavaScript語法,而不轉換新的API,這時我們就需要提供polyfill。

babel 和 polyfill

剛接觸 babel 的同學可能都認為在使用了 babel 後就可以無痛的使用 ES6 了,之後被各種 undefined 的報錯無情打臉。一句話概括, babel 的編譯不會做 polyfill。那麼 polyfill 是指什麼呢? 翻譯: 一種用於衣物、床具等的填充材料

const foo = (a, b) => {
    return Object.assign(a, b);
};
複製程式碼

當我們寫出上面這樣的程式碼,交給 babel 編譯時,我們得到了:

"use strict";
 var foo = function foo(a, b) {
    return Object.assign(a, b);
 };
複製程式碼

箭頭 function 被編譯成了普通函式,但丫的 Object.assign 還沒變身,而它作為 ES6 的新方法,並不能在IE9等瀏覽器上。為什麼不把 Object.assign 編譯成 (Object.assign||function() { /*...*/}) 這樣的替代方法呢?好問題!編譯為了保證正確的語義,只轉換語法而不是去增加或修改原有的屬性和方法。所以 babel 不處理 Object.assign 反倒是最正確的做法。而處理這些方法的方案則稱為 polyfill。

babel-plugin-transform-xxx

這個問題最原始解決思路是缺什麼補什麼,babel 提供了一系列 transform 的外掛來解決這個問題,例如針對 Object.assign,我們可以使用 babel-plugin-transform-object-assign:

npm i babel-plugin-transform-object-assign

# in .babelrc
{
  "presets": ["latest"],
  "plugins": ["transform-object-assign"]
}
複製程式碼

方便你嘗試,這裡準備了一些測試的程式碼。編譯之前的程式碼,我們得到了:

var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };

var foo = exports.foo = function foo(a, b) {
  return _extends(a, b);
};
複製程式碼

babel-plugin-transform-object-assign 在我們用到 Object.assign 方法之前使用ES5或更早的寫法替換了。看上去效果不錯,但細細考究一下會發現這樣的問題:

// another.js
export const bar = (a, b) => Object.assign(a, b);

// index.js
import { bar } from './another';

export const foo = (a, b) => Object.assign(a, b);
複製程式碼

被編譯成了:

/***/ index.js:
/***/ (function(module, exports, __webpack_require__) {

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.foo = undefined;

var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };

var _another = __webpack_require__(212);

var foo = exports.foo = function foo(a, b) {
  return _extends(a, b);
};

/***/ }),

/***/ another.js:
/***/ (function(module, exports, __webpack_require__) {

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});

var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; };

var bar = exports.bar = function bar(a, b) {
  return _extends(a, b);
};

/***/ })
複製程式碼

plugin-transform 的引用是 module 級別的,意味著在多個 module 使用時會重複的引用,這在多檔案的專案裡可能帶來災難。且也不想一個個的去新增需要用的 plugin,如果能自動引入該多好。

babel-runtime & babel-plugin-transform-runtime

前面提到問題主要在於方法的引入方式是內聯的,直接插入了一行程式碼從而無法優化。鑑於這樣的考慮,babel 提供了 babel-plugin-transform-runtime,從一個統一的地方 core-js 自動引入對應的方法。

npm i -D babel-plugin-transform-runtime
npm i babel-runtime

# .babelrc
{
  "presets": ["latest"],
  "plugins": ["transform-runtime"]
}
複製程式碼
  • 安裝開發時的依賴 babel-plugin-transform-runtime。
  • 安裝生產環境的依賴 babel-runtime (是否要在生產環境也依賴它取決於你釋出程式碼的方式,簡單點直接放在 dependency 裡總沒錯)

一切就緒,編譯時它會自動引入你用到的方法。但自動就意味著不一定精確

export const foo = (a, b) => Object.assign(a, b);

export const bar = (a, b) => {
    const o = Object;
    const c = [1, 2, 3].includes(3);
    return c && o.assign(a, b);
};
複製程式碼

會編譯成:

var _assign = __webpack_require__(214);
var _assign2 = _interopRequireDefault(_assign);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

var foo = exports.foo = function foo(a, b) {
    return (0, _assign2.default)(a, b);
};

var bar = exports.bar = function bar(a, b) {
    var o = Object;
    var c = [1, 2, 3].includes(3);
    return c && o.assign(a, b);
};
複製程式碼

foo 中的 assign 會被替換成 require 來的方法,而 bar 中這樣非直接呼叫的方式則無能為力了。同時,因為 babel-plugin-transform-runtime 依然不是全域性生效的,因此例項化的物件方法則不能被 polyfill,比如 [1,2,3].includes 這樣依賴於全域性 Array.prototype.includes 的呼叫依然無法使用。

babel-polyfill

上面兩種 polyfill 方案共有的缺陷在於作用域。因此 babel 直接提供了通過改變全域性來相容 es2015 所有方法的 babel-polyfill,安裝 babel-polyfill 後你只需要在main.js加一句 import 'babel-polyfill' 便可引入它,如果使用了 webpack 也可以直接在 entry 中新增 babel-polyfill 的入口。

import 'babel-polyfill';

export const foo = (a, b) => Object.assign(a, b);
複製程式碼

加入 babel-polyfill 後,打包好的 pollyfill.js 一下子增加到了 251kb(未壓縮),(建議感興趣的同學把程式碼拉下來執行一下,之後提到的所有方式也都可以看到打包結果)搜尋一下 polyfill.js 不難找到這樣的全域性修改:

//polyfill
`$export($export.S + $export.F, 'Object', {assign: __webpack_require__(79)});
複製程式碼

babel-polyfill 在專案程式碼前插入所有的 polyfill 程式碼,為你的程式打造一個完美的 es2015 執行環境。babel 建議在網頁應用程式裡使用 babel-polyfill,只要不在意它略有點大的體積(min 後 86kb),直接用它肯定是最穩妥的。值得注意的是,因為 babel-polyfill 帶來的改變是全域性的,所以無需多次引用,也有可能因此產生衝突,所以最好還是把它抽成一個 common module,放在專案 的 vendor 裡,或者乾脆直接抽成一個檔案放在 cdn 上。

如果你是在開發一個庫或者框架,那麼 babel-polyfill 的體積就有點大了,尤其是在你實際使用的只有一個 Object.assign 的情況下。更可怕的是對於一個庫來說,改變全域性環境是使不得的。誰也不希望使用了你的庫,還附帶了一家老小的 polyfill 改變了全域性物件。這時不汙染全域性環境的 babel-plugin-transform-runtime 才是最合適的。

babel-preset-env

回到應用開發。通過babel-runtime自動識別程式碼引入 polyfill 來優化不太靠譜,那是不是就無從優化了呢?並不是。還記得 babel 推薦使用的 babel-preset-env 麼?它可以根據指定目標環境判斷需要做哪些編譯。babel-preset-env 也支援針對指定目標環境選擇需要的 polyfill 了,只需引入 babel-polyfill,並在 babelrc 中宣告 useBuiltIns,babel 會將引入的 babel-polyfill 自動替換為所需的 polyfill。

# .babelrc
{
  "presets": [
    ["env", {
      "targets": {
        "browsers": ["IE >= 9"]
      },
      "useBuiltIns": true
    }]
  ]
}
複製程式碼

對比 "IE >= 9" 和 "chrome >= 59" 環境下編譯後的檔案大小:

               Asset     Size  Chunks           
         polyfill.js   252 kB       0  [emitted]  [big]
              ie9.js   189 kB       1  [emitted]
           chrome.js  30.5 kB       2  [emitted]
transform-runtime.js  17.3 kB       3  [emitted]
transform-plugins.js  3.48 kB       4  [emitted]
複製程式碼

在目前 IE9 的需求下能節省到將近 30%,但想不到瀏覽器之神 chrome 也還需要 30kb 的 polyfill,可能是為了修正那些 v8 的一些細小的規範問題吧。

polyfill.io

以上本應該已經夠用了,但本質上還是讓那些願意使用最新瀏覽器的優質使用者們做了犧牲。聰明的你可能已經想到了一種優化方案,針對瀏覽器來選擇 polyfill。沒錯!polyfill.io 給出的一項服務。

你可以嘗試在不同的瀏覽器下請求 https://cdn.polyfill.io/v2/polyfill.js 這個檔案,伺服器會判斷瀏覽器 UA 返回不同的 polyfill 檔案,你所要做的僅僅是在頁面上引入這個檔案,polyfill 這件事就自動以最優雅的方式解決了。更加讓人喜悅的是,polyfill.io 不旦提供了 cdn 的服務,也開源了自己的實現方案 polyfill-service。簡單配置一下,便可擁有自己的 polyfill service 了。

看上去一切都很美好,但在使用之前還請你多考慮一下。polyfill.io 面對國內奇葩的瀏覽器環境能不能把 UA 算準,如果缺失了 polyfill 還有沒有什麼補救方案,也許都是你需要考慮的。但無論如何,這是個優秀的想法和方案,或許未來也會有更多的網站採用 polyfill.io 的思路的。比如 theguardian 和 redux 作者 Dan 在 create-react-app 上的提議(雖然沒被接受哈~)。

相關文章