小程序开发框架解析
目前市面上的小程序种类繁多,目前以微信小程序的支持最为完善,其余的就是支付宝、淘宝、抖音、百度等小程序。它们的底层方案都是一致的,只不过在支持程度上有所不同。
以⽀付宝⼩程序为例:
- ⼩程序分别运⾏在 worker(JSEngine) 以及 render 渲染层中, render 可以有多个, worker 只有⼀个,⽅便 app 数据在⻚⾯间的共享和交互;(渲染层 & 逻辑层)
- worker 运⾏⼩程序的逻辑处理代码,包括事件处理,api 调⽤以及框架的⽣命周期管理;(逻辑层功能)
- render 运⾏⼩程序的渲染代码,主要包括模版/样式和框架的跨终端的 js 组件或 native 组件,获取逻辑层的数据进⾏渲染;(渲染层功能)
- worker 和所有的 render 都建⽴连接,将需要渲染的数据传递给对应的 render 进⾏渲染,worker 也会将 api 调⽤转给 native SDK 处理;(Hybrid 通信)
- render 则将组件的触发事件送到对应的 worker 处理,同时接受 worker 的 setData 调⽤ React 重新渲染。 render 可以看作⼀个⽆状态的渲染终端,状态统统保留在 app 级别的 worker ⾥⾯;(渲染层&逻辑层交互)
# 小程序跨端框架
由于原生小程序的语法与当今主流的前端开发框架的差异及当今对代码在多端运行的需求颇多,衍生出了许多兼容多端的前端跨端开发框架,下面分析已 react 语法为主的跨端框架;
# 编译时
⽤户编写的业务代码解析成 AST 树,然后通过语法分析强⾏将⽤户写的类 React 代码转换为可运⾏的⼩程序代码,代表:京东的 Taro1/2、去哪⼉的 Nanachi,淘宝的 Rax。下面以 RAX 为例:Rax 小程序编译时方案是基于 AST 转译的前提下,将 Rax 代码通过词法、语法分析转译成小程序原生代码的过程。由于 JavaScript 的动态能力以及 JSX 本身不是一个传统模板类型语法,所以在这个方案中引入一个较为轻量的运行时垫片。
编译时链路主要分为五个模块:
- CLI:整个链路的⼊⼝,⽤户编写的所有业务代码都经由 CLI 读取、处理和输出;
- loader:webpack loader,⽤于处理各种类型的⽂件,包括 app、page、component、script 以及 静态资源等;
- compiler:⽤于进⾏ AST 转换并⽣成对应的⼩程序代码;
- runtime:为⽣成的 js 代码提供了运⾏时的垫⽚⽀持;
- universal:多端统⼀的 universal 组件以及 API 的基础服务⽀持;
# CLI
从命令⾏读取各种必要参数,然后传⼊ webpack 执⾏。利⽤ webpack 的依赖分析能⼒,遍历到所有有效代码并交由对应的 loader 进⾏处理。 CLI 对外提供 watch 和 build 两个指令: 1. watch:监听代码变动并实时编译,2:build:剔除部分调试⽤的代码(如 source map)并压缩代码,完成编译打包;
下面是 RAX 框架 watch 和 build 的部分代码
watch
/**
* watch and copy constant dir file change
* @param {array} dirs
* @param {string} distDirectory
*/
function watch(options = {}) {
const {
afterCompiled,
type = DEFAULT_TYPE,
entry = DEFAULT_ENTRY,
platform = DEFAULT_PLATFORM,
workDirectory = cwd,
distDirectory = join(cwd, DEFAULT_DIST),
skipClearStdout = false,
constantDir = DEFAULT_CONSTANT_DIR_ARR,
disableCopyNpm = false,
turnOffSourceMap = false,
turnOffCheckUpdate = false,
} = options;
watchConstantDir(constantDir, distDirectory);
const needUpdate = checkNeedUpdate(turnOffCheckUpdate);
let config = getWebpackConfig({
mode: "watch",
entryPath: entry,
type,
workDirectory,
platform,
distDirectory,
constantDir,
disableCopyNpm,
turnOffSourceMap,
});
if (options.webpackConfig) {
config = mergeWebpack(config, options.webpackConfig);
}
spinner.shouldClear = !skipClearStdout;
const compiler = webpack(config);
const watchOpts = {
aggregateTimeout: 600,
};
compiler.outputFileSystem = new MemFs();
compiler.watch(watchOpts, (err, stats) => {
handleCompiled(err, stats, { skipClearStdout });
afterCompiled && afterCompiled(err, stats);
if (needUpdate) {
console.log(
chalk.black.bgYellow.bold(
"Update for miniapp relatedpackages available, please reinstall dependencies."
)
);
}
console.log("Watching for changes...");
});
}
/**
* watch and copy constant dir file change
* @param {array} dirs
* @param {string} distDirectory
*/
function watchConstantDir(dirs, distDirectory) {
const watcher = chokidar.watch(dirs);
watcher.on("all", (event, path) => {
copyConstantDir(path, distDirectory);
});
}
/**
* copy constant path to dist
* @param {string} path
* @param {string} distDirectory
*/
function copyConstantDir(path, distDirectory) {
if (!path) {
return;
}
if (!existsSync(path)) {
mkdirSync(path);
}
copySync(path, join(distDirectory, getCurrentDirectoryPath(path, "src")), {
filter: (filename) => !/\.ts$/.test(filename),
});
}
function handleCompiled(err, stats, { skipClearStdout }) {
if (err) {
console.error(err.stack || err);
if (err.details) {
console.error(err.details);
}
return;
}
if (stats.hasErrors()) {
const errors = stats.compilation.errors;
if (!skipClearStdout) consoleClear(true);
spinner.fail("Failed to compile.\n");
for (let e of errors) {
console.log(
chalk.red(` ${errors.indexOf(e) + 1}.
${e.error.message} \n`)
);
if (process.env.DEBUG === "true") {
console.log(e.error.stack);
}
}
console.log(
chalk.yellow("Set environment `DEBUG=true` to see detail error stacks.")
);
}
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
其实 watch 做的事情就是将 webpack 配置传入后启动了一个本地服务器来运行代码,build 同理,做一些配置化的工作
build
/**
* Start jsx2mp build.
* @param options
*/
function build(options = {}) {
const {
afterCompiled,
type = DEFAULT_TYPE,
entry = DEFAULT_ENTRY,
platform = DEFAULT_PLATFORM,
workDirectory = cwd,
distDirectory = join(cwd, DEFAULT_DIST),
skipClearStdout = false,
constantDir = DEFAULT_CONSTANT_DIR_ARR,
disableCopyNpm = false,
turnOffCheckUpdate = false,
} = options;
// Clean the dist dir before generating
if (existsSync(distDirectory)) {
del.sync(distDirectory + "/**");
}
constantDir.forEach((dir) => copyConstantDir(dir, distDirectory));
const needUpdate = checkNeedUpdate(turnOffCheckUpdate);
let config = getWebpackConfig({
mode: "build",
entryPath: entry,
platform,
type,
workDirectory,
distDirectory,
constantDir,
disableCopyNpm,
});
if (options.webpackConfig) {
config = mergeWebpack(config, options.webpackConfig);
}
spinner.shouldClear = !skipClearStdout;
const compiler = webpack(config);
compiler.outputFileSystem = new MemFs();
compiler.run((err, stats) => {
handleCompiled(err, stats, { skipClearStdout });
afterCompiled && afterCompiled(err, stats);
if (needUpdate) {
console.log(
chalk.black.bgYellow.bold(
"Update for miniapp related packages available, please reinstall dependencies."
)
);
}
});
}
// 依赖 webpack 对项⽬进⾏依赖分析,然后调⽤ loader对对应类型的⽂件进⾏处理
const AppLoader = require.resolve("jsx2mp-loader/src/app-loader");
const PageLoader = require.resolve("jsx2mp-loader/src/page-loader");
const ComponentLoader = require.resolve("jsx2mp-loader/src/componentloader");
const ScriptLoader = require.resolve("jsx2mp-loader/src/script-loader");
const FileLoader = require.resolve("jsx2mp-loader/src/file-loader");
function getEntry(type, cwd, entryFilePath, options) {
const entryPath = dirname(entryFilePath);
const entry = {};
const {
platform = "ali",
constantDir,
mode,
disableCopyNpm,
turnOffSourceMap,
} = options;
const loaderParams = {
platform: platformConfig[platform],
entryPath: entryFilePath,
constantDir,
mode,
disableCopyNpm,
turnOffSourceMap,
};
if (type === "project") {
// ....
entry.app =
AppLoader +
"?" +
JSON.stringify({
entryPath,
platform: platformConfig[platform],
mode,
disableCopyNpm,
turnOffSourceMap,
}) +
"!./" +
entryFilePath;
if (Array.isArray(appConfig.routes)) {
appConfig.routes
.filter(({ targets }) => {
return !Array.isArray(targets) || targets.indexOf("miniapp") > -1;
})
.forEach(({ source, component, window = {} }) => {
component = source || component;
entry["page@" + component] =
PageLoader +
"?" +
JSON.stringify(
Object.assign({ pageConfig: window }, loaderParams)
) +
"!" +
getDepPath(component, entryPath);
});
} else if (Array.isArray(appConfig.pages)) {
// Compatible with pages.
appConfig.pages.forEach((pagePath) => {
entry["page@" + pagePath] =
PageLoader +
"?" +
JSON.stringify(loaderParams) +
"!" +
getDepPath(pagePath, entryPath);
});
}
}
if (type === "component") {
entry.component =
ComponentLoader +
"?" +
JSON.stringify(loaderParams) +
"!" +
entryFilePath;
}
return entry;
}
module.exports = (options = {}) => {
const config = {
mode: "production", // Will be fast
entry: getEntry(type, workDirectory, relativeEntryFilePath, options),
output: {
path: distDirectory,
},
target: "node",
context: workDirectory,
module: {
rules: [
{
test: /\.t|jsx?$/,
use: [
{
loader: ScriptLoader,
options: {
mode: options.mode,
entryPath: relativeEntryFilePath,
platform: platformConfig[platform],
constantDir,
disableCopyNpm,
turnOffSourceMap,
},
},
{
loader: BabelLoader,
options: getBabelConfig(),
},
],
},
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.webp$/],
loader: FileLoader,
options: {
entryPath: relativeEntryFilePath,
},
},
{
test: /\.json$/,
use: [
{
loader: ScriptLoader,
options: {
mode: options.mode,
entryPath: relativeEntryFilePath,
platform: platformConfig[platform],
constantDir,
disableCopyNpm,
turnOffSourceMap,
},
},
],
},
],
},
resolve: {
extensions: getPlatformExtensions(platform, [
".js",
".jsx",
".ts",
".tsx",
".json",
]),
mainFields: ["main", "module"],
},
};
return config;
};
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
我们可以看到代码内对 appConfig.routes、appConfig.pages 做了一系列的操作,这主要是 RAX 通过其配置文件 app.json,获取到所有需要页面的路由,再对所有的页面作为入口,所有依赖⽂件将依次被遍历并交由对应 loader 进⾏处理。loader 处理完毕后最终的编译代码将⽣成到⽬的⽬录。
{
"routes": [
{
"path": "/",
"source": "pages/Home/index"
}
],
"window": {
"defaultTitle": "Rax App 1.0"
}
}
2
3
4
5
6
7
8
9
10
11
# Loader
在 RAX 的 build 指令中,可以看到代码通过了几个 loader 的运作,最终打包成了小程序的代码 https://github.com/raxjs/miniapp/blob/master/packages/jsx2mp-loader/CHANGELOG.md (opens new window)
- AppLoader
首先,是 AppLoader,它用来处理 rax 源码中的 app.js 文件,并将 app.json 中 的 window
属性并作⽀付宝/微信两端的配置抹平
module.exports = async function appLoader(content) {
const query = parse(this.request);
// Only handle app role file
if (query.role !== "app") {
return content;
}
if (!existsSync(outputPath)) mkdirpSync(outputPath);
const compilerOptions = Object.assign({}, compiler.baseOptions, {
// ...options,
});
const rawContentAfterDCE = eliminateDeadCode(rawContent);
let transformed;
try {
transformed = compiler(rawContentAfterDCE, compilerOptions);
} catch (e) {
console.log(
chalk.red(`\n[${platform.name}] Error occured when
handling App ${this.resourcePath}`)
);
if (process.env.DEBUG === "true") {
throw new Error(e);
} else {
const errMsg = e.node
? `${e.message}\nat ${this.resourcePath}`
: `Unknown compile error! please check your code at ${this.resourcePath}`;
throw new Error(errMsg);
}
}
const { style, assets } = await processCSS(transformed.cssFiles, sourcePath);
transformed.style = style;
transformed.assets = assets;
const outputContent = {
code: transformed.code,
map: transformed.map,
css: transformed.style ? defaultStyle + transformed.style : defaultStyle,
};
const outputOption = {
outputPath: {
code: join(outputPath, platform.type === QUICKAPP ? "app.ux" : "app.js"),
css: join(outputPath, "app" + platform.extension.css),
},
mode,
isTypescriptFile: isTypescriptFile(this.resourcePath),
type: "app",
platform,
rootDir,
};
output(outputContent, rawContent, outputOption);
return [
`/* Generated by JSX2MP AppLoader, sourceFile: ${this.resourcePath}.
*/`,
generateDependencies(transformed.imported),
].join("\n");
};
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
- PageLoader
- 处理定义在 app.json 中 routes 属性内的 page 类型组件
- 根据 jsx-compiler 中解析到的该组件所引⽤组件的信息,写⼊ json ⽂件中的 usingComponents ,并将这些组件加⼊ webpack 依赖分析链并交由 component-loader 处理
- 处理⽤户定义在 app.json 中 routes 数组内每⼀个⻚⾯的配置(即 window 配置项)并输出⾄ 对应⻚⾯的 json ⽂件中
module.exports = async function pageLoader(content) {
const query = parse(this.request);
// Only handle page role file
if (query.role !== "page") {
return content;
}
const compilerOptions = Object.assign({}, compiler.baseOptions, {
// ...options
});
const rawContentAfterDCE = eliminateDeadCode(content);
let transformed;
try {
transformed = compiler(rawContentAfterDCE, compilerOptions);
} catch (e) {
console.log(
chalk.red(
`\n[${platform.name}] Error occured when handling Page ${this.resourcePath}`
)
);
if (process.env.DEBUG === "true") {
throw new Error(e);
} else {
const errMsg = e.node
? `${e.message}\nat ${this.resourcePath}`
: `Unknown compile error! please check your code at ${this.resourcePath}`;
throw new Error(errMsg);
}
}
const { style, assets } = await processCSS(transformed.cssFiles, sourcePath);
transformed.style = style;
transformed.assets = assets;
if (!existsSync(pageDistDir)) mkdirpSync(pageDistDir);
// ...
let config = {
...transformed.config,
};
if (existsSync(pageConfigPath)) {
const pageConfig = readJSONSync(pageConfigPath);
delete pageConfig.usingComponents;
Object.assign(config, pageConfig);
}
// ...
if (config.usingComponents) {
const usingComponents = {};
Object.keys(config.usingComponents).forEach((key) => {
const value = config.usingComponents[key];
if (/^c-/.test(key)) {
const result = MINIAPP_PLUGIN_COMPONENTS_REG.test(value)
? value
: removeExt(
addRelativePathPrefix(relative(dirname(this.resourcePath), value))
);
usingComponents[key] = normalizeOutputFilePath(result);
} else {
usingComponents[key] = normalizeOutputFilePath(value);
}
});
config.usingComponents = usingComponents;
}
output(outputContent, content, outputOption);
// ...
return [
`/* Generated by JSX2MP PageLoader, sourceFile: ${this.resourcePath}.
*/`,
generateDependencies(dependencies),
].join("\n");
};
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
- ComponentLoader
- 处理 component 类型组件并交由 jsx-compiler 处理然后产出编译后代码,并写⼊⾄指定⽬标 ⽂件夹位置
- 根据 jsx-compiler 中解析到的该组件所引⽤组件的信息,写⼊ json ⽂件的 usingComponents 属性中,并将这些组件加⼊ webpack 依赖分析链并交由 componentloader 处理
module.exports = async function componentLoader(content) {
const query = parse(this.request);
// Only handle component role file
if (query.role !== "component") {
return content;
}
const compilerOptions = Object.assign({}, compiler.baseOptions, {
// ...options,
});
let transformed;
try {
const rawContentAfterDCE = eliminateDeadCode(content);
transformed = compiler(rawContentAfterDCE, compilerOptions);
} catch (e) {
console.log(
chalk.red(
`\n[${platform.name}] Error occured when handling Component ${this.resourcePath}`
)
);
if (process.env.DEBUG === "true") {
throw new Error(e);
} else {
const errMsg = e.node
? `${e.message}\nat ${this.resourcePath}`
: `Unknown compile error! please check your code at ${this.resourcePath}`;
throw new Error(errMsg);
}
}
const { style, assets } = await processCSS(transformed.cssFiles, sourcePath);
transformed.style = style;
transformed.assets = assets;
const config = Object.assign({}, transformed.config);
if (config.usingComponents) {
const usingComponents = {};
Object.keys(config.usingComponents).forEach((key) => {
const value = config.usingComponents[key];
usingComponents[key] = normalizeOutputFilePath(value);
});
config.usingComponents = usingComponents;
}
const distFileDir = dirname(distFileWithoutExt);
if (!existsSync(distFileDir)) mkdirpSync(distFileDir);
output(outputContent, content, outputOption);
function isCustomComponent(name, usingComponents = {}) {
const matchingPath = join(dirname(resourcePath), name);
for (let key in usingComponents) {
if (
usingComponents.hasOwnProperty(key) &&
usingComponents[key] &&
usingComponents[key].indexOf(matchingPath) === 0
) {
return true;
}
}
return false;
}
};
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
- FileLoader
- 处理图⽚等静态⽂件资源,将其拷⻉⾄指定⽬标⽂件夹
const { join, relative, dirname } = require("path");
const { copySync } = require("fs-extra");
const loaderUtils = require("loader-utils");
module.exports = function fileLoader(content) {
const { entryPath, outputPath } = loaderUtils.getOptions(this) || {};
const rootContext = this.rootContext;
const relativeFilePath = relative(
join(rootContext, dirname(entryPath)),
this.resourcePath
);
const distSourcePath = join(outputPath, relativeFilePath);
copySync(this.resourcePath, distSourcePath);
return "";
};
2
3
4
5
6
7
8
9
10
11
12
13
14
- ScriptLoader
- npm 包:搜集代码中使⽤到的 npm 依赖,获取 npm 包的真实地址 => 路径处理 => babel 编译 => 输出代码⾄⽬标⽂件夹
- 来⾃ npm 包的第三⽅原⽣⼩程序库:⽤户使⽤绝对路径去使⽤第三⽅原⽣⼩程序库时,script-loader 需要读取 js ⽂件同⽬录下同名的 json ⽂件中的 usingComponents 字段并将其加⼊ webpack 的依赖分析链
module.exports = function scriptLoader(content) {
const query = parse(this.request);
if (query.role) {
return content;
}
// ...
if (isFromNodeModule(this.resourcePath)) {
if (disableCopyNpm) {
return isCommonJSON ? "{}" : content;
}
const pkg = readJSONSync(sourcePackageJSONPath);
const npmName = pkg.name; // Update to real npm name, for that tnpm will create like `_rax-view@1.0.2@rax-view` folders.
const npmMainPath = join(sourcePackagePath, pkg.main || "");
const isUsingMainMiniappComponent =
pkg.hasOwnProperty(MINIAPP_CONFIG_FIELD) &&
this.resourcePath === npmMainPath;
// Is miniapp compatible component.
if (
isUsingMainMiniappComponent ||
isRelativeMiniappComponent ||
isThirdMiniappComponent
) {
// ...
if (isThirdMiniappComponent) {
const source = dirname(this.resourcePath);
const target = dirname(
normalizeNpmFileName(
join(
outputPath,
"npm",
relative(rootNodeModulePath, this.resourcePath)
)
)
);
outputDir(source, target);
outputFile(rawContent);
}
return [
`/* Generated by JSX2MP ScriptLoader, sourceFile:${this.resourcePath}. */`,
generateDependencies(dependencies),
content,
].join("\n");
} else {
outputFile(rawContent);
}
} else if (isFromConstantDir(this.resourcePath) && isThirdMiniappComponent) {
const dependencies = [];
outputFile(rawContent, false);
// Find dependencies according to usingComponents config
const componentConfigPath = removeExt(this.resourcePath) + ".json";
const componentConfig = readJSONSync(componentConfigPath);
for (let key in componentConfig.usingComponents) {
const componentPath = componentConfig.usingComponents[key];
const absComponentPath = resolve(
dirname(this.resourcePath),
componentPath
);
dependencies.push({
name: absComponentPath,
options: loaderOptions,
});
}
return [
`/* Generated by JSX2MP ScriptLoader, sourceFile:${this.resourcePath}. */`,
generateDependencies(dependencies),
content,
].join("\n");
} else if (!isAppJSon) {
outputFile(rawContent, false);
}
return isJSON
? "{}"
: transformCode(content, mode, [
require("@babel/plugin-proposal-class-properties"),
]).code;
// For normal js file, syntax like class properties can't be parsed without babel plugins
};
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# compiler
编译:是⼀种利⽤编译程序从源语⾔编写的源程序产⽣⽬标程序的过程或者动作,完整的流程是从⾼级语⾔转换成计算机可以理解的⼆进制语⾔的过程:Rax -> ⼩程序 DSL
# runtime
三方框架和原生支持上还是有区别,框架通常提供了垫片做两者功能的桥接,将差异尽量抹平, 所以,Rax 小程序编译时方案提供了一个运行时垫片,用来对齐模拟 Rax core API 。既然引入了运行时,自然可以基于这套机制对数据流做更多的管理,以及提供 Rax 工程在其他端上的 API,比如路由相关的 history location 等。
https://github.com/raxjs/miniapp/tree/master/packages/jsx2mp-runtime/src (opens new window)
# univeral
提供对应小程序生态的组件和 api 等基础服务支持
# 运行时
⼩程序的技术底层依托于 web 技术,由于多线程架构的限制,对于有多端需求的项⽬来说,加⼀个功能或者改⼀个样式都可能需要改动两套代码(DOM、BOM API ⽆法打平);
⽬的:
- 为了更好的复⽤组件,尽可能完整的⽀持 Web 端的特性;
- 在⼩程序端的渲染结果要尽可能接近 Web 端 h5 ⻚⾯;
目前通常以提供适配器的方式来支持
适配器可以理解是一棵在 appService 端运行的轻型 dom 树,它提供基础的 dom/bom api。appService 端和 webview 端的交互通过适配器来进行,Web 端框架和业务代码不直接触达和 webview 端的通信接口(如 setData 等接口)。
dom 树本身是没有固定模式可循的,它的层级、dom 节点数量都是不固定的,没有办法用固定的 wxml 将其描述出来,因此这里使用了小程序自定义组件的自引用特性。
自定义组件支持使用自己作为其子节点,也就是说可以用递归引用的方式来构造任意层级、任意节点数量的自定义组件树,所以可以将若干个 dom 节点映射成一个小程序的自定义组件,每一个自定义组件相当于描述出了 dom 树的一部分,然后将这些自定义组件拼接起来就可以描述出一棵完整的 dom 树。
如上图所述,虚线框将一棵 dom 树划分成五棵子树,每棵子树最多不超过三层。这个虚线框就可以理解成是一个自定义组件,每个自定义组件渲染一棵层级不超过三层的 dom 子树,然后将这些自定义组件拼接起来就相当于渲染出了一棵完整的 dom 树。
# 实现
RAX 的运行时采用了 Kbone 的包,而 Kbone 就是通过 miniprogram-render、miniprogram-element 和 mp-webpack-plugin 这三个包完成了运行时的依赖。mp-webpack-plugin 作为 webpack 插件,被 webpack 配置所依赖;前两个包则组成了适配器,被生成的小程序代码依赖(提供了 dom/bom 依赖 -- window 对象、document 对象、dom 对象)。不过通常情况下用户不用管适配器是怎么被使用的,只要用户配置好 mp-webpack-plugin 插件,在执行构建时就会将使用适配器的代码生成出来,而后用户只需要走小程序使用 npm (opens new window)的流程将适配器安装构建到小程序目录即可。后续如果遇到要升级 kbone 的情况,也只要升级这三个包就可以了。
在运行时,通过插件将 render 模拟出的 window 和 document 等变量注入到 webpack 打包出的 JS Bundle 中,再生成一个固定的小程序项目骨架,在 app.js 中加载 JS Bundle 即可。