作者简介: 李叶,毕业于华中科技大学,现为 LeanCloud JavaScript SDK 负责人。在前端工程化、前端性能方面有丰富的经验,关注 React 及相关技术。
责任编辑: 唐小引,技术之路,共同进步。欢迎技术投稿、约稿,给文章纠错,请发送邮件至[email protected]。
导语: 本文主要介绍 SDK 在跨平台支持过程中开发者们经常遇见的问题,以及解决这些问题时用到的工具并总结的一些最佳实践。希望可以为那些对跨平台开发有兴趣的同学提供有价值的帮助。
作为后端云服务提供商,我们在底层通过 REST API 与 WebSocket 提供数据、文件存储、短信、推送、实时消息等服务。还为各个目标平台编写了 SDK 来封装这些 API,在 SDK 中实现客户端状态的持久化,为用户提供更加符合直觉的抽象。一个有趣的现象是越来越多的平台使用的都是 JavaScript:
为什么 SDK 要跨平台?降低成本是最为重要的一大原因。对于用户,提供跨平台的 SDK 可以降低学习与切换成本。并且,随着同构应用以及服务端渲染的流行,对于采用这种方案的用户,跨平台 SDK 可以方便地作为「平台无关」代码进行共享。而对于公司而言,如果能够在多个平台中共享这部分代码,将会减少 SDK 的开发与维护成本。
基于以上前提,我们的目标具体表现为:
接下来,我们分 API、编译打包、小程序、测试四个部分详细了解 SDK 在跨平台实践中遇到的常见问题及解决方案。
这些平台都会使用内置或者外部的 JavaScript Engine 来执行 JavaScript 代码。所有属于 ECMAScript 标准的 API 都是所有平台都支持的,比如 Math、Array、TypedArray、Promise、正则表达式。这倒不是指它们使用的是同一个 JavaScript Engine(事实上存在 V8、SpiderMonkey、JSC、Chakra 等各种实现),得益于 TC39 的存在以及 Babel 的出色表现,我们几乎不需要担心我们的 JavaScript 代码在不同平台上的一致性问题。这也意味着,如果一个第三方库只使用了 ECMAScript 的 API,那么它一定是跨平台的,我们可以放心使用,一个典型的例子就 lodash。
ECMAScript 的 API 是语言层面上的,除此之外,各个平台还会根据自己需要解决的问题提供平台特有的 API。比如,其中唯一有委员会(W3C)来制定标准的平台——Web 平台——提供了下面这些 API。
其中 DOM API 在其他平台上都没有,而网络请求 API 在 Node.js 平台上则是完全不同的设计。对于 LeanCloud SDK,我们关心的是实现以下这些功能以及实现所需要用到的 API:
从上表中可以看到平台在设计这些基础能力 API 时,分为三大流派:
API 的本质是对实现的抽象,SDK 就像一个由 API 调用构成的金字塔,越往上抽象越贴近用户。要跨平台,用户就需要将不同的底层 API 抽象成一个。这里有两种思路,假设我们有两个平台的 API A 与 B:
具体到我们的实现:
要想达成只使用一套 codebase 的目标,除了统一的 API 在各平台上的不同实现,还需要在不同的平台上运行对应的代码。我们先来看看有哪些工具能完成这个任务,这里以 WebSocket 为例。
最开始,我们的 SDK 是没有编译打包环节的,在运行时进行平台检测来执行不同的代码。
// src/websocket.js
let WebSocket;
if (!utils.isNode) {
WebSocket = window.WebSocket;
} else {
WebSocket = require('ws').WebScoket;
}
为了解决这个问题,我们引入了 webpack 来实现「条件编译」:
// src/websocket.js
let WebSocket;
if (process.env.PLATFORM === 'Browser') {
WebSocket = window.WebSocket;
} else {
WebSocket = require('ws').WebScoket;
}
// webpack/browser.js
module.exports = {
// ...
plugins: [
new webpack.EnvironmentPlugin(["PLATFORM"])
]
};
// package.json:
{
"scripts": {
"build:browser": "PLATFORM=Browser webpack --config webpack/browser.js"
}
}
webpack 后:
var WebSocket;
if ('Browser' === 'Browser') {
WebSocket = window.WebSocket;
} else {
WebSocket = require('ws').WebScoket;
}
uglify 后:
var WebSocket;WebSocket=window.WebSocket;
可见:https://github.com/defunctzombie/package-browser-field-spec
// package.json:
{
"browser": {
"ws": "./src/websocket.js"
}
}
// src/websocket-browser.js
module.exports = window.WebSocket;
// src/websocket.js
const WebSocket = require('ws');
除了对内告诉 bundler 要如何打包模块,browser field 也用来对外申明浏览器版本的入口:
// package.json:
{
"main": "./dist/node/index.js",
"browser": {
"./dist/node/index.js": "./dist/av.js",
"ws": "./src/websocket-browser.js"
}
}
作为事实标准,browser 字段得到了市面上几乎所有 bundler 的支持(包括 React Native 内置的 Packager、cocos creator 使用的 browserify,以及 webpack 与 rollup),npm 上众多跨平台的 package 也都是采用了这种申明方式。
同样的,我们还有一些 React Native 特有的代码需要在打包时替换。webpack 使用了一种更通用的方式支持了这个特性。
// package.json:
{
"main": "./dist/node/index.js",
"browser": {
"./dist/node/index.js": "./dist/av.js",
"ws": "./src/websocket.js"
},
"react-native": {
"./dist/node/index.js": "./dist/av-rn.js",
"./src/utils/localstorage.js": "./src/utils/localstorage-rn.js"
}
}
// webpack/react-native.js
module.exports = {
// ...
resolve: {
aliasFields: ['react-native', 'browser']
}
};
刚才说到,市面上几乎所有的 bundler 都支持这个标准,bundler 会按照我们的配置正确的使用对应的模块,所以为目标平台编译出一个文件并不是必须的。事实上这样做是有缺点的
与此同时,预编译的版本也不会自动得到依赖模块的新 bug,并且考虑到很多 bundler 在具体的实现上总有各种各样的问题,所以我们目前依然在每次一发布时都提供了各个平台的预编译版本。
至此,我们几乎完成了前面所设定的目标:
直到出现了一位新玩家。
先来看下小程序的架构。
在第一部分说到,由于 Web API 抽象层级高、后台硬、现有轮子多,各个平台都倾向于实现 Web API。SDK 大部分时候都是直接调用的 Web API。另一方面,我们也使用了 superagent/axios 等第三方库提供更加易用的 API,并不希望去修改这些第三方库。
很自然地,为了适配小程序,最便捷的方案是用小程序的 API 来 polyfill Web API。很快我们就遇到了两个问题:
小程序的 JavaScript 代码在真机上是运行在 JSC / JSCore 上的,但是在开发者工具中,这部分代码是直接运行在浏览器环境中的,是能够使用包括 window
、document
、XMLHttpRequest
在内的所有 Web API 的。为了保证 IDE 与真机运行环境的一致性,IDE 在编译阶段会在每个文件的 CommonJS wapper 中申明这些变量:
define("app", function(require, module, exports, window,document,frames,self,location,navigator,localStorage,history,Caches,screen,alert,confirm,prompt,XMLHttpRequest,WebSocket,webkit,WeixinJSCore,WeixinJSBridge,Reporter){
'use strict';
// SDK code
new XMLHttpRequest(); // throw
new window.XMLHttpRequest(); // throw
});
这意味着即使能够为 global object 增加 Web API,也无法在其他文件中访问到。
define("app", function(require, module, exports, window,XMLHttpRequest/* ... */){
'use strict';
// polyfill code
window = window || {};
window.XMLHttpRequest = require('./xmlhttprequest.js');
try {
XMLHttpRequest = XMLHttpRequest || require('./xmlhttprequest.js');
} catch (e) {}
// SDK code
new XMLHttpRequest();
new window.XMLHttpRequest();
});
还是以 HTTP 请求为例,小程序的 wx.request API 在开发者工具中是用浏览器中的 XMLHttpRequest 实现的。因此小程序的 API 缺少了很多实现 Web API 需要的特性:
abort
;HEADERS_RECEIVED
与 LOADING
等中间状态。一方面,我们只能在微信小程序中禁用掉 SDK 的一些功能,比如文件上传进度功能。另一方面尽可能去 mock 一些特性或数据来保证现有的基于 Web API 的代码逻辑不会抛异常,比如 getResponseHeade('content-type')
始终返回 'application/json'
,其他 key 始终返回 ‘’
。
这些 polyfill 开源在 GitHub - leancloud/weapp-polyfill: Polyfills for w3c API on top of Weapp API 。目前我们 polyfill 了以下 API,如果有在小程序中使这些 API 的需求,这个库应该能节省你一些时间。
测试是保证 SDK 质量的重要手段,我们使用了 Mocha 作为测试框架,Sinon.js 作为 spy 与 mock 工具,它们都同时支持浏览器与 Node.js。再加上 SDK 提供的 API 是平台无关的,使得我们能够使用一份测试代码分别在浏览器与 Node.js 中运行测试。
对于跨平台 SDK,测试流程的自动化是必不可少的。我们使用 travis-ci 来运行 Node.js 的测试,使用 Saucelabs(Selenium)来运行浏览器测试,保证每次提交在我们支持的所有 Node.js 版本与我们支持的所有浏览器中都能通过测试。
遗憾的是,对于其他平台,由于工具的缺失,目前并没有良好的测试方案,我们现在也只是在发布之前手动进行冒烟测试。
了解最新移动开发、VR/AR 干货技术分享,请关注 mobilehub 微信公众号(ID: mobilehub)。