背景
鸿蒙采用了前端的webpack,rollup等打包工具对js代码进行模块化整合打包,为了后续能够对整条打包工具链进行速度优化或者提供相应的效率插件,有必要对rollup、webpack等打包工具的实现逻辑有一定的理解。本文先了解一下rollup的实现逻辑。
现状
先了解一下现有的js打包工具以及打包工具的作用:
前端打包工具一堆,webpack,rspack,rollup,esbuild,吹的天花乱坠其实核心只有两个:
- 解决代码兼容问题
所谓的环境兼容问题,简单的说就是开发想用新的语法糖比如es6,但是各家出的浏览器对es6还没有完全支持,所以需要有一个中间的js transpiler(这里使用转义器的叫法而不是跟随前端叫编译器的原因是客户端一般认为只有高级语言转二进制或者字节码才被认为是编译,也就是我们认为在v8引擎里面跑的那个才是编译器,而这里解决兼容的工具比如babel,或者使用rust写的swc做的事情是把es6->es5,只能算是同类语言语法的转换而已)做一个代码转换,完成对各种代码的兼容。
下图是华子自己写的一个ets的rollup转义插件,可以将arkui中的一些特殊定义的ets语法转义成原始的ts语法
- 性能优化
js每个文件就是一个模块,打包工具把所有的代码根据依赖整合到一个或者少量的bundle.js生成文件,主要的作用就是干掉无用代码,优化代码性能,减少http请求,让v8引擎在接收需要跑的js文件的代码就处于一个良好优化的状态从而提高运行时效率
所以打包工具的作用非常类似于iOS侧静态链接器,以及安卓侧的dx
技术原理
打包工具在hvigor中的位置
先看看hvigor的启动流程混淆后的源码:
fork出子线程然后执行相关的代码
对外发出构建通知
启动构建
开始进行创建依赖解析有向图,并执行hook代码
根据配置开始执行任务
下面就是按照预先定义好的tasks以及有向依赖图去执行对应的js编译脚本
最终ark-compile.js会调用到rollup,这里会去加载compile_plugin.js脚本然后调用rollup命令进行打包
这个图是华为官网给出的hvigor工具链的运行构建三阶段,基本跟上面的源码能对应上。
也正如hvigor文档中介绍的那样,华为对hvigor和hvigor-ohos-plugin的分层是这样的
-
hvigor是构建工具,提供任务注册编排,编译工程模型管理,编译配置定制,插件扩展等核心能力,
-
hvigor-ohos-plugin基于hvigor插件机制开发的一款插件,服务于OpenHarmonyOS应用构建工作流,完成HAP/APP打包
rollup一般用于子仓的打包任务,所以和rollup关联的其实就是task,也就是在执行阶段,每一个子模块都会触发一个task runner 通过调用rollup去完成一个鸿蒙module的打包任务,完成从ets->ts的转义,并且合成一个bundle.js(暂时还未找到中间产物生成位置,有点奇怪,安卓是有的,但是模块的build文件夹中没有任何东西,感觉是合成以后被删除了)
rollup
上面说明了rollup核心就是两件事,一个是转义,一个是文件合并与优化
-
转义
rollup转义的工作其实是交给插件babel去做的,本身只承担了转义工作的前置任务,将ES代码转换成AST树(当然这个任务也不是rollup干的是调用的acronjs,本文不解析AST树的生成,因为这又是一个涉及到编译原理的更大范围的话题了)
比如
(a, b) => a + b;
对应的AST.
下面以一个非常简单的arrow函数转普通函数的示例来说明转义插件一般是怎么实现的:
//转换前
const add = (a, b) => a + b;
//转换后
var add = function (a, b) {
return a + b;
};
const arrowFunctionPlugin = {
visitor: {
ArrowFunctionExpression(path) {
const { params, body } = path.node;
const functionExpression = types.functionExpression(null, params, body, false, false);
path.replaceWith(functionExpression);
}
}
};
其实从AST树的视角来看逻辑非常简单,我们准备好基于输入参数节点,返回值节点生成一个ES5语法的方法(这里就是现成的functionExpression),然后babel插件遍历AST中的ArrowFunctionExpression节点,并将其替换为functionExpression节点就可以了。至于其他的新的特性语法其实转换方式都与此类似。
-
文件合并与优化
文件合并的逻辑
在rollup中文件就是模块。每个模块都会根据文件的代码生成一个 AST ,rollup 需要对每一个 AST 节点进行分析。分析 AST 节点,看看这个节点有没有调用函数或方法。如果有,就查看所调用的函数或方法是否在当前作用域,如果不在就往上找,直到找到模块顶级作用域为止。如果本模块都没找到,说明这个函数、方法依赖于其他模块,需要从其他模块引入。例如
import abc from './abc.js'
其中 abc 就得从 abc.js 文件找。在引入 abc 函数的过程中,如果发现 abc 函数依赖其他模块,就会递归读取其他模块,如此循环直到没有依赖的模块为止。最后将所有引入的代码打包在一起。
稍微过一下v0.3.1版本的rollup源代码,看看超级简化版本的实现是咋样的:
准备开始打包入口,先生成一个bundle类做打包这个事情
export function rollup ( entry, options = {} ) {
const bundle = new Bundle(
entry,
resolvePath: options.resolvePath
});
return bundle.build().then( () => {
return {
generate: options => bundle.generate( options ),
write: ( dest, options = {} ) => {
//省略
}
}
}
}
读取文件内容生成一个module,然后在new的过程中去解析出ast
readFile( path, { encoding: 'utf-8' })
.then( code => {
const module = new Module({
path,
code,
bundle: this
});
return module;
});
this.ast = parse( code, {
ecmaVersion: 6,
sourceType: 'module',
onComment: ( block, text, start, end ) => this.comments.push({ block, text, start, end })
});
analyse( this.ast, this.code, this );
然后再去遍历整个AST树,分析出相关的函数,变量名的作用域
function addToScope ( declarator ) {
var name = declarator.id.name;
scope.add( name, false );
if ( !scope.parent ) {
currentTopLevelStatement._defines[ name ] = true;
}
}
当解析到一个标识符时,rollup 会遍历它当前的作用域,看看有没这个标识符。如果没有找到,就往它的父级作用域找。如果一直找到模块顶级作用域都没找到,就说明这个函数、方法依赖于其它模块,需要从其他模块引入。如果一个函数、方法需要被引入,就将它添加到 Module 的 dependson 对象里
if ( ( !definingScope || definingScope.depth === 0 ) && !statement._defines[ node.name ] ) {
statement._dependsOn[ node.name ] = true;
}
这里其实还包含了一个优化代码的逻辑,就是没有在任何dependson对象中的符号就会被干掉。最后bundle.generate生成最终的合并文件。
总结下来就是
- 解析源码生成 AST: 首先,Rollup 使用 Acorn 解析每个模块的源码,生成对应的 AST。
- 构建作用域树: 在生成 AST 后,Rollup 会遍历 AST,并构建作用域树。这个树表示了代码中不同作用域的层次结构,包括全局作用域、函数作用域、块作用域等。
分析 AST 节点:
- 记录声明: 在遍历 AST 时,Rollup 会记录每个作用域中的声明(如变量声明、函数声明)。这些声明会被存储在作用域对象中,以便后续查找。
- 处理引用: 当遇到引用(如变量引用、函数调用)时,Rollup 会从当前作用域向上查找,直到找到对应的声明。这种查找过程确保了变量和函数的引用能够正确解析到它们的定义位置。
- Tree Shaking: Rollup 会根据作用域树的信息,确定哪些代码是未使用的,并将其移除。这通过分析 AST 节点的引用和使用情况来实现。
- 生成代码: 最后,Rollup 会根据优化后的 AST 重新生成代码,并输出打包后的文件。