博客 / 詳情

返回

Cocos Creator 最佳實踐:JavaScript兼容性問題規避

本文從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.3Cocos Creator 又將 Mac 端的 JS VM 切換到了 V8,以提升應用性能。
  • 對於 Android 客户端和 Windows 客户端:在 Cocos Creator 1.6 及以前,Cocos Creator 同樣是使用 SpiderMonkey 作為 JS VM ;從 1.7 開始,得益於 JSB 2.0V8 成了 AndroidWindows 客户端的 JS VM
  • 對於 Web 端:使用瀏覽器本身的JavaScript VM 來解析 JavaScript 代碼。

api-compat-jsb2-arch.png

從上可見,由於 JS VM 不同,同一份代碼在不同的平台上運行可能會有很大差異。為了讓我們的產品能夠給儘可能多的用户使用,我們在開發階段就需要時刻注意 JavaScriptAPI 兼容性。

舉個例子: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-compatESLint 的一個插件,由前 uber 工程師 Amila Welihinda 開發。它可以幫助發現代碼中的不兼容 API

api-compat-1.png

下面介紹如何在工程中接入 eslint-plugin-compat

2.1 安裝 eslint-plugin-compat

安裝 eslint-plugin-compat 和安裝其他 ESLint 插件類似:

$ npm install eslint-plugin-compat --save-dev

還可以順便把依賴的 browserslistcaniuse-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-compatbabel、Autoprefixer 等。下面我們來詳細瞭解一下 browserslist 的描述規範。

browserslist 支持指定目標瀏覽器類型,並且能夠靈活組合多種指定條件。

指定目標瀏覽器類型

browserslist 收錄瞭如下一些瀏覽器,可以在條件中使用(注意大小寫敏感):

  • Android:用於 Android WebView
  • Baidu:用於百度瀏覽器。
  • BlackBerrybb:用於黑莓瀏覽器。
  • Chrome:用於 Google Chrome
  • ChromeAndroidand_chr:用於 Android Chrome
  • Edge:用於 Microsoft Edge
  • Electron:用於 Electron framework。 將會被轉換成 Chrome 版本。
  • Explorerie:用於 Internet Explorer
  • ExplorerMobileie_mob:用於 Internet Explorer Mobile
  • Firefoxff:用於 Mozilla Firefox
  • FirefoxAndroidand_ff:用於 Android Firefox
  • iOSios_saf:用於 iOS Safari
  • Node:用於 Node.js
  • Opera:用於 Opera
  • OperaMiniop_mini:用於 Opera Mini
  • OperaMobileop_mob:用於 Opera Mobile
  • QQAndroidand_qq:用於 Android QQ 瀏覽器。
  • Safari:用於 desktop Safari。
  • Samsung:用於 Samsung Internet。
  • UCAndroidand_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 versionsunreleased Chrome versions:表示要兼容未發佈的開發版本。後者則具體指明是要兼容未發佈的 Chrome 版本。
  • last 2 major versionslast 2 iOS major versions:表示要兼容最近兩個主要版本所包含的所有小版本。後者則具體指明是要兼容 iOS 的最近兩個主要版本所包含的所有小版本。
  • since 2015last 2 years:自 2015 年或最近兩年到現在所發佈的所有版本。
  • dead:官方不再維護或者超過兩年沒有更新的瀏覽器版本。
  • last 2 versions:每種瀏覽器的最近兩個版本。
  • last 2 Chrome versionsChrome 瀏覽器的最近兩個版本。
  • defaultsBrowserslist 的默認規則(> 0.5%, last 2 versions, Firefox ESR, not dead)。
  • not ie <= 8:從前面的條件中再排除掉低於或者等於 IE 8 的瀏覽器。

在閲讀這些規則的時候,推薦訪問 http://browsersl.ist 輸入相同的命令進行測試,可以直接得出符合條件的瀏覽器版本。

api-compat-browserslist.png

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/, 組合 (並集) api-compat-union.png > .5% or last 2 versions > .5%, last 2 versions
and 組合 (交集) api-compat-intersection.png > .5% and last 2 versions
not 組合 (補集) api-compat-complement.png > .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 調用。

api-compat-vscode.png

三、使用 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 方法的兼容問題並沒有被掃描出來:

api-compat-includes-1.png

然而,從 caniuse 上可以查知,Array.prototype.includes() 方法不能被 iOS 8 兼容:

api-compat-includes-2.png

實際上,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.includesincludes 方法當成是 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() 方法將會被該插件告警:

api-compat-includes-3.png

四、使用 Polyfill 解決兼容問題

ESLint 在開發階段掃描出 API 兼容問題固然是一種防治兼容性問題的手段,但如果團隊裏的同事並不認真注意 ESLint 的掃描結果,甚至沒有將 ESLint 作為代碼合入掃描的一環的話,就有可能會有漏網之魚繼續肆虐。

因此,一種更為一勞永逸的方法是為一些常用的 API 補上相應 Polyfill 。這樣一方面可以為不兼容的瀏覽器版本添加上支持,另一方面又可以使得團隊成員安心地使用新的 API ,提高開發效率。

4.1 Cocos Creator engine 裏的 Polyfill

實際上,Cocos Creatorengine 項目也內置了很多常見 API Polyfill

api-compat-polyfill.png

其中就包括了 Array.prototype.includes()

api-compat-polyfill-array.png

因此,如果使用 2.1.3 以上版本的 Cocos Creator 構建帶有 Array.prototype.includes() 方法的工程,編譯出來的應用將可以順利在 iOS 8 機器上運行。這是因為 Array.prototype.includes() 在構建時被統一被 “翻譯” 成了 engine 項目裏提供的方法。

相應地,為了避免 Polyfill 裏的 isArrayfindincludesAPI 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 項目中,那麼就得考慮給你自己的項目補上該 APIPolyfill

例如,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 及以上版本才被支持:

api-compat-padstart (1123).png

尋找 Polyfill

如何尋找這兩個方法的 Polyfill 呢?一個最權威的來源就是 MDN 站點(https://developer.mozilla.org... )。以 string.prototype.padStart() 為例,我們可以在站點右上角的搜索框中輸入 padStart

api-compat-padstart-search.png

之後敲回車進入搜索,在搜索結果中點擊最匹配的結果:

api-compat-padstart-search-2.png

就進入了 string-prototype-padStart 的文檔頁,在左側的導航欄中可以看到有 Polyfill 的欄目:

api-compat-polyfill-padstart (1).png

點擊它即可跳轉到對應的 Polyfill 實現:

!api-compat-polyfill-padstart.png

編寫自定義的 Polyfill 腳本

找到了 string.prototype.padStart()string.prototype.padEnd() 兩個 APIPolyfill 後,我們在自己的工程中編寫一個自定義的 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() 方法,自動打上這兩個 APIPolyfill 。我們可以再編寫一個應用初始化腳本,例如叫做 ABCInit.js ,該腳本用於在應用初始化時執行一些指定工作。

/**
 * ABCInit.js
 * 應用啓動時的一些初始化工作
 */
import ABCPolyfill from 'ABCPolyfill';

// 初始化操作
function doInit() {
  ABCPolyfill.ABCPolyfill();
}

(function () {
    doInit();
})();

之後可以在你的工程的初始場景裏腳本組件中引用該腳本即可生效:

/**
 * 工程的初始場景掛載的腳本組件
 */

require('ABCInit');

// ...

為了避免 eslint-plugin-builtin-compat 誤報,可以將 padStartpadEnd 也追加進排除名單中:

// .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"]
  }
  // ...
}

五、小結

  1. 時刻注意 API 兼容性;
  2. 使用 eslint-plugin-compat 檢查靜態類型的不兼容 API ,並將告警級別設為錯誤;
  3. 使用 eslint-plugin-builtin-compat 檢查動態類型的不兼容 API,並將告警級別設為警告;
  4. 考慮為不兼容 API 增加 Polyfill

  1. 1 ↩
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.