本文從Cocos Creator 開發的角度出發,仔細探討了關注 JavaScript API 兼容性的必要性,以及如何藉助工具和 Polyfill 來規避 Cocos Creator 項目的兼容性問題。
一、引言:JavaScript虛擬機的差異性
不同的瀏覽器和移動設備所使用的 JavaScript 虛擬機(VM)千差萬別,所支持的 API 也大相徑庭。
我們來了解一下 Cocos Creator 在各個端所使用的 JavaScript VM :
- 對於
iOS客户端和Mac客户端:在Cocos Creator 1.6及以前,Cocos Creator一直是使用非系統原生的SpiderMonkey作為JS VM;從1.7開始,Cocos Creator引入了JSB 2.0,支持了V8、JavaScriptCore等多種JS VM。於是Cocos Creator便將iOS端和Mac端的JS VM都改為了系統自帶的JavaScriptCore,以達到節省包體的目的;到了2.1.3,Cocos Creator又將Mac端的JS VM切換到了V8,以提升應用性能。 - 對於
Android客户端和Windows客户端:在Cocos Creator 1.6及以前,Cocos Creator同樣是使用SpiderMonkey作為JS VM;從1.7開始,得益於JSB 2.0,V8成了Android和Windows客户端的JS VM。 - 對於
Web端:使用瀏覽器本身的JavaScript VM來解析JavaScript代碼。
從上可見,由於 JS VM 不同,同一份代碼在不同的平台上運行可能會有很大差異。為了讓我們的產品能夠給儘可能多的用户使用,我們在開發階段就需要時刻注意 JavaScript 的 API 兼容性。
舉個例子:fetch() 方法是一個用來取代 XMLHTTPRequest 的 API。相比後者,它的優點在於可讀性更高,且可以很方便地使用 Promise 寫出更優雅的代碼。
fetch(
'http://domain/service',
{ method: 'GET' }
)
.then( response => response.json() )
.then( json => console.log(json) )
.catch( error => console.error('error:', error) );
然而,fetch() 方法不支持所有的 IE 瀏覽器,也無法在 2017 年以前的 Chrome、Firefox 和 Safari 版本上運行。當你的用户有很大一部分是上述的用户時,你就需要考慮禁止使用 fetch() API ,而重新回到 XMLHTTPRequest 的懷抱。
在開發階段,人工保證 API 的兼容性是不可靠的。更可靠的方式是藉助工具來自動化掃描。例如下面要介紹的 eslint-plugin-compat 。
二、使用 eslint-plugin-compat
eslint-plugin-compat 是 ESLint 的一個插件,由前 uber 工程師 Amila Welihinda 開發。它可以幫助發現代碼中的不兼容 API 。
下面介紹如何在工程中接入 eslint-plugin-compat 。
2.1 安裝 eslint-plugin-compat
安裝 eslint-plugin-compat 和安裝其他 ESLint 插件類似:
$ npm install eslint-plugin-compat --save-dev
還可以順便把依賴的 browserslist 和 caniuse-lite 一起安裝了:
$ npm install browserslist caniuse-lite --save-dev
2.2 修改 ESLint 配置
之後,我們需要修改 ESLint 的配置,加上該插件的使用:
// .eslintrc.json
{
"extends": "eslint:recommended",
"plugins": [
"compat"
],
"rules": {
//...
"compat/compat": 2
},
"env": {
"browser": true
// ...
},
// ...
}
2.3 配置目標運行環境
通過在 package.json 中增加 browserslist 字段來配置目標運行環境。示例:
{
// ...
"browserslist": ["chrome 70", "last 1 versions", "not ie <= 8"]
}
上面的值表示 Chrome 版本 70 以上,或每種瀏覽器的最近一個版本,或者非 ie 8 及以下。這裏的填寫格式是遵循 browserslist (https://github.com/browsersli... )所定義的一套描述規範。browserslist 是一套描述產品目標運行環境的工具,它被廣泛用在各種涉及瀏覽器/移動端的兼容性支持工具中,例如 eslint-plugin-compat 、babel、Autoprefixer 等。下面我們來詳細瞭解一下 browserslist 的描述規範。
browserslist 支持指定目標瀏覽器類型,並且能夠靈活組合多種指定條件。
指定目標瀏覽器類型
browserslist 收錄瞭如下一些瀏覽器,可以在條件中使用(注意大小寫敏感):
Android:用於Android WebView。Baidu:用於百度瀏覽器。BlackBerry或bb:用於黑莓瀏覽器。Chrome:用於Google Chrome。ChromeAndroid或and_chr:用於Android Chrome。Edge:用於Microsoft Edge。Electron:用於Electron framework。 將會被轉換成Chrome版本。Explorer或ie:用於Internet Explorer。ExplorerMobile或ie_mob:用於Internet Explorer Mobile。Firefox或ff:用於Mozilla Firefox。FirefoxAndroid或and_ff:用於Android Firefox。iOS或ios_saf:用於iOS Safari。Node:用於Node.js。Opera:用於Opera。OperaMini或op_mini:用於Opera Mini。OperaMobile或op_mob:用於Opera Mobile。QQAndroid或and_qq:用於Android QQ瀏覽器。Safari:用於 desktop Safari。Samsung:用於 Samsung Internet。UCAndroid或and_uc:用於 Android UC 瀏覽器。kaios:用於KaiOS瀏覽器。
browseslist 的條件語法
browserslist 支持非常靈活的條件語法,下面給出一些例子作為參考(注意大小寫敏感),供讀者們舉一反三。
> 5%:表示要兼容全球用户統計比例 > 5% 的瀏覽器版本。>=、<及<=也都是可用的。> 5% in US:表示要兼容美國用户統計比例 > 5% 的瀏覽器版本。這裏的US是美國的 Alpha-2 編碼 1。也可以換成其他國家/地區的 Alpha-2 編碼。例如,中國就是CN。> 5% in alt-AS:表示要兼容亞洲用户統計比例 > 5% 的瀏覽器版本。這裏的alt-AS表示亞洲地區1。> 5% in my stats:表示要兼容自定義的用户統計比例 > 5% 的瀏覽器版本。cover 99.5%:表示要兼容用户份額累計前 99.5% 的瀏覽器版本。cover 99.5% in US:同上,但通過 Alpha-2 編碼來加上國家/地區的限定。cover 99.5% in my stats:使用用户的數據。maintained node versions:所有官方還在維護的Node.js版本。current node:Browserslist 現在正在使用的Node.js版本。extends browserslist-config-mycompany:表示要兼容browserslist-config-mycompany這個npm包的查詢結果。ie 6-8:表示要兼容 IE 6 ~ IE 8 的版本(即 IE 6、IE 7 和 IE 8)。Firefox > 20:表示要兼容 > 20 的 Firefox 版本。>=、<及<=也都是可用的。iOS 7:表示要兼容iOS 7。Firefox ESR:表示要兼容最新的Firefox ESR版本。PhantomJS 2.1 and PhantomJS 1.9:表示要兼容PhantomJS 2.1和 1.9 版本。unreleased versions或unreleased Chrome versions:表示要兼容未發佈的開發版本。後者則具體指明是要兼容未發佈的Chrome版本。last 2 major versions或last 2 iOS major versions:表示要兼容最近兩個主要版本所包含的所有小版本。後者則具體指明是要兼容iOS的最近兩個主要版本所包含的所有小版本。since 2015或last 2 years:自 2015 年或最近兩年到現在所發佈的所有版本。dead:官方不再維護或者超過兩年沒有更新的瀏覽器版本。last 2 versions:每種瀏覽器的最近兩個版本。last 2 Chrome versions:Chrome瀏覽器的最近兩個版本。defaults:Browserslist的默認規則(> 0.5%, last 2 versions, Firefox ESR, not dead)。not ie <= 8:從前面的條件中再排除掉低於或者等於 IE 8 的瀏覽器。
在閲讀這些規則的時候,推薦訪問 http://browsersl.ist 輸入相同的命令進行測試,可以直接得出符合條件的瀏覽器版本。
在 browserlist 上測試條件
細心的讀者可能會發現最後一條的查詢結果會報錯,這是因為not操作需要放在一個查詢條件之後(下文會介紹)。你可以從其他規則中隨意挑一條規則來組合,例如ie 6-10, not ie <= 8將會篩出 IE 9 和 IE 10 。
browseslist 的條件組合
browserslist 支持多種條件的組合,下面我們來了解 browseslist 的條件組合方法。
,和or都可以用來表示邏輯 “或”。例如,last 1 version or > 1%與last 1 version, > 1%等價,都表示每種瀏覽器的最近 1 個版本,或者 > 1% 的市場份額。“或” 操作相當於集合論中的並集。and用來表示邏輯 “與”。例如,last 1 version and > 1%表示每種瀏覽器的最近一個版本,且 > 1% 的市場份額。“與” 操作相當於集合論中的交集。not用來表示邏輯 “非”。例如> .5% and not ie <= 8表示 > 1% 的市場份額且排除 ie 8 及以下的版本。“非” 操作相當於集合論裏頭的補集,所以not不能作為第一個條件,因為你總需要知道“補”的是什麼的“集”。
三種條件組合類型可以用下面的表格來示意:
| 條件組合類型 | 示意圖 | 示例 |
|---|---|---|
or/, 組合 (並集) |
> .5% or last 2 versions > .5%, last 2 versions |
|
and 組合 (交集) |
> .5% and last 2 versions |
|
not 組合 (補集) |
> .5% and not last 2 versions > .5% or not last 2 versions > .5%, not last 2 versions |
配置你的 browserslist
瞭解了以上規則後,我們可以來配置適用於我們的工程的 browserslist 。
舉個例子:假如我們的項目希望在 iOS 8 及以上,或者版本號 49 及以上且市場份額大於 0.2% 的 Chrome 桌面瀏覽器運行,那麼可以使用如下的規則:
// ...
"browserslist": [
">.2% and chrome >= 49",
"iOS >= 8"
],
完成後,可以使用 npx browserslist 來測試你配置的 browserslist 。
$ npx browserslist
chrome 78
chrome 77
chrome 76
chrome 75
chrome 74
chrome 73
chrome 72
chrome 63
chrome 49
ios_saf 13.0-13.2
ios_saf 12.2-12.4
ios_saf 12.0-12.1
ios_saf 11.3-11.4
ios_saf 11.0-11.2
ios_saf 10.3
ios_saf 10.0-10.2
ios_saf 9.3
ios_saf 9.0-9.2
ios_saf 8.1-8.4
ios_saf 8
也可以訪問 https://browsersl.ist/ 上輸入條件測試結果。
測試效果
完成了 browserslist 規則的配置後,我們就可以結合 ESLint 掃描工程中的 API 兼容問題。同時 VS Code 插件也可以即時提示不兼容的 API 調用。
三、使用 eslint-plugin-builtin-compat
eslint-plugin-compat 的原理是針對確認的類型和屬性,使用 caniuse (http://caniuse.com) 的數據集 caniuse-db 以及 MDN(https://developer.mozilla.org... )的數據集 mdn-browser-compat-data 裏的數據來確認 API 的兼容性。但對於不確定的實例對象,由於難以判斷該實例的方法的兼容性,為了避免誤報,eslint-plugin-compat 選擇了跳過這類 API 的檢查。
例如,foo.includes 在不確定 foo 是否為數組類型的時候,就無法判斷 includes 方法的兼容性。在下圖中,我們在使用上面的 browserslint 配置的情況下,includes 方法的兼容問題並沒有被掃描出來:
然而,從 caniuse 上可以查知,Array.prototype.includes() 方法不能被 iOS 8 兼容:
實際上,Cocos Creator 的 engine 項目自 2.1.3 版本開始,就已經針對 Array.prototype.includes() 方法加入了 Polyfill ,從而徹底規避了該 API 的兼容問題。在本節後面介紹 Polyfill 的時候我們將介紹如何避免該 API 的誤報。
為了避免漏報這種問題,我們可以結合另一個兼容檢查插件 eslint-plugin-builtin-compat 。該插件同樣藉助 mdn-browser-compat-data 來進行兼容掃描,與 eslint-plugin-compat 不同的是,該插件不會放過實例對象,因此它會把所有 foo.includes 的 includes 方法當成是 Array.prototype.includes() 方法來掃描。可想而知,這個插件可能會導致誤報。因此建議將其告警級別改為 warning 級別。
3.1 安裝 eslint-plugin-builtin-compat
$ npm install eslint-plugin-builtin-compat --save-dev
3.2 修改 ESLint 配置
與 eslint-plugin-compat 類似,我們可以修改 ESLint 的配置,加上該插件的使用。但由於該插件容易誤報,因此只建議將其告警級別改為 warning 級別:
// .eslintrc.json
{
"extends": "eslint:recommended",
"plugins": [
"compat",
"builtin-compat"
],
"rules": {
//...
"compat/compat": 2,
"builtin-compat/no-incompatible-builtins": 1
},
"env": {
"browser": true
// ...
},
// ...
}
加入該插件後,可以發現 Array.prototype.includes() 方法將會被該插件告警:
四、使用 Polyfill 解決兼容問題
靠 ESLint 在開發階段掃描出 API 兼容問題固然是一種防治兼容性問題的手段,但如果團隊裏的同事並不認真注意 ESLint 的掃描結果,甚至沒有將 ESLint 作為代碼合入掃描的一環的話,就有可能會有漏網之魚繼續肆虐。
因此,一種更為一勞永逸的方法是為一些常用的 API 補上相應 Polyfill 。這樣一方面可以為不兼容的瀏覽器版本添加上支持,另一方面又可以使得團隊成員安心地使用新的 API ,提高開發效率。
4.1 Cocos Creator engine 裏的 Polyfill
實際上,Cocos Creator 的 engine 項目也內置了很多常見 API 的 Polyfill :
其中就包括了 Array.prototype.includes() :
因此,如果使用 2.1.3 以上版本的 Cocos Creator 構建帶有 Array.prototype.includes() 方法的工程,編譯出來的應用將可以順利在 iOS 8 機器上運行。這是因為 Array.prototype.includes() 在構建時被統一被 “翻譯” 成了 engine 項目裏提供的方法。
相應地,為了避免 Polyfill 裏的 isArray 、find、includes 等 API 被 eslint-plugin-builtin-compat 誤報,可以在 .eslintrc 中將這些 API 加入該插件的排除列表中:
// .eslintrc.json
{
"extends": "eslint:recommended",
"plugins": [
"compat",
"builtin-compat"
],
// ...
"settings": {
"builtin-compat-ignore": ["ArrayBuffer", "find", "log2", "parseFloat", "parseInt", "assign", "values", "trimLeft", "startsWith", "endsWith", "repeat"]
}
// ...
}
4.2 自行增加 Polyfill
engine 項目裏的 Polyfill 並不能覆蓋所有的 API 。如果你希望使用的某個不兼容 API 並沒有包含在 engine 項目中,那麼就得考慮給你自己的項目補上該 API 的 Polyfill 。
例如,string.prototype.padStart() 和 string.prototype.padEnd() 兩個 API 分別提供了用於字符串的頭部和尾部補全的便利方法:
'x'.padStart(5, 'ab') // 'ababx'
'x'.padStart(4, 'ab') // 'abax'
'x'.padEnd(5, 'ab') // 'xabab'
'x'.padEnd(4, 'ab') // 'xaba'
而這兩個方法只在 iOS 10 及以上版本才被支持:
尋找 Polyfill
如何尋找這兩個方法的 Polyfill 呢?一個最權威的來源就是 MDN 站點(https://developer.mozilla.org... )。以 string.prototype.padStart() 為例,我們可以在站點右上角的搜索框中輸入 padStart :
之後敲回車進入搜索,在搜索結果中點擊最匹配的結果:
就進入了 string-prototype-padStart 的文檔頁,在左側的導航欄中可以看到有 Polyfill 的欄目:
點擊它即可跳轉到對應的 Polyfill 實現:
!
編寫自定義的 Polyfill 腳本
找到了 string.prototype.padStart() 和 string.prototype.padEnd() 兩個 API 的 Polyfill 後,我們在自己的工程中編寫一個自定義的 Polyfill 腳本。例如叫做 ABCPolyfill.js :
/**
* ABCPolyfill.js
* 補一些 polyfill,解決若干兼容問題
*/
var ABCPolyfill = function () {
console.log('ABC polyfill');
if (!String.prototype.padStart) {
String.prototype.padStart = function padStart(targetLength, padString) {
targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0;
padString = String(typeof padString !== 'undefined' ? padString : ' ');
if (this.length >= targetLength) {
return String(this);
} else {
targetLength = targetLength - this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed
}
return padString.slice(0, targetLength) + String(this);
}
};
}
if (!String.prototype.padEnd) {
String.prototype.padEnd = function padEnd(targetLength,padString) {
targetLength = targetLength>>0; //floor if number or convert non-number to 0;
padString = String((typeof padString !== 'undefined' ? padString : ' '));
if (this.length > targetLength) {
return String(this);
}
else {
targetLength = targetLength-this.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength/padString.length); //append to original to ensure we are longer than needed
}
return String(this) + padString.slice(0,targetLength);
}
};
}
};
module.exports.ABCPolyfill = ABCPolyfill;
接下來,我們要在應用啓動後加載執行這個 Polyfill 腳本里的 ABCPolyfill() 方法,自動打上這兩個 API 的 Polyfill 。我們可以再編寫一個應用初始化腳本,例如叫做 ABCInit.js ,該腳本用於在應用初始化時執行一些指定工作。
/**
* ABCInit.js
* 應用啓動時的一些初始化工作
*/
import ABCPolyfill from 'ABCPolyfill';
// 初始化操作
function doInit() {
ABCPolyfill.ABCPolyfill();
}
(function () {
doInit();
})();
之後可以在你的工程的初始場景裏腳本組件中引用該腳本即可生效:
/**
* 工程的初始場景掛載的腳本組件
*/
require('ABCInit');
// ...
為了避免 eslint-plugin-builtin-compat 誤報,可以將 padStart 和 padEnd 也追加進排除名單中:
// .eslintrc.json
{
"extends": "eslint:recommended",
"plugins": [
"compat",
"builtin-compat"
],
// ...
"settings": {
"builtin-compat-ignore": ["ArrayBuffer", "find", "log2", "parseFloat", "parseInt", "assign", "values", "trimLeft", "startsWith", "endsWith", "repeat", "padStart", "padEnd"]
}
// ...
}
五、小結
- 時刻注意
API兼容性; - 使用
eslint-plugin-compat檢查靜態類型的不兼容API,並將告警級別設為錯誤; - 使用
eslint-plugin-builtin-compat檢查動態類型的不兼容API,並將告警級別設為警告; - 考慮為不兼容
API增加Polyfill。
- 1 ↩