鸿蒙应用开发-rollup打包和高阶函数

863 阅读17分钟

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主要包括以下几个方面

  • 代码不会被执行,不可到达
  • 代码执行的结果不会被用到
  • 代码只会影响死变量,只写不读

image.png

基于两个关键实现

  1. ES6 的模块引入是静态分析的,可以在编译时正确判断到底加载了什么代码。 ES6 Module一些特性如下

    • 只能作为模块顶层的语句出现,不能出现在 function 或是 if等块级作用域中
    • import 的模块名只能是字符串常量
    • import binding 是 immutable 的,类似 const
    • import hoisted,不管 import的语句出现的位置在哪里,在模块初始化的时候所有的**import 都必须已经导入完成**
  2. 分析程序流,判断哪些变量被使用、引用,打包这些代码

    • 基于作用域,在 AST 过程中对函数或全局对象形成对象记录
    • 在整个形成的作用域链对象中进行匹配 import 导入的标识,最后只打包匹配的代码,而删除那些未被匹配使用的代码

下面的截图中 index.js 是入口文件,打包生成的代码在 bundle.js 中,除此之外的 a.js、util.js 等文件均作为被引用的依赖模块

1)消除未使用的变量

image.png

a.js中定义的变量 b 和 c 没有使用到,它们不会出现在打包后的bundle.js文件中

2)消除未被调用的函数

image.png

仅引入但未使用到的 util3()和 util2()函数没有被打包进来

3)消除未被使用的类

image.png

只引用类文件 mixer.js 但实并未用 它的任何方法和变量,该类不会出现在bundle.js文件中

4)未消除的副作用-模块中类的方法未被引用

image.png

引用类文件 mixer.js并使用了其中的getName方法,虽然其他方法未被使用,但是整个类是被打包进去的

之所以无法消除,是因为JS中有一些动态调用的存在

image.png

5)未消除的副作用-模块中定义的变量影响了全局变量

image.png

a.js和utils.js模块中都给window.c进行了重新赋值,他们的引入顺序会影响window上c这个属性的最终值

Rollup探索

AST 抽象语法树

树上定义了代码的结构,通过操作这棵树,可以精准的定位到声明语句、赋值语句、运算语句等等。实现对代码的分析、优化、变更等操作

image.png

AST工作流

  • Parse(解析) 将源代码转换成抽象语法树,树上有很多的estree节点
  • Transform(转换) 对抽象语法树进行转换
  • Generate(代码生成) 将上一步经过转换过的抽象语法树生成新的代码

image.png

打包流程

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 实例进行处理

image.png

具体细节可以参考,原理:无用代码去哪了?项目减重之 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());

目录结构简介

image.png

image.png

工作过程简述

  1. rollup读取入口文件,将代码通过Acorn解析成AST,然后递归的读取和解析依赖的文件代码
  2. 在 rollup 中,一个文件就是一个模块,每一个模块都会根据文件中的代码生成一个 AST 抽象语法树
  3. 对树上的每一个 AST 节点进行分析,看看这个节点有没有调用函数或方法,有没有引用变量。如果有,就查看所调用的函数或方法是否在当前作用域,如果不在就往上找,直到找到模块顶级作用域为止。
  4. 如果本模块都没找到,说明这个函数、方法依赖于其他模块,需要从其他模块引入。如果发现其他模块中有方法依赖其他模块,就会递归读取其他模块,如此循环直到没有依赖的模块为止
  5. 找到这些变量或者方法是在哪里定义的,把定义语句包含进来即可 其他无关代码一律不要
import { name, age, MyCls } from './modules/myModule'

我们从myModule中引入了name、age、MyCls这几个变量,就需要从myModule文件查找
在引入这几个变量的过程中,如果发现变量还依赖其他模块,就会递归读取其他模块,如此循环直到没有所引入的变量不再依赖的模块为止
  1. 最后将所有引入的代码打包在一起,写入最终的单个文件中。这个文件通过-o指定

举例看过程

  1. 按照如下的截图创建目录和文件,之后进入工程目录下执行 npm install,之后执行npm run build命令,生成的文件位于dist/bundle.js
  2. 可以看到,代码中没有用到的变量是不会被打进去的。一般开发库或者框架会选择rollup进行打包,可以减少代码的体积

image.png

大框架流程

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() )
				]);
			}
		};
	});
}

读取入口文件

  1. rollup() 首先生成一个 Bundle 实例,也就是打包器。
  2. 然后根据入口文件路径去读取文件,根据文件内容生成一个 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】模块,将引入的模块和导出的模块存储起来
  1. 每个 Module 实例都有一个 imports 和 exports 对象,作用是将该模块引入和导出的对象存储起来
  2. 存储的时候可以看到key是导入的名称,value是具有source、name、localName这些key的对象

image.png

// 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 实例

image.png

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;
	}
}
分析标识符,并找出它们的依赖项

标识符:变量名,函数名,属性名等。

  1. 当解析到一个标识符时,rollup 会遍历它当前的作用域,看看有没这个标识符。
  2. 如果没有找到,就往它的父级作用域找。
  3. 如果一直找到模块顶级作用域都没找到,就说明这个函数、方法依赖于其它模块,需要从其他模块引入。
  4. 如果一个函数、方法需要被引入,就将它添加到 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;
			});
	}

image.png

生成代码

到了这一步之后我们就已经引入了所有的函数,这是调用 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的变量实际的模块名或者文件路径

总结一下,我们能学到什么

  1. 对于工具类最好用函数来写,除非你需要将变量保存下来方便在方法中调用。这有利于tree shaking
  2. 尽量使用静态引入,减少动态引入的代码

高阶函数

高阶函数就是一个 接收函数类型的参数或者返回函数类型的返回值 的函数。当然也可以既接收函数类型的参数,也返回函数类型的返回值

高阶函数在代码中的应用

image.png

看下怎么调用

首先通过hasItemFilter获取一个函数类型的返回值,将这个返回值作为参数传递到genOptionsResult中

然后调用genOptionsResult获取返回值,这个返回值又是一个函数类型,紧接着我们可以按照函数的形式用小括号调用

image.png

看一下hasItemFilter的定义

image.png

小结

  1. 在JS中函数和其他类型一样,可以用在任意地方,只需要将函数当成特殊类型的变量就行
  2. 在JS中借助Lodash或Ramda等库,还可以对函数进行柯里化,柯里化的过程就是将一个原始函数转换成一个高阶函数

参考资料

  1. developtools_ace_ets2bundle  gitee.com/openharmony…   
  2. developtools_ace_ets2bundle中的rollup打包配置 gitee.com/openharmony…
  3. www.lodashjs.com/
  4. ramda.cn/docs/#all
  5. acorn
  6. magic-string
  7. rollup git仓库
  8. rollup npm package
  9. rollup官网
  10. rollup 在线体验 repl
  11. 在线查看JS的抽象语法树AST
  12. 工具:在线 ES6转ES5
  13. 工具:Google traceur 将ES转码成JS(适用于浏览器端)
  14. 工具:在线查看AST astexplorer
  15. 工具:在线查看AST语法树 esprima
  16. [工具: 揭秘 Rollup Tree Shaking](segmentfault.com/a/119000004…)
  17. 原理:无用代码去哪了?项目减重之 rollup 的 Tree-shaking 2.47.0版本
  18. 原理:从 rollup 初版源码学习打包原理 2.26.5版本
  19. 原理:浅析Rollup打包原理 0.3.1版本
  20. 原理:rollup打包原理 0.3.0版本
  21. 原理:rollup打包产物解析及原理(对比webpack)
  22. rollup - 构建原理及简易实现
  23. 原理:Rollup概念与运行原理
  24. 使用:rollup从入门到打包一个按需加载的组件库
  25. 使用:【实战篇】最详细的Rollup打包项目教程
  26. 使用:Rollup打包工具的使用(超详细,超基础,附代码截图超简单)
  27. 使用:一文带你快速上手Rollup
  28. 简单:关于Rollup那些事
  29. 使用Acorn解析JavaScript](juejin.cn/post/684490…)
  30. Roll打包系列文章
  31. rollup 与 Tree shaking juejin.cn/post/706903…
  32. 原理:揭秘 Rollup Tree Shaking https://github.com/careteenL/rollup
  33. Tree-Shaking性能优化实践 - 原理篇 www.developers.pub/article/112…