12 如何理解 AST 实现和编译原理?

经常留意前端开发技术的同学一定对 AST 技术不陌生。AST 技术是现代化前端基建和工程化建设的基石:Babel、Webpack、ESLint、代码压缩工具等耳熟能详的工程化基建工具或流程,都离不开 AST 技术;Vue、React 等经典前端框架,也离不开基于 AST 技术的编译。

目前社区上不乏 Babel 插件、Webpack 插件等知识的讲解,但是涉及 AST 的部分,往往都是使用现成工具转载模版代码。这一讲,我们就从 AST 基础理念讲起,并实现一个简单的 AST 实战脚本。

AST 基础知识

我们先对 AST 下一个定义,AST 是 Abstract Syntax Tree 的缩写,表示抽象语法树:

在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax Tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似 if-condition-then 这样的条件跳转语句,可以使用带有三个分支的节点来表示。

AST 的应用场景经常出现在源代码的编译过程中:一般语法分析器创建出 AST,然后 AST 在语义分析阶段添加一些信息,甚至修改 AST 内容,最终产出编译后代码。

AST 初体验

了解了 AST 基本概念,我们对 AST 进行一个“感官认知”。这里提供给你一个平台:AST explorer,在这个平台中,可以实时看到 JavaScript 代码转换为 AST 之后的产出结果。如下图所示:

AST 在线分析结果图

可以看到,经过 AST 转换,我们的 JavaScript 代码(左侧)变成了一种 ESTree 规范的数据结构(右侧),这种数据结构就是 AST。

这个平台实际使用了 acorn 作为 AST 解析器。下面我们就来介绍一下 acorn,本节内容我们将要实现的脚本,也会依赖 acorn 的能力。

acorn 解析

实际上,社区上多项著名项目都依赖的 acorn 的能力(比如 ESLint、Babel、Vue.js 等),acorn 的介绍为:

A tiny, fast JavaScript parser, written completely in JavaScript.

由此可知,acorn 是一个完全使用 JavaScript 实现的、小型且快速的 JavaScript 解析器。基本用法非常简单,代码如下:

let acorn = require('acorn')
let code = 1 + 2
console.log(acorn.parse(code))

更多使用方式我们不再一一列举。你可以结合相关源码进一步学习。

我们将视线更多地聚焦 acorn 的内部实现中。对所有语法解析器来说,实现流程上很简单,如下图所示:

acorn 工作流程图

源代码经过词法分析,即分词得到 Token 序列,对 Token 序列进行语法分析,得到最终 AST 结果。但 acorn 稍有不同的是:acorn 将词法分析和语法分析交替进行,只需要扫描一遍代码即可得到最终 AST 结果

acorn 的 Parser 类源码形如:

export class Parser {constructor(options, input, startPos) {//...}parse() {// ...}// 判断所处 contextget inFunction() { return (this.currentVarScope().flags & SCOPE_FUNCTION) > 0 }get inGenerator() { return (this.currentVarScope().flags & SCOPE_GENERATOR) > 0 }get inAsync() { return (this.currentVarScope().flags & SCOPE_ASYNC) > 0 }get allowSuper() { return (this.currentThisScope().flags & SCOPE_SUPER) > 0 }get allowDirectSuper() { return (this.currentThisScope().flags & SCOPE_DIRECT_SUPER) > 0 }get treatFunctionsAsVar() { return this.treatFunctionsAsVarInScope(this.currentScope()) }get inNonArrowFunction() { return (this.currentThisScope().flags & SCOPE_FUNCTION) > 0 }static extend(...plugins) {// ...}// 解析入口static parse(input, options) {return new this(options, input).parse()}static parseExpressionAt(input, pos, options) {let parser = new this(options, input, pos)parser.nextToken()return parser.parseExpression()}// 分词入口static tokenizer(input, options) {return new this(options, input)}
}

我们稍做解释:

  • type 表示当前 Token 类型;

  • pos 表示当前 Token 所在源代码中的位置;

  • startNode 方法返回当前 AST 节点;

  • nextToken 方法从源代码中读取下一个 Token;

  • parseTopLevel 方法实现递归向下组装 AST 树。

这是 acorn 实现解析 AST 的入口骨架,实际的分词环节主要解决以下问题。

  1. 明确需要分析哪些 Token 类型。

  • 关键字:import,function,return 等

  • 变量名称

  • 运算符号

  • 结束符号

  1. 状态机:简单来讲就是消费每一个源代码中的字符,对字符意义进行状态机判断。以“我们对于/的处理”为例,对于3/10的源代码,/就表示一个运算符号;对于var re = /ab+c/源代码来说,/就表示正则运算的起始字符了。

在分词过程中,实现者往往使用一个 Context 来表达一个上下文,实际上Context 是一个栈数据结果(这一部分源码你可以点击这里阅读)。

acorn 在语法解析阶段主要完成 AST 的封装以及错误抛出。在这个过程中,需要你了解,一段源代码可以用:

  • Program——整个程序

  • Statement——语句

  • Expression——表达式

来描述。

当然,Program 包含了多段 Statement,Statement 又由多个 Expression 或者 Statement 组成。这三种大元素,就构成了遵循 ESTree 规范的 AST。最终的 AST 产出,也是这三种元素的数据结构拼合。具体实现代码我们不再探究。

下面我们通过 acorn 以及一个脚本,来实现非常简易的 Tree Shaking 能力。

AST 实战演练——实现一个简易 Tree Shaking 脚本

上一讲我们介绍了 Tree Shaking 技术的方方面面。下面,我们就基于本节内容的主题——AST,来实现一个简单的 DCE(dead code elimination)。

目标如下,实现一个 Node.js 脚本 treeShaking.js,执行命令:

node treeShaking test.js

可以将test.js中的 dead code 消除。我们使用test.js测试代码如下:

function add(a, b) {return a + b
}
function multiple(a, b) {return a * b
}
var firstOp = 9
var secondOp = 10
add(firstOp, secondOp)

理论上讲,上述代码中的multiple方法可以被“摇掉”。

我们进入实现环节,首先请看下图,了解整体架构流程:

基于 AST 的 tree-shaking 简易实现

设计 JSEmitter 类,用于根据 AST 产出 JavaScript 代码(js-emitter.js 文件内容):

class JSEmitter {// 访问变量声明,以下都是工具方法visitVariableDeclaration(node) {let str = ''str += node.kind + ' 'str += this.visitNodes(node.declarations)return str + '\n'}visitVariableDeclarator(node, kind) {let str = ''str += kind ? kind + ' ' : strstr += this.visitNode(node.id)str += '='str += this.visitNode(node.init)return str + ';' + '\n'}visitIdentifier(node) {return node.name}visitLiteral(node) {return node.raw}visitBinaryExpression(node) {let str = ''str += this.visitNode(node.left)str += node.operatorstr += this.visitNode(node.right)return str + '\n'}visitFunctionDeclaration(node) {let str = 'function 'str += this.visitNode(node.id)str += '('for (let param = 0; param < node.params.length; param++) {str += this.visitNode(node.params[param])str += ((node.params[param] == undefined) ? '' : ',')}str = str.slice(0, str.length - 1)str += '){'str += this.visitNode(node.body)str += '}'return str + '\n'}visitBlockStatement(node) {let str = ''str += this.visitNodes(node.body)return str}visitCallExpression(node) {let str = ''const callee = this.visitIdentifier(node.callee)str += callee + '('for (const arg of node.arguments) {str += this.visitNode(arg) + ','}str = str.slice(0, str.length - 1)str += ');'return str + '\n'}visitReturnStatement(node) {let str = 'return ';str += this.visitNode(node.argument)return str + '\n'}visitExpressionStatement(node) {return this.visitNode(node.expression)}visitNodes(nodes) {let str = ''for (const node of nodes) {str += this.visitNode(node)}return str}// 根据类型,执行相关处理函数visitNode(node) {let str = ''switch (node.type) {case 'VariableDeclaration':str += this.visitVariableDeclaration(node)break;case 'VariableDeclarator':str += this.visitVariableDeclarator(node)break;case 'Literal':str += this.visitLiteral(node)break;case 'Identifier':str += this.visitIdentifier(node)break;case 'BinaryExpression':str += this.visitBinaryExpression(node)break;case 'FunctionDeclaration':str += this.visitFunctionDeclaration(node)break;case 'BlockStatement':str += this.visitBlockStatement(node)break;case "CallExpression":str += this.visitCallExpression(node)break;case "ReturnStatement":str += this.visitReturnStatement(node)break;case "ExpressionStatement":str += this.visitExpressionStatement(node)break;}return str}// 入口run(body) {let str = ''str += this.visitNodes(body)return str}
}
module.exports = JSEmitter

我们来具体分析一下,JSEmitter 类中创建了很多 visitXXX 方法,他们最终都会产出 JavaScript 代码。我们继续结合treeShaking.js的实现来理解:

const acorn = require("acorn")
const l = console.log
const JSEmitter = require('./js-emitter')
const fs = require('fs')
// 获取命令行参数
const args = process.argv[2]
const buffer = fs.readFileSync(args).toString()
const body = acorn.parse(buffer).body
const jsEmitter = new JSEmitter()
let decls = new Map()
let calledDecls = []
let code = []
// 遍历处理
body.forEach(function(node) {if (node.type == "FunctionDeclaration") {const code = jsEmitter.run([node])decls.set(jsEmitter.visitNode(node.id), code)return;}if (node.type == "ExpressionStatement") {if (node.expression.type == "CallExpression") {const callNode = node.expressioncalledDecls.push(jsEmitter.visitIdentifier(callNode.callee))const args = callNode.argumentsfor (const arg of args) {if (arg.type == "Identifier") {calledDecls.push(jsEmitter.visitNode(arg))}}}}if (node.type == "VariableDeclaration") {const kind = node.kindfor (const decl of node.declarations) {decls.set(jsEmitter.visitNode(decl.id), jsEmitter.visitVariableDeclarator(decl, kind))}return}if (node.type == "Identifier") {calledDecls.push(node.name)}code.push(jsEmitter.run([node]))
});
// 生成 code
code = calledDecls.map(c => {return decls.get(c)
}).concat([code]).join('')
fs.writeFileSync('test.shaked.js', code)

对于上面代码分析,首先我们通过process.argv获取到目标文件,对于目标文件通过fs.readFileSync()方法读出字符串形式的内容buffer,对于这个buffer变量,我们使用acorn.parse进行解析,并对产出内容进行遍历。

在遍历过程中,对于不同的节点类型,调用 JS Emitter 实例不同的处理方法。在整个过程中,我们维护了:

  • decls——Map 类型

  • calledDecls——数组类型

  • code——数组类型

三个关键变量。decls存储所有的函数或变量声明类型节点,calledDecls则存储了代码中真正使用到的数或变量声明,code存储了其他所有没有被节点类型匹配的 AST 部分。

下面我们来分析具体的遍历过程。

  • 在遍历过程中,我们对所有函数和变量的声明,都维护到decls中。

  • 接着,我们对所有的 CallExpression 和 IDentifier 进行检测。因为 CallExpression 代表了一次函数调用,因此在该 if 条件分支内,将相关函数节点调用情况推入到calledDecls数组中,同时我们对于该函数的参数变量也推入到calledDecls数组。因为 IDentifier 代表了一个变量的取值,我们也推入到calledDecls数组。

经过整个 AST 遍历,我们就可以只遍历calledDecls数组,并从decls变量中获取使用到的变量和函数声明,最终使用concat方法合并带入code变量中,使用join方法转化为字符串类型。

至此,我们的简易版 Tree Shaking 实现就完成了,建议你结合实际代码,多调试,相信会有更多收获。

总结

这一讲,我们聚焦了 AST 这一热点话题。说 AST 是热点,是因为当前前端基础建设、工程化建设中越来越离不开 AST 技术的支持,AST 在前端中扮演的重要角色也越来越广为人知。

但事实上,AST 是计算机领域中一个历经多年的基础概念,每一名开发者也都应该循序渐进地了解 AST 相关技术以及编译原理。

这一讲,我们先从基本概念入手,然后借助了 acorn 的能力,动手实现了一个真实的 AST 落地场景——实现简易 Tree Shaking,正好又和上一章节内容相扣。由此可见,前端基建和工程化是一张网,网上的每一个技术点,都能由点及面,绘制出一张前端知识图谱。

13 工程化思维处理方案:如何实现应用主题切换功能?

前几讲内容,我们主要围绕 JavaScript 和项目相关工程化方案展开。实际上,在前端基础建设中,样式方案的处理也必不可少。这一讲,就让我们设计一个工程化主题切换功能,并梳理现代前端样式的解决方案。

设计一个主题切换工程架构

随着 iOS 13 引入 Dark Mode(深色模式),各大应用和网站也都开始支持深色模式。相比传统的页面配色方案,深色模式具有较好的降噪性,也能让用户的眼睛看内容更舒适。

那么对于前端来说,如何高效地支持深色模式呢?这里的高效就是指工程化、自动化方案,不需要开发中 hard coding。

在介绍具体方案前,我们先来了解一个必备概念和工程化神器——PostCSS。

PostCSS 原理和相关插件能力

简单来说, PostCSS 是一款编译 CSS 的工具。

PostCSS is a tool for transforming styles with JS plugins. These plugins can lint your CSS, support variables and mixins, transpile future CSS syntax, inline images, and more.

如上介绍,postCSS 具有良好的插件性,其插件也是使用 JavaScript 编写的,非常有利于开发者扩展。基于前几节介绍的 Babel 思想,对比 JavaScript 的编译器,我们不难猜出 PostCSS 的工作原理:PostCSS 接收一个 CSS 文件,并提供了插件机制,提供给开发者分析、修改 CSS 的规则,具体实现方式也是基于 AST 技术

而我们这一讲介绍的工程化主题切换架构,也离不开 PostCSS 的基础能力。我们马上来设计一个相关结构能力。

架构思路总结

主题切换——社区上介绍的方案往往通过 CSS 变量(CSS 自定义属性)来实现,关于 CSS 变量的介绍,相对基础,这里我们只贴出知识链接:CSS 自定义属性。

这无疑是一个很好的思路,但是作为架构来说,使用 CSS 自定义属性——只是其中一个环节。站在更高、更中台化的视觉思考,我们还需要设计:

  • 如何维护不同主题色值;

  • 谁来维护不同主题色值;

  • 研发和设计之间,如何保持不同主题色值的同步沟通;

  • 如何最小化前端工程师的开发量,不需要 hard coding 两份颜色数值;

  • 如何做到一键切换时的性能最优;

  • 如何配合 JavaScript 状态管理,同步主题切换的信号。

基于以上考虑,以一个超链接样式为例,我们希望做到在开发时,编写:

a {color: cc(GBK05A);
}

这样的代码,就能一劳永逸——直接支持两套(light/dark)主题模式。也就是说,在应用编译时,上述代码预期被编译为下面这样的代码:

a {color: #646464;
}

html[data-theme=‘dark’] a {
color: #808080;
}

我们来看看在编译时,构建环节发生了什么:

  • cc(GBK05A)这样的声明,被编译为#646464

  • 也就是说,cc是一个 CSS function,而GBK05A是一组色值,分别包含了 light 和 dark 两种主题的颜色;

  • 同时在 HTML 根节点上,添加属性选择器data-theme='dark',并添加a标签 color 色值样式为#808080

我们设想,用户点击“切换主题”按钮时,首先通过 JavaScript 将 HTML 根节点标签添加data-themedark的属性值,这时CSS 选择器html[data-theme='dark'] a将起作用,实现了样式的切换。

结合下图理解:

回到我们的架构设计中,如何在构建时完成 CSS 的样式编译转换呢?答案指向了 PostCSS。我们来盘点一下具体架构步骤。

  • 首先编写一个名为 postcss-theme-colors 的 PostCSS 插件,实现上述编译过程。

  • 维护一个色值,结合上例(这里以 YML 格式为例)就是:

GBK05A: [BK05, BK06]
BK05: '#808080'
BK06: '#999999'

postcss-theme-colors 需要:

  1. 识别cc()方法;

  2. 读取色值;

  3. 通过色值,对cc()方法求值,得到两种颜色,分别对应 dark 和 light 模式;

  4. 原地编译 CSS 中的颜色为 light 模式色值;

  5. 同时 dark 模式色值写到 HTML 节点上。

这里需要补充的是,为了将 dark 模式色值按照html[data-theme='dark']方式写到 HTML 节点上,我们使用了另外两个 PostCSS 插件完成:

  • PostCSS Nested

  • PostCSS Nesting

整体架构设计,总结为下图:

PostCSS 架构转换设计

主题色切换架构实现

有了架构,这部分我们就来实现架构环节中的重点环节。首先,我们需要了解 PostCSS 插件体系。

PostCSS 插件体系

PostCSS 具有天生的插件化体系,开发者一般很容易上手插件开发:

var postcss = require('postcss');
module.exports = postcss.plugin('pluginname', function (opts) {opts = opts || {};// Work with options herereturn function (css, result) {// Transform the CSS AST};
})

上面代码是一个典型的 PostCSS 插件编写模版。一个 PostCSS 就是一个 Node.js 模块,开发者调用postcss.plugin(源码链接定义在postcss.plugin中 )工厂方法返回一个插件实体,形如:

return {postcssPlugin: 'PLUGIN_NAME',/*Root (root, postcss) {// Transform CSS AST here}*//*Declaration (decl, postcss) {// The faster way to find Declaration node}*//*Declaration: {color: (decl, postcss) {// The fastest way find Declaration node if you know property name}}*/}
}

在编写 PostCSS 插件时,我们可以直接使用postcss.plugin方法完成实际开发。接下来,我们就开始动手实现 postcss-theme-colors。

动手实现 postcss-theme-colors

上述内容,在 PostCSS 插件设计中,我们看到了清晰的 AST 设计痕迹,经过之前小节的学习,我们应该对于 AST 不再陌生。根据插件代码骨架,我们加入具体实现逻辑,代码如下:

const postcss = require('postcss')
const defaults = {function: 'cc',groups: {},colors: {},useCustomProperties: false,darkThemeSelector: 'html[data-theme="dark"]',nestingPlugin: null,
}
const resolveColor = (options, theme, group, defaultValue) => {const [lightColor, darkColor] = options.groups[group] || []const color = theme === 'dark' ? darkColor : lightColorif (!color) {return defaultValue}if (options.useCustomProperties) {return color.startsWith('--') ? `var(${color})` : `var(--${color})`}return options.colors[color] || defaultValue
}
module.exports = postcss.plugin('postcss-theme-colors', options => {options = Object.assign({}, defaults, options)// 获取色值函数(默认为 cc())const reGroup = new RegExp(`\\b${options.function}\\(([^)]+)\\)`, 'g')return (style, result) => {// 判断 PostCSS 工作流程中,是否使用了某些 pluginsconst hasPlugin = name =>name.replace(/^postcss-/, '') === options.nestingPlugin ||result.processor.plugins.some(p => p.postcssPlugin === name)// 获取最终 CSS 值const getValue = (value, theme) => {return value.replace(reGroup, (match, group) => {return resolveColor(options, theme, group, match)})}// 遍历 CSS 声明style.walkDecls(decl => {const value = decl.value// 如果不含有色值函数调用,则提前退出if (!value || !reGroup.test(value)) {return}const lightValue = getValue(value, 'light') const darkValue = getValue(value, 'dark') const darkDecl = decl.clone({value: darkValue})let darkRule// 使用插件,生成 dark 样式if (hasPlugin('postcss-nesting')) {darkRule = postcss.atRule({name: 'nest',params: `${options.darkThemeSelector} &`,})} else if (hasPlugin('postcss-nested')) {darkRule = postcss.rule({selector: `${options.darkThemeSelector} &`,})} else {decl.warn(result, `Plugin(postcss-nesting or postcss-nested) not found`)}// 添加 dark 样式到目标 HTML 节点中if (darkRule) {darkRule.append(darkDecl)decl.after(darkRule)}const lightDecl = decl.clone({value: lightValue})decl.replaceWith(lightDecl)})}
})

上面代码我加入了相关注释,整体逻辑并不难理解。理解了这部分源码,使用方式也就呼之欲出了:

const colors = {C01: '#eee',C02: '#111',
}
const groups = {G01: ['C01', 'C02'],
}
postcss([require('postcss-theme-colors')({colors, groups}),
]).process(css)

通过上述操作,我们实现了 postcss-theme-colors 插件,整体架构也就完成了大半。接下来,我们将继续完善,最终打造出一个更符合基础建设要求的方案。

架构平台化——色组 & 色值平台设计

上面的使用示例中,我们采用了 hard coding 的方式,如下代码:

const colors = {C01: '#eee',C02: '#111',
}
const groups = {G01: ['C01', 'C02'],
}

声明了colorsgroups两个常量,并传递给 postcss-theme-colors 插件。其中groups变量声明了色组的概念,比如 group1 命名为 G01,它对应了 C01(日间色),C02(夜间色)两个色值。这样的好处显而易见。

  • 我们将 postcss-theme-colors 插件和色值声明解藕, postcss-theme-colors 插件并不关系颜色,而是接受colorsgroups变量。

  • 色值和色组解耦:

    1. colors维护具体色值;

    2. groups维护具体色组。

这样一来,如前文一个超链接样式声明:

a {color: cc(GBK05A);
}

如上代码,我们在业务开发中,直接声明了“使用 GBK05A 这个色组”。业务开发者不需要关心这个色组在 light/dark 模式下分别对应哪些色值。而设计团队可以专门维护色组和色值,最终只提供给开发者色组即可

在此基础上,我们完全可以抽象出一个色组 & 色值平台,方便设计团队更新内容。这个平台可以以 JSON 或者 YML 任何形式存储色值色组对应关系,方便各个团队协作。

在前文提到的主题切换设计架构图的基础上,我们扩充其为平台化的解决方案:

总结

这一讲我们没有聚焦具体 CSS 样式的用法,而是从更高的角度,梳理了现代化前端基础建设当中的样式相关工程方案。并从“主题切换”这一话题,联动了 PostCSS、Webpack,甚至前端状态管理流程。

这里,我想给大家留几个思考问题,postcss-custom-properties 有什么作用,它又是如何实现的?基于 CSS 变量新特性,我们可以如何优化本讲中的 postcss-theme-colors 插件?(答案已经在源代码中了。)

14 解析 Webpack 源码,实现自己的构建工具

前端工程化和基础建设这个话题,自然少不了分析构建工具。作为前端工程中最常见、最经典的构建工具,Webpack 必须要有一个独立小节进行精讲。可是,关于 Webpack 什么样的内容才更有意义呢?当前社区,Webpack 插件编写、loader 编写相关内容已经非常多了,甚至 Tapable 机制也已经有了涉猎。

这一讲,我们独辟蹊径,从 Webpack 的实现入手,帮助你构建一个自己的工程化工具。

Webpack 的初心和揭秘

我不建议对着 Webpack 源码讲解,因为 Webpack 是一个庞大的体系,其源码逐行讲解太过枯燥,真正能转化在技术积累上的内容较少。今天,我们先抽丝剥茧,从 Webpack 的使命谈起,相信你会有一个更加清晰的认知。

Webpack 的介绍只有简单一句:

Webpack is a static module bundler for modern JavaScript applications.

虽然 Webpack 看上去无所不能,但从其本质上来说,Webpack 实质就是一个“前端模块打包器”。前端模块打包器做的事情很简单:它帮助开发者将 JavaScript 模块(各种类型的模块化规范)打包为一个或多个 JavaScript 脚本文件。

我们回到最初起源,前端为什么需要一个模块打包器呢?其实理由很简单:

  • 不是所有浏览器都直接支持 JavaScript 规范;

  • 前端需要管理依赖脚本,把控不同脚本加载的顺序;

  • 前端需要按顺序加载不同类型的静态资源。

想象一下,我们的 Web 应用有这样一段内容:

<html><script src="/src/1.js"></script><script src="/src/2.js"></script><script src="/src/3.js"></script><script src="/src/4.js"></script><script src="/src/5.js"></script><script src="/src/6.js"></script>
</html>

每个 JavaScript 文件都需要额外的 HTTP 请求获取,并且因为依赖关系,1.js6.js需要按顺序加载。因此,打包需求应运而生:

<html><script src="/dist/bundle.js"></script>
</html>

这里需要注意几点:

  • 随着 HTTP/2 技术的推广,未来长远上看,浏览器像上述代码一样发送多个请求不再是性能瓶颈,但目前来看还过于乐观(更多内容参见 HTTP/2 简介);

  • 并不是将所有脚本都打包在一起就是性能最优/dist/bundle.js的 size 一般较大,但这属于另外“性能优化”话题了,相关内容,我们在10 讲“代码拆分和按需加载:缩减 bundle size,把性能做到极致”中已有涉及。

总之,打包器的需求就是前端“刚需”,实现上述打包需要也并不简单,需要考虑:

  • 如何维护不同脚本的打包顺序,保证bundle.js的可用性;

  • 如何避免不同脚本、不同模块的命名冲突;

  • 在打包过程中,如何确定真正需要的脚本,而不将没有用到的脚本排除在bundle.js之外?

事实上,虽然当前 Webpack 依靠 loader 机制实现了对于不同类型资源的解析和打包,依靠插件机制实现了第三方介入编译构建的过程,但究其本质,Webpack 只是一个“无所不能”的打包器,实现了:

a.js + b.js + c.js. => bundle.js

的能力。

下面我们继续揭秘 Webpack 在打包过程中的奥秘。

为了简化,我们以 ESM 模块化规范举例。假设我们有:

  • circle.js模块求圆形面积;

  • square.js模块求正方形面积;

  • app.js模块作为主模块。

对应内容分别如下代码:

// filename: circle.js
const PI = 3.141;
export default function area(radius) {return PI * radius * radius;
}
// filename: square.js
export default function area(side) {return side * side;
}
// filename: app.js
import squareArea from './square';
import circleArea from './circle';
console.log('Area of square: ', squareArea(5));
console.log('Area of circle', circleArea(5));

经过 Webpack 打包之后,我们用bundle.js来表示 Webpack 处理结果(精简并可读化处理后):

// filename: bundle.js
const modules = {'circle.js': function(exports, require) {const PI = 3.141;exports.default = function area(radius) {return PI * radius * radius;}},'square.js': function(exports, require) {exports.default = function area(side) {return side * side;}},'app.js': function(exports, require) {const squareArea = require('square.js').default;const circleArea = require('circle.js').default;console.log('Area of square: ', squareArea(5))console.log('Area of circle', circleArea(5))}
}
webpackBundle({modules,entry: 'app.js'
});

如上代码,我们维护了modules变量,存储了不同模块信息,这个 map 中,key 为模块路径名,value 为一个被 wrapped 过的模块函数,我们先称之为module factory function,该函数形如:

function(exports, require) {// 模块内容
}

这样做是为每个模块提供exportsrequire能力,同时保证了每个模块都处于一个隔离的函数作用域范围

有了modules变量还不够,我们依赖webpackBundle方法,将所有内容整合在一起。webpackBundle方法接收modules模块信息以及一个入口脚本。代码如下:

function webpackBundle({ modules, entry }) {const moduleCache = {};

const require = moduleName => {
// 如果已经解析并缓存过,直接返回缓存内容
if (moduleCache[moduleName]) {
return moduleCache[moduleName];
}

<span class="hljs-keyword">const</span> <span class="hljs-keyword">exports</span> = {};
<span class="hljs-comment">// 这里是为了防止循环引用</span>
moduleCache[moduleName] = <span class="hljs-keyword">exports</span>;
<span class="hljs-comment">// 执行模块内容,如果遇见了 require 方法,则继续递归执行 require 方法 </span>
modules[moduleName](<span class="hljs-keyword">exports</span>, require);<span class="hljs-keyword">return</span> moduleCache[moduleName];

};
require(entry);
}

上述代码中需要注意:webpackBundle 方法中声明的require方法和 CommonJS 规范中的 require 是两回事,该require方法是 Webpack 自己实现的模块化解决方案。

我们通过下图来总结一下 Webpack 风格的打包器原理和流程:

Webpack 打包器原理和流程图

讲到这里,我们再扩充一下另一个打包器——Rollup 的打包原理,针对上述代码,Rollup 打包过后的产出为:

const PI = 3.141;
function circle$area(radius) {return PI * radius * radius;
}
function square$area(side) {return side * side;
}
console.log('Area of square: ', square$area(5));
console.log('Area of circle', circle$area(5));

如上代码,我们看到,Rollup 的原理思想与 Webpack 不同:Rollup 不会维护一个 module map,而是将所有模块拍平(flatten)放到 bundle 中,也就不存在包裹函数(module factory function)。

为了保证命名冲突不出现,Rollup 将函数和变量名进行了改写,在模块脚本circle.jssquare.js中,都命名了一个area方法。经过 Rollup 打包后,area方法根据模块主体,进行了重命名。

我们将 Webpack 和 Rollup 的打包方式进行对比总结。

  • Webpack 理念:

  1. 使用了 module map,维护项目中的依赖关系;

  2. 使用了包裹函数,对每个模块进行包裹;

  3. 使用了一个“runtime”方法(这里举例为webpackBundle),最终合成 bundle 内容。

  • Rollup 理念:

  1. 将每个模块拍平;

  2. 不使用包裹函数,不需要对每个模块进行包裹。

不同的理念也会造成不同的打包结果,这里我想给你留一个思考题:在 Rollup 处理理念下,如果模块出现了循环依赖,会发生什么现象呢

手动实现打包器

前面内容我们剖析了以 Webpak、Rollup 为代表的打包器核心原理。下面内容,我们将手动实现一个自己的简易打包器,我们的目标将会向 Webpack 打包设计对齐。核心思路如下:

  1. 读取入口文件(比如entry.js);

  2. 基于 AST 分析入口文件,并产出依赖列表;

  3. 使用 Babel 将相关模块编译到 ES5;

  4. 对每个依赖模块产出一个唯一的 ID,方便后续读取模块相关内容;

  5. 将每个依赖以及经过 Babel 编译过后的内容,存储在一个对象中进行维护;

  6. 遍历上一步中的对象,构建出一个依赖图(Dependency Graph);

  7. 将各模块内容 bundle 产出。

我们来一步一步实现。首先创建项目:

mkdir bundler-playground && cd $_

并启动 npm:

npm init -y

安装以下依赖:

  • @babel/parser用于分析源代码,产出 AST;

  • @babel/traverse用于遍历 AST,找到 import 声明;

  • @babel/core用于编译,将源代码编译为 ES5;

  • @babel/preset-env搭配@babel/core使用;

  • resolve用于获取依赖的绝对路径。

相关命令:

npm install --save @babel/parser @babel/traverse @babel/core  @babel/preset-env resolve

做完了这些,我们开始核心逻辑的编写,创建index.js,并引入如下依赖代码:

 const fs = require("fs");const path = require("path");const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const babel = require("@babel/core"); const resolve = require("resolve").sync;

接着,我们维护一个全局 ID,并通过遍历 AST,访问ImportDeclaration节点,收集依赖到deps数组中,同时完成 Babel 降级编译:

let ID = 0;

function createModuleInfo(filePath) {
// 读取模块源代码
const content = fs.readFileSync(filePath, “utf-8”);
// 对源代码进行 AST 产出
const ast = parser.parse(content, {
sourceType: “module”
});
// 相关模块依赖数组
const deps = [];
// 遍历模块 AST,将依赖推入 deps 数组中
traverse(ast, {
ImportDeclaration: ({ node }) => {
deps.push(node.source.value);
}
});
const id = ID++;
// 编译为 ES5
const { code } = babel.transformFromAstSync(ast, null, {
presets: [“@babel/preset-env”]
});
return {
id,
filePath,
deps,
code
};
}

上述代码中,相关注释已经比较明晰了。这里需要指出的是,我们采用了自增 ID的方式,如果采用随机的 GUID,会是更安全的做法

至此,我们实现了对一个模块的分析,并产出:

  • 该模块对应 ID;

  • 该模块路径;

  • 该模块的依赖数组;

  • 该模块经过 Babel 编译后的代码。

接下来,我们生成整个项目的依赖树(Dependency Graph)。代码如下:

function createDependencyGraph(entry) {// 获取模块信息const entryInfo = createModuleInfo(entry);// 项目依赖树const graphArr = [];graphArr.push(entryInfo);// 以入口模块为起点,遍历整个项目依赖的模块,并将每个模块信息维护到 graphArr 中for (const module of graphArr) {module.map = {};module.deps.forEach(depPath => {const baseDir = path.dirname(module.filePath);const moduleDepPath = resolve(depPath, { baseDir });const moduleInfo = createModuleInfo(moduleDepPath);graphArr.push(moduleInfo);module.map[depPath] = moduleInfo.id;});}return graphArr;
}

上述代码中,我们使用一个数组类型的变量graphArr来描述整个项目的依赖树情况。最后,我们基于graphArr内容,将相关模块进行打包。

function pack(graph) {const moduleArgArr = graph.map(module => {return `${module.id}: {factory: (exports, require) => {${module.code}},map: ${JSON.stringify(module.map)}}`;});const iifeBundler = `(function(modules){const require = id => {const {factory, map} = modules[id];const localRequire = requireDeclarationName => require(map[requireDeclarationName]); const module = {exports: {}};factory(module.exports, localRequire); return module.exports; }require(0);
    })({${moduleArgArr.join()}})
`;
<span class="hljs-keyword">return</span> iifeBundler;

}

如上代码,我们创建一个对应每个模块的模板对象:

return `${module.id}: {factory: (exports, require) => {${module.code}},map: ${JSON.stringify(module.map)}}`;

factory对应的内容中,我们包裹模块代码,并注入exportsrequire两个参数。同时,我们构造了一个 IIFE 风格的代码区块,用于将依赖树中的代码串联在一起。最难理解的部分在于:

  const iifeBundler = `(function(modules){const require = id => {const {factory, map} = modules[id];const localRequire = requireDeclarationName => require(map[requireDeclarationName]); const module = {exports: {}};factory(module.exports, localRequire); return module.exports; } require(0);})({${moduleArgArr.join()}})`;

针对这段代码,我们进行更细致的分析。

  • 使用 IIFE 的方式,来保证模块变量不会影响到全局作用域。

  • 构造好的项目依赖树(Dependency Graph)数组,将会作为名为modules的行参,传递给 IIFE。

  • 我们构造了require(id)方法,这个方法的意义在于:

  1. 通过require(map[requireDeclarationName])方式,按顺序递归调用各个依赖模块;

  2. 通过调用factory(module.exports, localRequire)执行模块相关代码;

  3. 该方法最终返回module.exports对象,module.exports 最初值为空对象({exports: {}}),但在一次次调用factory()函数后,module.exports对象内容已经包含了模块对外暴露的内容了。

总结

这一讲虽然标题包含“解析 Webpack 源码”,但我们并没有采用源码解读的方式展开,而是从打包器的设计原理入手,换一种角度进行 Webpack 源码解读,并最终动手实现了一个自己的简易打包器。

实际上,打包过程主要分为两步:依赖解析(Dependency Resolution)和代码打包(Bundling):

  • 在依赖解析过程中,我们通过 AST 技术,找到每个模块的依赖模块,并组合为最终的项目依赖树。

  • 在代码打包过程中,我们使用 Babel 对源代码进行编译,其中也包括了对 imports / exports(即对 ESM) 的编译。

整个过程稍微有些抽象,需要你用心体会。

主要内容总结为下图:

在实际生产环节,打包器当然功能更多,比如需要考虑:code spliting 甚至 watch mode 以及 reloading 能力等。但是不管什么样的特性和能力,只要我们理清最初心,掌握最基本的思想,任何疑问都会迎刃而解。

15 从编译到运行,跨端解析小程序多端方案

客观来说,小程序在用户规模及商业化方面的巨大成功,并不能掩盖其技术环节的设计问题和痛点。从孱弱简陋的小程序开发体验,到整体架构实现,再到小程序 APIs 碎片化现状,就注定了小程序多端方案层出不穷,展现出百家争鸣的局面。

欣欣向荣的小程序多端方案背后有着深广且有趣的技术话题,这一讲,就让我们一同解析小程序多端方案技术。

小程序生态如今已经如火如荼地开展开来,自腾讯微信小程序后,各巨头也纷纷建立起自己的小程序。这些小程序的设计原理类似,但是对于开发者来说,开发层面并不互通。在此背景下,效率为先,也就有了各种小程序多端方案。

小程序多端方案的愿景很简单,就是使用一种 DSL,可以“write once,run evrywhere”,这也就不再需要开发完微信小程序,再开发头条小程序、百度小程序。小程序多端方案也许听起来很神奇,但技术实现上我们可以大体划分为三类:

  • 编译时方案

  • 运行时方案

  • 编译时和运行时的结合方案

事实上,单纯的编译时方案或运行时方案都不能完全满足跨端需求,因此两者结合而成的第三种——编译时和运行时的结合方案,是目前的主流技术。

基于以上技术方案,小程序多端方案最终对外提供的使用方式可以分为:

  • 类 Vue 风格框架

  • 类 React 风格框架

  • 自定义 DSL 框架

下面我们将具体深入小程序多端方案的实现。

小程序多端——编译时方案

顾名思义,编译时方案的工作量主要集中在编译转化环节上。这类多端框架在编译阶段,基于 AST(抽象语法树)技术进行各平台小程序适配。

目前社区上存在较多基于 Vue DSL 和 React DSL 的静态编译方案。其实现理念类似,但也有区别,我们分开来看。

Vue DSL 静态编译

Vue 的设计风格和各小程序设计风格更加接近,因此 Vue DSL 静态编译方案相对容易。Vue 中单文件组件主要由:

  • template

  • script

  • style

组成,它分别对应了小程序中的:

  • .wxml 文件,template 文件

  • .js 文件,.json 文件

  • .wxss 文件

其中,因为小程序基本都可以接受 H5 环境中的 CSS,因此style 部分基本可以直接平滑迁移。template 转换为 .wxml 文件,需要进行 HTML 标签、模版语法的转换。以微信小程序举例,转换目标如下图:

编译过程图

那么上图表述的编译过程具体应该如何实现呢?可能你会想到正则,但正则的能力有限,复杂度也较高。更普遍的做法,如 mpvue、uni-app 等,都依赖了 AST(抽象语法树)技术。AST(抽象语法树)其实并不复杂,Babel 生态就为我们提供了很多开箱即用的 AST 分析和操作工具。下图是一个简单的 Vue 模版经过 AST 分析后的产出:

对应模版代码:

<a><b v-if="a" /></a>

经过 AST 解析为:

type: 1
tag: a
attrsList: []
attrsMap: {}
rawAttrsMap: {}
children:- type: 1tag: battrsList: []attrsMap:v-if: arawAttrsMap: {}children: []if: aifConditions:- exp: ablock: '[Circular ~.children.0]'plain: truestatic: falsestaticRoot: falseifProcessed: true
plain: true
static: false
staticRoot: false

基于以上类似 JSON 一般的 AST 产出结果,我们可以生成小程序指定的 DSL。整体流程如图:

熟悉 Vue 原理的同学可能会知道,Vue 中 template 会被 vue-loader 编译,我们的小程序多端方案就需要将 Vue 模版编译为小程序 .wxml 文件,思路异曲同工。可是,也许你会有疑问:Vue 中的 script 部分,怎么和小程序结合呢?这就需要在小程序运行时下文章功夫了,请继续阅读。

小程序多端——运行时方案

前面我们介绍了 Vue 单文件组件的 template 编译过程,而 script 部分的处理会更加困难。试想,对于一段 Vue 代码,我们通过响应式理念监听数据变化,触发视图修改,放到小程序中,多端方案要做的就是监听数据变化,调用 setData() 方法,触发小程序渲染层变化。

一般在 Vue 单文件组件的 script 部分,我们会使用以下代码:

new Vue({data() {},methods: {},components: {}
})

来初始化一个 Vue 实例。对于多端方案来说,就完全可以引入一个 Vue 的运行时版,对上述代码进行解析和执行。事实上,mpvue 就是 fork 了一份 Vue.js 的代码,因此内置了 Vue runtime 能力,同时添加了小程序平台的支持。

具体还需要做哪些小程序平台特性支持呢?举一个例子,以微信小程序为例,微信小程序平台规定,小程序页面中需要有一个 Page() 方法,以生成一个小程序实例,其中 Page() 方法是小程序官方提供的 API。

那么对于业务方写的new Vue()代码,多端平台要手动执行微信小程序平台的 Page(),完成初始化处理,如下:

经过上述步骤,我们的多端方案内置了 Vue 运行时版,并实例化了一个 Vue 实例,同时在初始阶段调用了小程序平台的 Page() 方法,因此也就有了一个小程序实例。

下面的工作,就是在运行时将 Vue 实例和小程序实例进行关联,以做到:数据变动时,小程序实例能够调用 setData() 方法,进行渲染层更新。

思想确立后,如何实施呢?首先这就需要你对 Vue 原理足够清楚了:Vue 基于响应式,对数据进行监听,在数据改动时,新生成一份虚拟节点 VNode。接下来对比新旧两份虚拟节点,找到 Diff,并进行 patch 操作,最终更新了真实的 DOM 节点

因为小程序架构中,并没有提供操作小程序节点的 API 方法,因此对于小程序多端方案,我们显然不需要进行 Vue 源码中的 patch 操作。又因为小程序隔离了渲染进程(渲染层)和逻辑进程(逻辑层),我们不需要处理渲染层,只需要调用 setData() 方法,更新一份最新的数据就可以了。

因此,借助 Vue 现有的能力,我们秉承“数据部分让 Vue 运行时版接手,渲染部分让小程序架构接手”的理念,就能实现一个类 Vue 风格的多端框架。框架原理如图:

类 Vue 风格的多端框架原理图

当然,整个框架的设计还要考虑事件处理等模块,我们就不再具体展开。

至此,编译时和运行时方案组合在一起,我们就实现了一个类 Vue 风格的小程序多端框架的技术方案架构。目前社区上都是采用了这一套技术架构方案,但是不同框架有各自的特点,比如网易考拉 Megalo 在上述方案的基础上,将整个数据结构进行了扁平化,目的是在调用 setData() 方法时,可以获得更好的性能

探索并没有到此为止,事实上,类 React 的小程序多端方案架构虽然道理和类 Vue 方案差不多,也需要将编译时和运行时相结合,但很多重要环节的处理却更加复杂,这是怎么回事呢?我们继续探索。

小程序多端——类 React 风格的编译时和运行时结合方案

类 React 风格的小程序多端方案,存在多项棘手的问题,其中之一就是:如何将 JSX 转换为小程序模版?

我们知道不同于 Vue 模版理念,React 生态选择了 JSX 来表达视图,但是 JSX 过于灵活,单纯基于 AST(抽象语法树)技术很难进行一对一转换。比如:

function CompParent({children, ...props}) {return typeof children === 'function' ? children(props) : null
}
function Comp() {return (<CompParent>{props => <div>{props.data}</div>}</CompParent>)
}

这段代码是 React 中,利用 JSX 表达能力实现的 Render Prop 模式,这也是静态编译的噩梦:如果不将代码运行,很难计算出需要表达的视图结果

针对这个“JSX 处理”问题,类 React 风格的小程序多端方案就可以分成两个流派:

  • 强行静态编译型,代表有:京东的 Taro 1/2,去哪儿的 Nanachi 等;

  • 运行时处理型,代表有:Taro Next,蚂蚁的 Remax。

强行静态编译型需要业务使用方在编写代码时,规避掉一些难以在编译阶段处理的动态化的写法,因此这类多端框架说到底是使用了限制的、阉割版的 JSX。比如在早期 Taro 版本的文档中,就有了清晰的说明:

因此,我认为强行静态编译 JSX 是一条死胡同,并不是一个完美的解决方案。事实上,Taro 发展到了 v3 版本之后,也意识到了这个问题,所以和蚂蚁 Remax 方案一样,Taro 新版本进行了架构升级,在运行时增加了对 React JSX 以及后续流程处理。具体是怎么做到的呢?请你继续阅读。

React 设计理念助力多端小程序起飞

我认为在运行时开发者能够处理 React JSX 的核心基础其实在于 React 的设计理念,React 将自身能力充分解耦,并提供给社区接入关键环节。这里我们需要先进行一些 React 原理解析。

React 核心理念可以分为三大部分:

  • React Core:处理最核心的 APIs,与终端平台和渲染解耦,主要提供了下面这些能力:

    1. React.createElement()

    2. React.createClass()

    3. React.Component

    4. React.Children

    5. React.PropTypes

  • React Renderer:渲染器定义了一个 React Tree 如何构建接轨不同平台,比如:

    1. React-dom 渲染组件树为 DOM elements;

    2. React Native 渲染组件树为不同原生平台视图。

  • Reconciler:负责 diff 算法,接驳 patch 行为。可以被 React-dom、React Native、React ART 这些 renderers 共用,并提供基础计算能力。现在 React 主要有两种类型的 reconcilers:

    1. Stack reconciler,React 15 以及更早期 React 版本使用;

    2. Fiber reconciler,新一代的架构。

更多基础内容,如 React Components、React Instances、React Elements,我们就不再一一展开。这里需要你了解的是:

  • React team 将 Reconciler 部分作为一个独立的 npm 包(react-reconciler 发布);

  • 在 React 环境下,不同平台,可以依赖一个 hostConfig 配置,和 react-reconciler 互动,连接并使用 Reconciler 能力;

  • 因此,不同平台的 renderers 在 HostConfig 中内置基本方法,即可构造自己的渲染逻辑。

核心架构可以总结为下图:

React 的 Reconciler 并不关心 renderers 中的节点是什么形状,只会把这个计算结果透传到 HostConfig 中定义的方法中,我们在这些方法(比如 appendChild、removeChild、insertBefore)中,完成渲染的准备和目的。而 HostConfig 其实就是一个对象:

const HostConfig = {//TODO We will specify all required methods here
}

翻看 react-reconciler 源码,可以总结出,完整的 hostConfig 包含了:

HostConfig.getPublicInstance
HostConfig.getRootHostContext
HostConfig.getChildHostContext
HostConfig.prepareForCommit
HostConfig.resetAfterCommit
HostConfig.createInstance
HostConfig.appendInitialChild
HostConfig.finalizeInitialChildren
HostConfig.prepareUpdate
HostConfig.shouldSetTextContent
HostConfig.shouldDeprioritizeSubtree
HostConfig.createTextInstance
HostConfig.scheduleDeferredCallback
HostConfig.cancelDeferredCallback
HostConfig.setTimeout
HostConfig.clearTimeout
HostConfig.noTimeout
HostConfig.now
HostConfig.isPrimaryRenderer
HostConfig.supportsMutation
HostConfig.supportsPersistence
HostConfig.supportsHydration
// -------------------
//      Mutation
//     (optional)
// -------------------
HostConfig.appendChild
HostConfig.appendChildToContainer
HostConfig.commitTextUpdate
HostConfig.commitMount
HostConfig.commitUpdate
HostConfig.insertBefore
HostConfig.insertInContainerBefore
HostConfig.removeChild
HostConfig.removeChildFromContainer
HostConfig.resetTextContent
HostConfig.hideInstance
HostConfig.hideTextInstance
HostConfig.unhideInstance
HostConfig.unhideTextInstance
// -------------------
//     Persistence
//     (optional)
// -------------------
HostConfig.cloneInstance
HostConfig.createContainerChildSet
HostConfig.appendChildToContainerChildSet
HostConfig.finalizeContainerChildren
HostConfig.replaceContainerChildren
HostConfig.cloneHiddenInstance
HostConfig.cloneUnhiddenInstance
HostConfig.createHiddenTextInstance
// -------------------
//     Hydration
//     (optional)
// -------------------
HostConfig.canHydrateInstance
HostConfig.canHydrateTextInstance
HostConfig.getNextHydratableSibling
HostConfig.getFirstHydratableChild
HostConfig.hydrateInstance
HostConfig.hydrateTextInstance
HostConfig.didNotMatchHydratedContainerTextInstance
HostConfig.didNotMatchHydratedTextInstance
HostConfig.didNotHydrateContainerInstance
HostConfig.didNotHydrateInstance
HostConfig.didNotFindHydratableContainerInstance
HostConfig.didNotFindHydratableContainerTextInstance
HostConfig.didNotFindHydratableInstance
HostConfig.didNotFindHydratableTextInstance

React reconciler 阶段会在不同的时机,调用上面这些方法。比如在 reconciler 阶段新建节点时会调用 createInstance 等方法;在提交阶段创建新的子节点时,调用 appendChild 方法。

依照 React 支持 web 和原生(React Native)的思路,如下图:

你可以类比出一套更好的 React 支持多端小程序的架构设计,如下图:

我们知道类 Vue 风格的多端框架,可以将 Vue template 编译为小程序模版。那么有了数据,类 React 风格的多端框架,在初始化时如何渲染出来页面呢?

以 Remax 为例,上图所示 VNodeData 数据中,包含了节点信息,比如 type=“view”,我们可以通过递归 VNodeData 这个数据结构,根据不同的 type 渲染出不同的小程序模版

总结一下,在初始化阶段以及第一次 mount 时,我们通过 setData() 方法初始化小程序。具体是通过递归数据结构,渲染小程序页面。接着,在数据发生变化时,我们通过 React reconciler 阶段的计算信息,以及自定义配置的 HostConfig 衔接函数,更新数据,并通过 setData() 方法触发小程序的渲染更新。

了解了类 React 风格的多端方案架构设计,我们可以结合实际框架实现,来进一步巩固思想,看一看实践中,开源方案的实施情况,请继续阅读。

剖析一款“网红”框架 —— Taro Next

在 2019 年 GMTC 大会上,京东 Taro 团队介绍了《小程序跨框架开发的探索与实践》,其中的 v3 理念就与上述思路吻合(目前仍然在版本开发中:NervJS-taro)。在分享中的一处截图如下:

由上图即可推知:Taro 团队提供的 taro-react包,是用来连接 React reconciler 和 taro-runtime 的。它主要负责:

  • 实现 HostConfig 配置

  • 实现 render 函数

比如,HostConfig 在 taro-react 源码中的实现为:

const hostConfig: HostConfig<string, // TypeProps, // PropsTaroElement, // ContainerTaroElement, // InstanceTaroText, // TextInstanceTaroElement, // HydratableInstanceTaroElement, // PublicInstanceobject, // HostContextstring[], // UpdatePayloadunknown, // ChildSetunknown, // TimeoutHandleunknown // NoTimeout
> & {hideInstance (instance: TaroElement): voidunhideInstance (instance: TaroElement, props): void
} = {// 创建 element 实例createInstance (type) {return document.createElement(type)},// 创建 text node 实例createTextInstance (text) {return document.createTextNode(text)},getPublicInstance (inst: TaroElement) {return inst},getRootHostContext () {return {}},getChildHostContext () {return {}},// appendChild 方法实现appendChild (parent, child) {parent.appendChild(child)},// appendInitialChild 方法实现appendInitialChild (parent, child) {parent.appendChild(child)},// appendChildToContainer 方法实现appendChildToContainer (parent, child) {parent.appendChild(child)},// removeChild 方法实现removeChild (parent, child) {parent.removeChild(child)},// removeChildFromContainer 方法实现removeChildFromContainer (parent, child) {parent.removeChild(child)},// insertBefore 方法实现insertBefore (parent, child, refChild) {parent.insertBefore(child, refChild)},// insertInContainerBefore 方法实现insertInContainerBefore (parent, child, refChild) {parent.insertBefore(child, refChild)},// commitTextUpdate 方法实现commitTextUpdate (textInst, _, newText) {textInst.nodeValue = newText},finalizeInitialChildren (dom, _, props) {updateProps(dom, {}, props)return false},prepareUpdate () {return EMPTY_ARR},commitUpdate (dom, _payload, _type, oldProps, newProps) {updateProps(dom, oldProps, newProps)},hideInstance (instance) {const style = instance.stylestyle.setProperty('display', 'none')},unhideInstance (instance, props) {const styleProp = props.stylelet display = styleProp?.hasOwnProperty('display') ? styleProp.display : nulldisplay = display == null || typeof display === 'boolean' || display === '' ? '' : ('' + display).trim()// eslint-disable-next-line dot-notationinstance.style['display'] = display},shouldSetTextContent: returnFalse,shouldDeprioritizeSubtree: returnFalse,prepareForCommit: noop,resetAfterCommit: noop,commitMount: noop,now,scheduleDeferredCallback,cancelDeferredCallback,clearTimeout: clearTimeout,setTimeout: setTimeout,noTimeout: -1,supportsMutation: true,supportsPersistence: false,isPrimaryRenderer: true,supportsHydration: false
}

以 insertBefore 方法为例:

insertBefore (parent, child, refChild) {parent.insertBefore(child, refChild)
},

parent 实际上是一个 TaroNode 对象,其 insertBefore 方法在 taro-runtime 中给出。taro-runtime 模拟了 DOM/BOM APIs,但是在小程序环境中,它并不能直接操作 DOM 节点,而是操作数据(即前文提到的 VNodeData,对应 Taro 里面的 TaroNode)。比如源码中,仍然以 insertBefore 方法举例,相关处理逻辑为:

public insertBefore<T extends TaroNode> (newChild: T, refChild?: TaroNode | null, isReplace?: boolean): T {newChild.remove()newChild.parentNode = this// payload 数据let payload: UpdatePayload// 存在 refChild(TaroNode 类型)if (refChild) {const index = this.findIndex(this.childNodes, refChild)this.childNodes.splice(index, 0, newChild)if (isReplace === true) {payload = {path: newChild._path,value: this.hydrate(newChild)}} else {payload = {path: `${this._path}.${Shortcuts.Childnodes}`,value: () => this.childNodes.map(hydrate)}}} else {this.childNodes.push(newChild)payload = {path: newChild._path,value: this.hydrate(newChild)}}
CurrentReconciler.insertBefore?.(<span class="hljs-keyword">this</span>, newChild, refChild)
<span class="hljs-keyword">this</span>.enqueueUpdate(payload)
<span class="hljs-keyword">return</span> newChild

}

整体 Taro Next 的类 React 多端方案架构如图,出自《小程序跨框架开发的探索与实践》分享:

了解了不同框架风格(Vue 和 React)的多端小程序技术架构方案,并不意味着我们就能直接写出一个新的框架,和社区上成熟方案相争锋指日可待了。一个成熟的技术方案除了实现主体架构,还包括多方面的内容,比如性能优化。

如何在已有思路基础上,完成更好的设计,也值得开发者深思,我们将继续展开这个话题。

小程序多端方案优化方向

一个成熟的小程序多端方案要考虑的环节是立体的,比如以 kbone 为代表,运行时方案都是通过模拟 Web 环境来彻底对接前端生态,而 Remax 只是简单的通过 react reconciler 连接 React 和小程序。如何从更高的角度,衡量和理解小程序多端方案的更多技术方向,我们从下面几个话题来继续阐述。

性能优化方向

从前文我们可以了解到,小程序多端框架主要由编译时和运行时两部分组成,一般来说,编译时做的事情越多,下的功夫越大,也就意味着运行时越轻量,负担越小,因此性能也就会更好。比如,我们可以在编译时做到 AOT(Ahead of Time)性能调优、Dead Code Elimination 等。而厚重的运行时一般意味着需要将完整的组件树在逻辑层传输到视图层,也就导致数据传输量更大,且页面中会存在更多的监听器。

另一方面,随着终端性能的增强,找到编译时和运行时所承担工作的平衡点,也显得至关重要。以 mpvue 框架为主,一般编译时都会完成静态模版的编译工作;而以 Remax 为代表,动态构建视图层表达就放在了运行时完成

在我看来,关于运行时和编译时的各中取舍,需要基于大量 benchmark 调研,也需要开发设计者广阔的技术视野和选型能力。除此之外,一般我们可以从以下几个方面来进一步实现性能优化。

  • 框架包 size。小程序的初始加载性能直接依赖于资源的包大小,因此小程序多端框架的包 size,至关重要。为此,各解决方案都从不同的角度完成瘦身,比如 Taro 力争实现更轻量的 DOM/BOM APIs,不同于 jsdom(size:2.1M),Taro 的核心的 DOM/BOM APIs 代码才 1000 行不到。

  • 数据更新粒度。在数据更新阶段,小程序的 setData() 所负载的数据一直是重要的优化方向,目前已经成为默认的常规手段,那么利用框架来完成 setData() 方法调用优化也就顺其自然了。比如数据负载的扁平化处理和增量处理,都是常见的优化手段。

未来发展方向

好的技术架构决定着未来发展潜力,上文我们提到了 React 将 React core、React-dom 等解耦,才奠定了现代化小程序多端方案的可行性。而小程序多端方案的设计,也决定着自身的未来应用空间。在此层面上,我认为开发者可重点考虑以下几个方面。

  • 工程化方案。小程序多端需要有一体化的工程解决方案,在设计上可以与 Webpack 等工程化工具深度融合绑定,并对外提供服务。但需要兼顾关键环节的可插拔性,能够适应多种工程化工具,对于未来发展和当下应用场景来说,尤其重要。

  • 框架方案。React 和 Vue 无疑是当前最重要的前端框架,目前小程序多端方案也都以二者为主。但是Flutter 和 Angular,甚至更小众的框架也应该得到重视。考虑到投入产出比,如果小程序多端团队难以面面俱到地支持这些框架和新 DSL,那么交给社区寻求支持,也是一个思路。比如,Taro 团队将支持的重点放在 React/Vue,而快应用以及 Flutter 和 Angular,暂且交给社区来适配和维护。

  • 跟进 Web 发展。在运行时,小程序多端方案一般需要在小程序逻辑层中运行 React 或者是 Vue 的运行时版,然后通过适配层,实现自定义渲染器。这就要求设计开发者需要跟进 Web 发展及 Web 框架的运行时能力,且实现适配层。这无疑对技术能力和水平提出了较高要求。如何处理 Web 和 Web 框架的关系、如何保持兼容互通,决定了小程序多端方案的生死。

  • 渐进增强型能力。无论是和 Web 兼容互通还是多种小程序之间的差异磨平,对于多端方案来说,很难从理论上彻底实现“write once,run evrywhere”。因此,这就需要框架级别上实现一套渐进增强能力。这种能力,可以是语法或 DSL 层面的暂时性妥协/便利性扩展,也可以通过暴露全局变量,进行不同环境的业务分发。比如腾讯开源的 OMIX 框架:OMIX 有自己的一套 DSL,但整体保留小程序已有的语法。在小程序已有语法之上,OMIX 还进行了扩充和增强,比如引入了 Vue 中比较有代表性的 computed。

总结

这一讲我们针对小程序多端方案进行了原理层面的分析,同时站在更高的视角,对不同方案和多端框架进行了比对和技术展望。实际上,理解全部内容需要你对 React 和 Vue 框架原理有更深入的了解,也需要对编译原理和宿主环境(小程序底层实现架构)有清晰的认知。

本讲内容如下:

从小程序发展元年开始,到 2018 微信小程序的起飞,再到后续各大厂商快速跟进、各大寡头平台自建小程序生态,小程序现象带给我们的不仅仅是业务价值方面的讨论和启迪,也应该是对相关技术架构的巡礼和探索。作为开发者,我认为对技术的深度挖掘和运用,是能够始终矗立在时代风口浪尖的重要根基。

下一讲,我将带你分析 Flutter 和原生跨平台技术栈,同时梳理当下相关技术热点。跨平台其实是一个老生常谈的话题,技术方案也是历经变迁,但始终热点不断。下一讲的内容和今天的内容也有着千丝万缕的联系,别走开,我们下一讲再见!

前端架构设计第六课工程化构建、编译、运行相关推荐

  1. 前端架构设计第十一课 自动化构建部署和工具

    23 npm cript:打造一体化的构建和部署流程 之前我们提到过,一个顺畅的基建流程离不开 npm scripts.npm scripts 将工程化的各个环节串联起来,相信任何一个现代化的项目都有 ...

  2. 前端架构设计第十课 前端数据结构和算法

    21 如何利用 JavaScript 实现经典数据结构? 前面几讲我们从编程思维的角度分析了软件设计哲学.从这一讲开始,我们将深入数据结构这个话题. 数据结构是计算机中组织和存储数据的特定方式,它的目 ...

  3. 前端架构设计第一课 CI环境npm/Yarn

    开篇词 像架构师一样思考,突破技术成长瓶颈 透过工程基建,架构有迹可循.你好,我是侯策(LucasHC),目前任职于某互联网独角兽公司,带领 6 条业务线前端团队,负责架构设计和核心开发.工程方案调研 ...

  4. 前端进阶之路: 前端架构设计(2)-流程核心

    可能很多人和我一样, 首次听到"前端架构"这个词, 第一反应是: "前端还有架构这一说呢?" 在后端开发领域, 系统规划和可扩展性非常关键, 因此架构师备受重视 ...

  5. 前端架构设计1:代码核心

    现在的前端领域, 随着JS框架, UI框架和各种库的丰富, 前端架构也变得十分的重要. 如果一个大型项目没有合理的前端架构设计, 那么前端代码可能因为不同的开发人员随意的引入各种库和UI框架, 导致代 ...

  6. 浅谈京东静态html原理,京东首页前端架构设计.ppt

    京东首页前端架构设计 工程化 Windows可视化工具 * 工程化 前端模块构建平台 * 总结 * QA * * JD.com JD.com JD.com JD.com JD.com JD.com J ...

  7. 走向ASP.NET架构设计-第六章-服务层设计(中篇)

    走向ASP.NET架构设计-第六章-服务层设计(中篇) 前言:上一篇文章介绍了一些服务层的基本知识,而且也简要的介绍了SOA的有关知识,本篇主要是介绍在服务层可以采用的一些模式.  本篇议题如下: F ...

  8. 前端架构设计应该包含哪些东西?

    前端架构设计 后台架构设计概念适用于前端,前端没有数据库设计,所以可以不考虑并发. vuejs的优点,一样适用于前端项目.高内聚,低耦合,可复用,单元测试. 从项目的生命周期,开发.上线.维护三个阶段 ...

  9. 架构方面学习笔记(3)-前端架构设计

    2022.02.08 今天读了一篇关于前端整洁架构的设计,因此对其中的内容进行了一些整理以及我自己的思考,后续阅读<领域驱动设计>后可以加入更多的内容. References: 前端领域的 ...

最新文章

  1. [转载] 信息系统项目管理师考试论文写作要点
  2. Go-技篇第一 技巧杂烩
  3. pandas使用select_dtypes函数移除dataframe中指定数据类型的数据列(exclude columns based on the data type in dataframe)
  4. python压缩与解压缩
  5. Jupyter 服务开发指南
  6. 最常用的javascript自定义函数大搜集
  7. 神经网络(补充)(第五章)
  8. TypeScript类型推论(Type Inference)
  9. ERROR: No matching distribution found for mysqlclient==2.0.3
  10. [Angularjs]ng-select和ng-options(转载)
  11. 【搬运】 Cadence orcad常用库olb介绍
  12. 双十一值得入手的数码好物有哪些?分享几款不错的数码好物
  13. PackageManager使用
  14. 北大青鸟网络工程师benet培训招生问答
  15. 2021-07-10蓝桥杯单片机学习知识点总结
  16. GeoServer style(sld)中文乱码解决方法
  17. Unity -Demo 之 ✨ 语音识别-讯飞SDK使用过程
  18. 搜狗输入法 与fcitx-ui-qimpanel冲突_搜狗AI录音笔E2预售开启,不只是录音笔,还能拍照翻译?丨43周新闻...
  19. 微信小程序云开发之收藏文章功能的简单实现
  20. php 上传pdf文件损坏,pdf文件损坏打不开怎么解决

热门文章

  1. MYSQL的sqlca详解_sql数据库如何使用
  2. html如何上下滚动字幕,css如何做滚动字幕效果?
  3. php在线查毒,linux clamav 免费查毒工具
  4. 和菜头_《写给应届毕业生的99条箴言》读书笔记
  5. MyEclipse javaw.exe-没有软盘错误
  6. 计算机博士5篇sci,8篇SCI论文,5篇TOP期刊——一个博士的独白
  7. 使用pandas对excel追加列数据
  8. ASP+Access的安全隐患及对策(转)
  9. Tita :OKR落地推行的具体方法
  10. 最近在写一款斗地主游戏,写了个癞子匹配顺子的算法