条件编译-谈谈前端打包中的跨平台适配

前言

本文将结合 axios 的多端适配原理探讨现代化前端环境下打包“跨平台”方案,以总结出现代化前端跨平台适配的最佳实践。方案适用于“一套源码需要适配多个平台”的业务场景。例如:

  • 同一套代码,但要适配多个平台不同 jsbridge——例如微信/qq/whatsapp/支付宝/头条等平台的 bridgeapi 都有一些差异。
  • 同一套代码,但是要适配多种浏览器宿主—safari/chrome/Firefox 等(当然目前浏览器差异已经很小了,所以这种场景比较少了)
  • 同一套代码,但是要适配前端浏览器环境、后端 node 环境、。例如 axios 这类类库就是要适配服务端和前端环境。
  • 同一套代码,但是要适配前端浏览器环境、前端构建时刻环境、esmodule 环境以及 commonjs 环境。例如 vue 打包产物就要区分不同的宿主使用场景来对待,某些场景下他也做了条件删除,例如浏览器场景下才会包含 template-compiler 编译器。

目标平台具有 api 的差异性,而我们“适配”的目的就是:

  1. 通过某种方法,尽量保证开发代码时按照 100% 无差异方式来编写。
  2. 使得打包产物,能够正常运行到对应平台上—即要能识别对应平台并调用对应平台的 api。

方案分类

从平台差异的判断时机来划分,我将此场景方案分为“运行时”和“构建时”方案。

  1. 运行时。即构建成一份 bundle.js,里面包含了所有平台代码,然后代码在对应平台运行时候识别平台并选择对应代码执行。
  2. 构建时。即在构建时就区分平台来编译,从而达到 A 平台使用 A.js 代码;B 平台使用 B.js 代码。

优缺点:

方案 优点 缺点
运行时方案 不需要每个平台各自一份代码,减少构建成本 产物体积大,对用户来说冗余无用代码
构建时方案 体积精简,各自平台的代码只包含对应平台的逻辑 构建复杂度偏高,需要分别构建多份平台产物。且需要多平台能支持引用不同的 bundle 产物

总体来看,其实两个方案的缺点都没那么明显,在可以忍受的范围内。具体我们做技术选型抉择时,可以考虑如下因素择优选择:

  1. 你的客户端或 html 能否支持按需引用不同的平台 bundle 代码。
    例如你打包出来 android.js 和 ios.js,结果实际使用时,你根本没有任何服务端或客户端技术来分别引用 2 个不同的 js。就只能放弃“构建时”方案了。
  2. 要看“平台差异部分的大小”。如果差异特别大(例如整个要打包 wxbridge.js,qqbridge.js, alipay.js),那么就有必要在编译时进行条件编译分平台包,避免把多个平台相关的 jssdk 都打包到一份产物,影响用户流量。反之,如果差异点特别小,不会显著增加产物体积,可能更适合在运行时简单判断即可。

模拟场景

为了本文能顺利讲解“运行时”和“构建时”方案。我们先把问题场景给模拟出来。

简单逻辑模拟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// index.js 主入口
console.log("my webapp init");
// 针对不同平台要走一个不同的逻辑。
if (window.wx) {
console.log("wx logic");
window.wx.alert("ok");
} else if (window.qq) {
console.log("qq logic");
window.qq.alert("ok");
} else if (window.ali) {
console.log(" ali logic");
window.ali.alert("ok");
} else {
console.log("jd logic");
window.alert("ok");
}

复杂平台库调用模拟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { openWebview as aliOpenWebview } from "./bridge/ali.js";
import { openWebview as jdOpenWebview } from "./bridge/jd.js";
import { openWebview as qqOpenWebview } from "./bridge/qq.js";
import { openWebview as wxOpenWebview } from "./bridge/wx.js";

// 复杂库调用
if (window.wx) {
wxOpenWebview();
} else if (window.qq) {
qqOpenWebview();
} else if (window.ali) {
aliOpenWebview();
} else {
jdOpenWebview();
}

运行时判断方案

上面我模拟出来的 demo,其实就属于运行时判断方案了。也是我们大多数同学在开发此类需求时必然会想到的方案。

运行方案理解简单,全靠前端同学代码判断。缺点就是打包产物混杂了所有平台代码。我们来看下产物:

可以看到产物中包含所有平台判断代码+平台各自的逻辑代码。

请注意“复杂库调用位置”,其实他 require 的所有各个平台的依赖库,都已经在我得这一个打包产物 main.6hgtyusf.js 文件中了。截图如下:

显然,对于简单逻辑部分,混在打包产物中无所谓。但对于体积庞大的 sdk 依赖库来说,所有平台包都打到 main.js 里,对用户来说是额外负担。

运行时方案的优化

运行时方案中,有个办法可以解决 main.js 体积问题。那就是动态懒加载。我们改成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 复杂库调用
if (window.wx) {
const res = await import("./bridge/wx.js");
res.openWebview();
} else if (window.qq) {
const res = await import("./bridge/qq.js");
res.openWebview();
} else if (window.ali) {
const res = await import("./bridge/ali.js");
res.openWebview();
} else {
const res = await import("./bridge/jd.js");
res.openWebview();
}

编译后查看产物,已经变成动态引入了。main.js 中已经不包含所有平台 sdk 代码内容:

浏览器中的运行效果:

如果是低速网络模拟后,会明显看到串行带来的延迟—后一个 js 要等第一个 js 加载完毕后才能去加载:

结论:对于耗时不敏感的应用场景,这种运行时方案勉强能用。

适配器模式

对于纯运行时判断的方案,适用于对体积不太敏感的场景。其实 axios 就是采用上述纯运行时判断的方案。只是 axios 在代码维护层面采用了“适配器模式”,从而让平台 api 成为一个可替换、可增添、可插入的标准可插拔的平台 api。

axios 跨端适配的核心秘密

但是有没有想过一个混合代码中,既有 nodeapi,又有浏览器 api。那么当你代码中出现 import ‘https’这样的语句的时候,在浏览器端都会因为缺少 https 模块而报错。那 axios 是怎样解决同构代码外部模块不存在问题的呢。

可以看到他浏览器版本里面的 httpAdaptor 直接被设置成了 null,这里 rollup 是如何做到的,待我抽空再研究。

条件编译方案

引言:c 语言中的条件编译

不知你是否记得,曾经学习 c 语言时候的条件编译:

预处理程序提供了条件编译的功能,可以按不同的条件去编译不同的程序部分,因而产生不同的目标代码文件,这对于程序的移植和调试是很有用的。条件编译通常用于跨平台开发、调试功能和功能开关。

例如我们需要编写一个跨平台的程序,其中根据操作系统的不同,使用不同的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

// 定义平台相关的宏
#define WINDOWS 1

int main() {
// 条件编译,根据操作系统选择不同的代码
#if defined(WINDOWS)
printf("This is a Windows system.\n");
#elif defined(LINUX)
printf("This is a Linux system.\n");
#else
printf("Unknown system.\n");
#endif

return 0;
}

webpack 中的变量替换

c 语言的宏替换可以理解为在编译阶段告诉编译器应该编译哪一段代码。而 webpack 构建其实没有类似的宏控制机制可以实现这个。

然而 webpack 中倒是存在一个 definePlugin 可以向代码注入变量替换,我们可以借助变量替换,让某个 if 语句变成“永久 true”,从而再借用 webpack 的压缩优化机制,来达到删除平台无关代码的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// webpack.config.js
{plugins: [
new webpack.DefinePlugin({
PLATFORM: JSON.stringify('wx')
})]},

// main.js
// 简单逻辑
if (PLATFORM === "wx") {
console.log("wx logic");
window.wx.alert("ok");
} else if (PLATFORM === "qq") {
console.log("qq logic");
window.qq.alert("ok");
} else if (PLATFORM === "ali") {
console.log(" ali logic");
window.ali.alert("ok");
} else {
console.log("jd logic");
window.alert("ok");
}

编译后产物:

可以看到 webpack DefiinePlugin 已将 PLATFORM 变量换成了 “wx”,而且由于 definePlugin 的替换时机是在 webpack 进行真正编译之前,所以后期 webpack 会把死代码删除。

其原理就类似于,webpack 编译过程忽然发现你在编写这样的代码:

1
2
3
4
5
if ("abc" === "abc") {
console.log("真棒");
} else {
console.log("不棒");
}

于是 webpack 直接构建时给你优化成了 if(true) {console.log('真棒')}。 这个东西叫做 webpack 编译处理中的“死代码删除”

变量替换+treeShaing

但是 esmodule 语法中的 import 逻辑都是写在文件顶层,无法增加 if 判断逻辑哦。怎么办呢?

尽管我们无法发直接针对顶部 import 进行“死代码删除”,但是其实借助变量替换+ treeShaking 确实也能做到自动删除“没用到的 import 模块”。

于是,我们可以考虑通过变量替换,让 webpack 编译后只剩下对某个平台库的调用,那么最终压缩的时候就会自动 treeShaking 去掉没用的模块,从而达到按平台打包的目的。

配置方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// main.js 代码:
import { openWebview as aliOpenWebview } from "./bridge/ali.js";
import { openWebview as jdOpenWebview } from "./bridge/jd.js";
import { openWebview as qqOpenWebview } from "./bridge/qq.js";
import { openWebview as wxOpenWebview } from "./bridge/wx.js";
console.log("my webapp init");
// 复杂库调用
if (PLATFORM === "wx") {
wxOpenWebview();
} else if (PLATFORM === "qq") {
qqOpenWebview();
} else if (PLATFORM === "ali") {
aliOpenWebview();
} else {
jdOpenWebview();
}

然后,webpack definePlugin 配置 PLATFORM 变量分别构建。然后构建后,你会发现,产物中虽然调用的地方变成了:

1
2
3
4
if (true) {
wxOpenWebview();
} else {
}

但当你去查看产物的时候,会发现 wx.js,qq.js,jd.js,ali.js 各个模块依然打包进去了产物。这是因为你开发环境还没有开启压缩。于是你可以这样打开 webpack 压缩:

1
2
3
4
5
6
7
8
// webpack.config.js
{
optimization: {
usedExports: true, // 标记未使用的导出
minimize: true,
minimizer: [new TerserPlugin()],
},
}

之后再次构建,你会发现产物中就只剩下各自平台的相关 js 模块代码了。

然而你会发现上面这种方案,导致你编写 main.js 主逻辑代码的时候,不仅要在顶部编写 import 导入四个平台的模块,而且使用的时候还要 if 判断四次对平台判断分别调用不同平台的 api。我们能否简化写法呢?例如只 import 一次,然后调用时候也不要关心平台判断。请看下文。

优化办法:webpackDefinePlugin 中的 import 替换

要想实现顶层 import 语句的按平台编译。我们却又缺少类似 c 语言的宏机制,是否有其他办法呢?

我们来尝试下是否可以把 import 语句后面的模块名直接按平台给编译前自动换掉,就等同于实现了 c 语言宏机制吧。开搞:

1
2
3
4
5
6
7
8
// main.js 引用一个通用的 bridge/index.js,这是个假的哨兵文件。
import { openWebview } from "./bridge/index.js";
// 复杂库调用
openWebview();
// webpack配置
new webpack.DefinePlugin({
'./bridge/index.js': './bridge/wx.js'
})],

实验发现不可行:因为 webpack.DefinePlugin 只能针对代码中的变量进行替换,无法针对普通字符串进行替换。

优化办法:自己写 loader 替换?

既然 definePlugin 不给我替换,我能否自己编写 loader 来换掉对应 import 语句。理论上可行。于是我们试试吧。

1
2
3
4
5
6
7
8
// loader 编写
export default function (content) {
const res = content.replace(
/(from [\"\'].*bridge\/)(index.js)(.*[\"\'])/,
`$1wx.js$3`
);
return res;
}

可以看到,产物中主逻辑符合预期,且产物中仅包含了 wx.js 的库代码。并没有冗余其他平台代码:

于是,接下来,我们可以稍微的改成可配置化的 loader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// loader 源码
export default function (content) {
const options = this.getOptions();
const res = content.replace(
/(from [\"\'].*bridge\/)(index.js)(.*[\"\'])/,
`$1${options.platform || "index"}.js$3`
);
return res;
}
// webpack.config.js配置:

const targetPlatform = process.env.PLATFORM
export default {
mode: "development",
entry: "./src/index.js",
devtool: "cheap-module-source-map", // 平衡可读性和构建速度
output: {
filename: `[name]-${targetPlatform}-[hash:8].js`,
path: path.join(import.meta.dirname, "dist"),
},
module: {
rules: [
{
test: /\.js/,
use: {
loader: path.join(import.meta.dirname, './loaders/my-loader.js'),
options: {
platform: targetPlatform // 根据平台传递对应参数, 告诉 loader 按需替换。
}
}
},
],
},
};

之后,我们项目源码主入口就这么编写主逻辑:

1
2
3
import { openWebview, shareFriends } from "./bridge/index.js";
// 复杂库调用
openWebview();

至于我们的 bridge 库目录,就这样组织结构:

接下来,package.json 编写几套编译脚本:

1
2
3
4
5
6
7
8
9
10
{
"scripts": {
"build": "npm run build:wx && npm run build:ali && npm run build:jd && npm run build:qq",
"build:wx": "cross-env PLATFORM=wx webpack --config webpack.config.js",
"build:ali": "cross-env PLATFORM=ali webpack --config webpack.config.js",
"build:jd": "cross-env PLATFORM=jd webpack --config webpack.config.js",
"build:qq": "cross-env PLATFORM=qq webpack --config webpack.config.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
}

执行 npm run build, 产物结果符合预期(每个平台都有一份自己的 bundle 代码):

而且这个方案,让我们 main.js 里面编写逻辑的时候简单多了!

基于 loader 思路实现宏编译命令

其实上面我的 loader 思路,就有点类似于 c 语言的宏条件编译了。那我们能否把语法改成类似于 c 语言宏条件的写法来声明多平台条件编译。

例如 main.js 里面这样玩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// #if process.env.PLATFORM === 'wx'
import { openWebview } from "./bridge/wx.js";
// #endif
// #if process.env.PLATFORM === 'qq'
import { openWebview } from "./bridge/qq.js";
// #endif
// #if process.env.PLATFORM === 'jd'
import { openWebview } from "./bridge/jd.js";
// #endif
// #if process.env.PLATFORM === 'ali'
import { openWebview } from "./bridge/ali.js";
// #endif
console.log("my webapp init");
// 复杂库调用
openWebview();

可以,社区已经有人写了这样的插件:“webpack-conditional-loader”。

配该 loader 后,编译产物会变成如下这样:

基于此方案,我们可以在 bridge 目录下新建一个 index.js,然后在里面编写上述#if 宏命令。 然后让其他调用者全部都来引用 bridge/index.js 就可以了。

alias 别名方法

貌似自己写 loader 能解决,那 webpack 自身的 alias 能否解决这个问题?我们试试 alias 功能。

实测可行。下面描述过程:

首先,将 main.js 主逻辑引用类库的 import 语句改成:

1
2
3
import { openWebview, shareFriends } from "bridge";
// 复杂库调用
openWebview();

其中 bridge 就是一个代表不同平台类库的别名。然后我们去配置 webpack 的别名规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const targetPlatform = process.env.PLATFORM;
export default {
output: {
filename: `[name]-${targetPlatform}-[hash:8].js`,
path: path.join(import.meta.dirname, "dist"),
},
resolve: {
alias: {
bridge: path.join(
import.meta.dirname,
"./src/bridge/" + targetPlatform + ".js"
),
},
},
};

之后直接执行多次平台编译命令,传递不同的平台环境变量。构建成功:

webpack resolve 方案

resolve.modules 字段表示 webpack 的默认搜索路径,他是个数组类型 可以配置多个路径.

我们是否可以把 wx.js, jd.js, qq.js 分别放到不同目录。例如 wx/index.js, qq/index.js, jd/index.js。
然后通过修改默认搜索路径从而让他找到对应的包?其原理跟 alias 类似。这个交给读者朋友们自行尝试吧。

结论

  1. 小差异,采用运行时判断即可。建议参考 axios 采用适配器模式,同时解决跨端编译时的依赖 external 问题。
  2. 大差异,建议采用 loader 或 alias 方案。从通用性和显性声明角度,建议使用现成的“webpack-conditional-loader”解决。