鸿蒙应用开发-知识:rollup打包

2 阅读11分钟

Hvigor

hvigor是一款基于TS实现的 构建任务编排工具,主要提供任务管理机制,包括任务注册编排、工程模型管理、配置管理等关键能力,更符合node生态开发者开发习惯

它不负责实际的打包,实际打包的工作是通过rollup来做的。

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 模块是未来的官方标准和主流

tree-shaking

tree-shaking 本质上是 消除无用的 JS 代码。 当引入一个模块时,并不引入整个模块的所有代码,而是只引入需要的代码,那些不需要的无用代码就会被”摇“掉

tree-shaking 虽然能够消除无用代码,但仅针对 ES6 模块语法,因为 ES6 模块采用的是静态分析,从字面量对代码进行分析

DCE(dead code elimination)

无用代码有一个专业术语 - dead code elimination(DCE)。编译器可以判断出哪些代码并不影响输出,然后消除这些代码。DCE主要包括以下几个方面

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

基于两个关键实现

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

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

    1. 基于作用域,在 AST 过程中对函数或全局对象形成对象记录
    2. 在整个形成的作用域链对象中进行匹配 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方法,虽然其他方法未被使用,但是整个类是被打包进去的

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 之后,生成的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 中,一个文件就是一个模块

  2. 每一个模块都会根据文件中的代码生成一个 AST 抽象语法树,之后会对树上的每一个 AST 节点进行分析

  3. 分析 AST 节点,就是看看这个节点有没有调用函数或方法。如果有,就查看所调用的函数或方法是否在当前作用域,如果不在就往上找,直到找到模块顶级作用域为止。

  4. 如果本模块都没找到,说明这个函数、方法依赖于其他模块,需要从其他模块引入

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

大框架流程

image.png

读取入口文件

  1. rollup() 首先生成一个 Bundle 实例,也就是打包器。
  2. 然后根据入口文件路径去读取文件,最后根据文件内容生成一个 Module 实例

rollup->bundle.build->fetchModule

image.png

new Module()过程

在 new 一个 Module 实例时,会调用 acorn 库的 parse() 方法将代码解析成 AST

image.png

分析导入和导出的模块,将引入的模块和导出的模块填入对应的对象
  1. 每个 Module 实例都有一个 imports 和 exports 对象,作用是将该模块引入和导出的对象填进去
  2. 填入的时候可以看到key是导入的名称,value是具有source、name、localName这些key的对象

image.png

image.png

分析每个 AST 节点的作用域,找出节点中定义的变量

每遍历到一个 AST 节点,都会为它生成一个 Scope 实例

image.png

Scope 的作用很简单,它有一个 names 属性数组,用于保存这个 AST 节点内的变量

image.png

分析标识符,并找出它们的依赖项

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

  1. 当解析到一个标识符时,rollup 会遍历它当前的作用域,看看有没这个标识符。
  2. 如果没有找到,就往它的父级作用域找。
  3. 如果一直找到模块顶级作用域都没找到,就说明这个函数、方法依赖于其它模块,需要从其他模块引入。
  4. 如果一个函数、方法需要被引入,就将它添加到 statement_dependsOn 对象里。生成代码时会根据 _dependsOn 里的值来引入文件

image.png

根据依赖项,读取对应的文件

rollup根据语句_dependsOn里面依赖的标识符名称,在模块的imports里面查找它对应的文件。然后读取这个文件生成一个新的 Module 实例

image.png

image.png

生成代码

到了这一步之后我们就已经引入了所有的函数,这是调用 Bundle 的 generate() 方法生成代码。这一步还会做一些额外的操作

移除额外代码

例如从 foo.js 中引入的 foo1() 函数代码是这样的:export function foo1() {}

rollup 会移除掉 export,变成 function foo1() {}。因为最终会把所有的代码都写入到一个文件中,所以也就不存在export,所有的代码都在一个文件里

重命名

例如两个模块中都有一个同名函数 foo(),打包到一起时,会对其中一个函数重命名,变成 _foo(),以避免冲突

参考资料

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