Hvigor
华为文档是这么介绍Hvigor的。developer.huawei.com/consumer/cn…
hvigor是一款基于TS实现的 构建任务编排工具,主要提供任务管理机制,包括任务注册编排、工程模型管理、配置管理等关键能力。在执行任何任务之前会构建任务依赖图,所有任务会形成一个有向无环图
hvigor插件和hvigorfile.ts文件中的构建脚本都将通过任务依赖机制对任务依赖图做出影响
hvigor提供了很多可以进行hook修改的点,给任务编排提供了很大的灵活性。实际打包的工作是通过developtools_ace_ets2bundle来做的,这里面既包括rollup,也包括webpack。
本篇我们先介绍下rollup
在下图中所有绿色标记的线框为可以使用的hook点。每个hook点的使用方式请参考基础构建能力。
构建不同的任务可以参考这里
developer.huawei.com/consumer/cn…
rollup
一个用于 JavaScript 的 模块打包工具,它将小的代码片段编译成更大、更复杂的代码
rollup打包工具在前端很早就有了,与它齐名的还有webpack,webpack主要用于打包web应用。
rollup基于ESM模块打包,能同时处理Nodejs和浏览器的JS打包工作,它还会自动对代码进行tree shaking减小包的体积,在对库和模块打包时非常有用。React、Vue等框架的构建就是使用的rollup
rollup 支持的打包文件的格式有 amd, cjs, esm/es, iife, umd。其中amd 为 AMD 标准,cjs 为 CommonJS 标准,esm/es为 ES 模块标准,iife 为立即调用函数, umd 同时支持 amd、cjs 和 iife
rollup.js 默认采用 ES 模块标准,ESM:ECMAScript 模块是官方标准和主流
整个流程是这样的:开发者写esm代码 -> rollup通过入口,递归识别esm模块 -> (可以支持配置输出多种格式的模块,如esm、cjs、umd、amd)最终打包成一个或多个bundle.js
tree-shaking
tree-shaking 本质上是 消除无用的 JS 代码。 当引入一个模块时,并不引入整个模块的所有代码,而是只引入需要的代码,那些不需要的无用代码就会被”摇“掉
tree-shaking 虽然能够消除无用代码,但仅针对 ES6 模块语法,因为 ES6 模块采用的是静态分析,从字面量对代码进行分析
DCE(dead code elimination)
无用代码有一个专业术语 - dead code elimination(DCE)。编译器可以判断出哪些代码并不影响输出,然后消除这些代码。DCE主要包括以下几个方面
- 代码不会被执行,不可到达
- 代码执行的结果不会被用到
- 代码只会影响死变量,只写不读
基于两个关键实现
-
ES6 的模块引入是静态分析的,可以在编译时正确判断到底加载了什么代码。 ES6 Module一些特性如下
- 只能作为模块顶层的语句出现,不能出现在
function
或是if
等块级作用域中 - import 的模块名只能是字符串常量
- import binding 是 immutable 的,类似
const
- import hoisted,不管
import
的语句出现的位置在哪里,在模块初始化的时候所有的**import
都必须已经导入完成**
- 只能作为模块顶层的语句出现,不能出现在
-
分析程序流,判断哪些变量被使用、引用,打包这些代码
- 基于作用域,在 AST 过程中对函数或全局对象形成对象记录
- 在整个形成的作用域链对象中进行匹配 import 导入的标识,最后只打包匹配的代码,而删除那些未被匹配使用的代码
下面的截图中 index.js 是入口文件,打包生成的代码在 bundle.js 中,除此之外的 a.js、util.js 等文件均作为被引用的依赖模块
1)消除未使用的变量
a.js中定义的变量 b 和 c 没有使用到,它们不会出现在打包后的bundle.js文件中
2)消除未被调用的函数
仅引入但未使用到的 util3()和 util2()函数没有被打包进来
3)消除未被使用的类
只引用类文件 mixer.js 但实并未用 它的任何方法和变量,该类不会出现在bundle.js文件中
4)未消除的副作用-模块中类的方法未被引用
引用类文件 mixer.js并使用了其中的getName方法,虽然其他方法未被使用,但是整个类是被打包进去的
之所以无法消除,是因为JS中有一些动态调用的存在
5)未消除的副作用-模块中定义的变量影响了全局变量
a.js和utils.js模块中都给window.c进行了重新赋值,他们的引入顺序会影响window上c这个属性的最终值
Rollup探索
AST 抽象语法树
树上定义了代码的结构,通过操作这棵树,可以精准的定位到声明语句、赋值语句、运算语句等等。实现对代码的分析、优化、变更等操作
AST工作流
- Parse(解析) 将源代码转换成抽象语法树,树上有很多的estree节点
- Transform(转换) 对抽象语法树进行转换
- Generate(代码生成) 将上一步经过转换过的抽象语法树生成新的代码
打包流程
rollup的打包流程主要是 通过遍历输入的文件,生成抽象语法树AST并对AST进行剪枝做treeshaking功能,然后把最终用到的代码写入到输出文件中。有以下两个阶段
-
rollup()阶段,解析源码,生成 AST tree,对 AST tree 上的每个节点进行遍历,判断出是否 include(标记避免重复打包),是的话标记,然后生成 chunks,最后导出。
- 通过 resolveId()方法解析文件地址,拿到文件绝对路径
- 通过从入口文件的绝对路径出发找到它的模块定义,并获取这个入口模块所有的依赖语句并返回所有内容
- 每个文件都是一个模块,每个模块都会有一个 Module 实例。在 Module 实例中,模块文件的代码通过 acorn 的 parse 方法遍历解析为 AST 语法树
- 将 source 解析并设置到当前 module 上,完成从文件到模块的转换,并解析出 ES tree node 以及其内部包含的各类型的语法树
-
generate()/write()阶段,根据 rollup()阶段做的标记,进行代码收集,最后生成真正用到的代码
- 将经处理生成后的代码写入文件,handleGenerateWrite()方法内部生成了 bundle 实例进行处理
具体细节可以参考,原理:无用代码去哪了?项目减重之 rollup 的 Tree-shaking。
我看了下大体代码差不多。rollup最新版本为4.9.6 并且使用wasm技术,感兴趣的同学可以查看 github.com/rollup/roll…
为了简单的探索rollup的打包原理,我使用的版本为0.3.1
rollup中两个比较重要的库是 acorn 和 magic-string
acorn
一个JS语法解析器,用于将JS代码组成的字符串解析成抽象语法树AST。rollup 使用它来实现 AST 抽象语法树的遍历解析
比如这个代码 export default function add(a, b) { return a + b }
通过 在线查看AST astexplorer.net/ 之后,生成的AST如下
{
"type": "Program",
"start": 0,
"end": 50,
"body": [
{
"type": "ExportDefaultDeclaration",
"start": 0,
"end": 50,
"declaration": {
"type": "FunctionDeclaration",
"start": 15,
"end": 50,
"id": {
"type": "Identifier",
"start": 24,
"end": 27,
"name": "add"
},
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 28,
"end": 29,
"name": "a"
},
{
"type": "Identifier",
"start": 31,
"end": 32,
"name": "b"
}
],
"body": {
"type": "BlockStatement",
"start": 34,
"end": 50,
"body": [
{
"type": "ReturnStatement",
"start": 36,
"end": 48,
"argument": {
"type": "BinaryExpression",
"start": 43,
"end": 48,
"left": {
"type": "Identifier",
"start": 43,
"end": 44,
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 47,
"end": 48,
"name": "b"
}
}
}
]
}
}
}
],
"sourceType": "module"
}
AST是一棵树,由一个个的节点组成,每个节点都有一个 type 字段表示类型,例如 Identifier 表示一个标识符;BlockStatement 表示一个块语句;ReturnStatement 表示一个return语句等
树的根节点 type 是 Program 表示一个程序,这个程序内所有语句对应的代码的AST位于 body 字段下
magic-string
一个操作字符串的库,可以方便的替换、移除字符串中内容,并将字符串写入文件。rollup 使用它来操作字符串和生成 source-map 文件
下面是官方的一些示例用法
import MagicString from 'magic-string';
import fs from 'fs'
const s = new MagicString('problems = 99');
s.update(0, 8, 'answer');
s.toString(); // 'answer = 99'
s.update(11, 13, '42'); // character indices always refer to the original string
s.toString(); // 'answer = 42'
s.prepend('var ').append(';'); // most methods are chainable
s.toString(); // 'var answer = 42;'
const map = s.generateMap({
source: 'source.js',
file: 'converted.js.map',
includeContent: true
}); // generates a v3 sourcemap
fs.writeFileSync('converted.js', s.toString());
fs.writeFileSync('converted.js.map', map.toString());
目录结构简介
工作过程简述
- rollup读取入口文件,将代码通过Acorn解析成AST,然后递归的读取和解析依赖的文件代码
- 在 rollup 中,一个文件就是一个模块,每一个模块都会根据文件中的代码生成一个 AST 抽象语法树
- 对树上的每一个 AST 节点进行分析,看看这个节点有没有调用函数或方法,有没有引用变量。如果有,就查看所调用的函数或方法是否在当前作用域,如果不在就往上找,直到找到模块顶级作用域为止。
- 如果本模块都没找到,说明这个函数、方法依赖于其他模块,需要从其他模块引入。如果发现其他模块中有方法依赖其他模块,就会递归读取其他模块,如此循环直到没有依赖的模块为止
- 找到这些变量或者方法是在哪里定义的,把定义语句包含进来即可 其他无关代码一律不要
import { name, age, MyCls } from './modules/myModule'
我们从myModule中引入了name、age、MyCls这几个变量,就需要从myModule文件查找
在引入这几个变量的过程中,如果发现变量还依赖其他模块,就会递归读取其他模块,如此循环直到没有所引入的变量不再依赖的模块为止
- 最后将所有引入的代码打包在一起,写入最终的单个文件中。这个文件通过-o指定
举例看过程
- 按照如下的截图创建目录和文件,之后进入工程目录下执行 npm install,之后执行npm run build命令,生成的文件位于dist/bundle.js
- 可以看到,代码中没有用到的变量是不会被打进去的。一般开发库或者框架会选择rollup进行打包,可以减少代码的体积
大框架流程
export function rollup ( entry, options = {} ) {
// 第一步:生成一个Bundle实例,管理本次的打包
const bundle = new Bundle({
entry,
resolvePath: options.resolvePath
});
// 第二步:bundle.build对代码进行编译,内部会生成从入口文件开始,递归的按照每个文件生成一个Module
return bundle.build().then( () => {
return {
generate: options => bundle.generate( options ),
write: ( dest, options = {} ) => {
let { code, map } = bundle.generate({
dest,
format: options.format,
globalName: options.globalName
});
code += `\n//# ${SOURCEMAPPING_URL}=${basename( dest )}.map`;
// 第三步:完成时会同时生成bundle.js文件和source-map文件
return Promise.all([
writeFile( dest, code ),
writeFile( dest + '.map', map.toString() )
]);
}
};
});
}
读取入口文件
rollup()
首先生成一个Bundle
实例,也就是打包器。- 然后根据入口文件路径去读取文件,根据文件内容生成一个
Module
实例
rollup->bundle.build->fetchModule
fetchModule ( importee, importer ) {
return Promise.resolve( importer === null ? importee : this.resolvePath( importee, importer ) )
.then( path => {
if ( !path ) {
// external module
if ( !has( this.modulePromises, importee ) ) {
// 如果是外部的一个Module,生成一个ExternalModule并存放在externalModules中
const module = new ExternalModule( importee );
this.externalModules.push( module );
this.modulePromises[ importee ] = Promise.resolve( module );
}
return this.modulePromises[ importee ];
}
if ( !has( this.modulePromises, path ) ) {
// 根据文件路径读取代码,生成Module实例
this.modulePromises[ path ] = readFile( path, { encoding: 'utf-8' })
.then( code => {
const module = new Module({
path,
code,
bundle: this
});
return module;
});
}
return this.modulePromises[ path ];
});
}
new Module()过程
每个文件都是一个模块,每个模块都会有一个 Module 实例。在 Module 实例中,会调用 acorn 库的 parse() 方法将代码解析成 AST
// 每个文件都是一个模块,每个模块都会有一个 Module 实例
export default class Module {
constructor ({ path, code, bundle }) {
this.bundle = bundle; // 属于哪个Bundle的实例
this.path = path; // 模块路径
this.relativePath = relative( bundle.base, path ).slice( 0, -3 ); // remove .js
this.code = new MagicString( code, {
filename: path
});
this.suggestedNames = {};
this.comments = [];
try {
// 解析代码并生成AST语法树
this.ast = parse( code, {
ecmaVersion: 6, // 要解析的 JavaScript 的 ECMA 版本,这里按 ES6 解析
sourceType: 'module', // sourceType值为 module 和 script。module 模式,可以使用 import/export 语法
onComment: ( block, text, start, end ) => this.comments.push({ block, text, start, end })
});
} catch ( err ) {
err.file = path;
throw err;
}
// 分析语法树上的每一个节点
this.analyse();
}
analyse 会 分析导入和导出的模块,将引入的模块和导出的模块填入对应的对象
分析当前模块导入【import】和导出【exports】模块,将引入的模块和导出的模块存储起来
- 每个
Module
实例都有一个imports
和exports
对象,作用是将该模块引入和导出的对象存储起来 - 存储的时候可以看到key是导入的名称,value是具有source、name、localName这些key的对象
// import { name, age, MyCls } from './modules/myModule'
// 这段代码对应的imports如下
// key 为要引入的具体对象,value 为对应的 AST 节点内容。
imports = {
name: { source: './modules/myModule', name: 'name', localName: 'name' },
age: { source: './modules/myModule', name: 'age', localName: 'age' },
MyCls: { source: './modules/myModule', name: 'MyCls', localName: 'MyCls' }
}
// 由于没有导出的对象,所以为空
exports = {}
分析每个 AST 节点的作用域,找出节点中定义的变量
每遍历到一个 AST 节点,都会为它生成一个 Scope
实例
Scope
的作用很简单,它有一个 names
属性数组,用于保存这个 AST 节点内的变量
// 作用域
export default class Scope {
constructor ( options ) {
options = options || {};
this.parent = options.parent; // 父级作用域
this.depth = this.parent ? this.parent.depth + 1 : 0; // 作用域深度
this.names = options.params || []; // 作用域内的变量名
this.isBlockScope = !!options.block; // 是否是块级作用域
}
add ( name, isBlockDeclaration ) { // 添加变量名
if ( !isBlockDeclaration && this.isBlockScope ) {
// it's a `var` or function declaration, and this
// is a block scope, so we need to go up
this.parent.add( name, isBlockDeclaration );
} else {
this.names.push( name );
}
}
contains ( name ) { // 是否包含某个变量
return !!this.findDefiningScope( name );
}
findDefiningScope ( name ) { // 查找定义变量的作用域
if ( ~this.names.indexOf( name ) ) {
return this;
}
if ( this.parent ) {
return this.parent.findDefiningScope( name );
}
return null;
}
}
分析标识符,并找出它们的依赖项
标识符:变量名,函数名,属性名等。
- 当解析到一个标识符时,rollup 会遍历它当前的作用域,看看有没这个标识符。
- 如果没有找到,就往它的父级作用域找。
- 如果一直找到模块顶级作用域都没找到,就说明这个函数、方法依赖于其它模块,需要从其他模块引入。
- 如果一个函数、方法需要被引入,就将它添加到
statement
的_dependsOn
对象里。生成代码时会根据_dependsOn
里的值来引入文件
ast.body.forEach( statement => {
function checkForReads ( node, parent ) {
if ( node.type === 'Identifier' ) {
// disregard the `bar` in `foo.bar` - these appear as Identifier nodes
if ( parent.type === 'MemberExpression' && node !== parent.object ) {
return;
}
// disregard the `bar` in { bar: foo }
if ( parent.type === 'Property' && node !== parent.value ) {
return;
}
const definingScope = scope.findDefiningScope( node.name );
// 如果不属于一个作用域或者作用域深度为0 并且语句定义里也没有这个标识符。就判定为是一个依赖
if ( ( !definingScope || definingScope.depth === 0 ) && !statement._defines[ node.name ] ) {
statement._dependsOn[ node.name ] = true;
}
}
}
根据依赖项,读取对应的文件
rollup根据语句_dependsOn里面依赖的标识符名称,在模块的imports里面查找它对应的文件。然后读取这个文件生成一个新的 Module
实例
expandStatement ( statement ) {
if ( statement._included ) return emptyArrayPromise;
statement._included = true;
let result = [];
// We have a statement, and it hasn't been included yet. First, include
// the statements it depends on
// 获取语句依赖的标识符有哪些
const dependencies = Object.keys( statement._dependsOn );
// 递归获取依赖的语句
return sequence( dependencies, name => {
// 在这里读取对应的代码文件
return this.define( name ).then( definition => {
result.push.apply( result, definition );
});
})
// then include the statement itself
.then( () => {
result.push( statement );
})
// then include any statements that could modify the
// thing(s) this statement defines
.then( () => {
return sequence( keys( statement._defines ), name => {
const modifications = has( this.modifications, name ) && this.modifications[ name ];
if ( modifications ) {
return sequence( modifications, statement => {
if ( !statement._included ) {
return this.expandStatement( statement )
.then( statements => {
result.push.apply( result, statements );
});
}
});
}
});
})
// the `result` is an array of statements needed to define `name`
.then( () => {
return result;
});
}
生成代码
到了这一步之后我们就已经引入了所有的函数,这是调用 Bundle
的 generate()
方法生成代码。这一步还会做一些额外的操作
移除额外代码
例如从 foo.js
中引入的 foo1()
函数代码是这样的:export function foo1() {}
。
rollup 会移除掉 export,
变成 function foo1() {}
。因为最终会把所有的代码都写入到一个文件中,所以也就不存在export,所有的代码都在一个文件里
重命名
例如两个模块中都有一个同名函数 foo()
,打包到一起时,会对其中一个函数重命名,变成 _foo()
,以避免冲突
最后把 AST 节点的源码 addSource 到 magicString,并通过magicString.toString()将代码写入最终文件bundle.js中
鸿蒙中关于rollup的介绍也有一小段
developer.huawei.com/consumer/cn…
DevEco IDE中模块间的依赖关系通过oh-package.json5中的dependencies进行配置。dependencies列表中所有模块默认都会进行安装(本地模块)或下载(远程模块),但是不会默认参与编译。HAP/HSP编译时会以入口文件(一般为Index.ets/ts)开始搜索依赖关系,搜索到的模块或文件才会加入编译。
在编译期,静态import和常量动态import可以被打包工具rollup及其插件识别解析,加入依赖树中,参与到编译流程,最终生成方舟字节码。但是如果是变量动态import,该变量值可能需要进行运算或者外部传入才能得到,在编译态无法解析出其内容,也就无法加入编译。为了将这部分模块/文件加入编译,还需要额外增加一个runtimeOnly的buildOption配置,用于配置动态import的变量实际的模块名或者文件路径
总结一下,我们能学到什么
- 对于工具类最好用函数来写,除非你需要将变量保存下来方便在方法中调用。这有利于tree shaking
- 尽量使用静态引入,减少动态引入的代码
高阶函数
高阶函数就是一个 接收函数类型的参数或者返回函数类型的返回值 的函数。当然也可以既接收函数类型的参数,也返回函数类型的返回值
高阶函数在代码中的应用
看下怎么调用
首先通过hasItemFilter获取一个函数类型的返回值,将这个返回值作为参数传递到genOptionsResult中
然后调用genOptionsResult获取返回值,这个返回值又是一个函数类型,紧接着我们可以按照函数的形式用小括号调用
看一下hasItemFilter的定义
小结
- 在JS中函数和其他类型一样,可以用在任意地方,只需要将函数当成特殊类型的变量就行
- 在JS中借助Lodash或Ramda等库,还可以对函数进行柯里化,柯里化的过程就是将一个原始函数转换成一个高阶函数
参考资料
- developtools_ace_ets2bundle gitee.com/openharmony…
- developtools_ace_ets2bundle中的rollup打包配置 gitee.com/openharmony…
- www.lodashjs.com/
- ramda.cn/docs/#all
- acorn
- magic-string
- rollup git仓库
- rollup npm package
- rollup官网
- rollup 在线体验 repl
- 在线查看JS的抽象语法树AST
- 工具:在线 ES6转ES5
- 工具:Google traceur 将ES转码成JS(适用于浏览器端)
- 工具:在线查看AST astexplorer
- 工具:在线查看AST语法树 esprima
- [工具: 揭秘 Rollup Tree Shaking](segmentfault.com/a/119000004…)
- 原理:无用代码去哪了?项目减重之 rollup 的 Tree-shaking 2.47.0版本
- 原理:从 rollup 初版源码学习打包原理 2.26.5版本
- 原理:浅析Rollup打包原理 0.3.1版本
- 原理:rollup打包原理 0.3.0版本
- 原理:rollup打包产物解析及原理(对比webpack)
- rollup - 构建原理及简易实现
- 原理:Rollup概念与运行原理
- 使用:rollup从入门到打包一个按需加载的组件库
- 使用:【实战篇】最详细的Rollup打包项目教程
- 使用:Rollup打包工具的使用(超详细,超基础,附代码截图超简单)
- 使用:一文带你快速上手Rollup
- 简单:关于Rollup那些事
- 使用Acorn解析JavaScript](juejin.cn/post/684490…)
- Roll打包系列文章
- rollup 与 Tree shaking juejin.cn/post/706903…
- 原理:揭秘 Rollup Tree Shaking https://github.com/careteenL/rollup
- Tree-Shaking性能优化实践 - 原理篇 www.developers.pub/article/112…