umijs / mako

An extremely fast, production-grade web bundler based on Rust.
https://makojs.dev
MIT License
1.83k stars 67 forks source link

[RFC] tree-shaking 支持 dev #853

Open PeachScript opened 9 months ago

PeachScript commented 9 months ago

背景

目前 Mako 的 tree-shaking 仅支持 build,在 dev 阶段下会有如下问题:

  1. 构建产物尺寸变大,耗时变长
  2. 构建产物与生产的差异较大,开发阶段的功能验证不一定与生产等价(如果 tree-shaking 误删了内容只能部署后才能发现)

社区的竞品例如 Farm、Webpack 都是支持 dev 阶段做 tree-shaking 的。

面临的问题

目前已知最大的问题是 #396 ,在初次构建时 tree-shaking 会过滤 module_graph 中的 module 以及 module 中的 AST 语句,而在增量构建时需要重新 parse 修改的文件为 AST,其引用的模块有可能已经被删掉了,最终导致构建失败,举个例子:

// a.ts 初次构建时 Mako 发现引用 a 的模块根本没用到 b,所以把 b.ts 从 module_graph 中删掉了
export { default as b } from './b';

// a.ts 二次构建时加了条 console,但 b 已经无了所以会导致 chunk 生成失败
export { default as b } from './b';
console.log(balabala);

这个问题本质是增量构建时 parse 的 AST 没有做 tree-shaking,它所需要的模块关系和 module_graph 中存在的关系不是一回事,那能不能在增量构建 parse AST 以后做一遍 tree-shaking 呢?

// a.ts 二次构建时把 AST 做一遍 tree-shaking 变成
console.log(balabala);

的确是可以的,但实现起来却没这么简单,tree-shaking 需要感知的副作用可能是向外扩散的,修改的文件 AST 的副作用可能由引入它的祖先或依赖文件决定,那么增量 tree-shaking 的范围就不太好控制了,根据 @stormslowly 的调研,Farm 就是采用类似思路,在增量构建时去搜索到当前改动模块最小的拓扑图,单独对这个拓扑做一次 tree-shaking,再更新回 module_graph。

// a.ts 二次构建时如果发现 b 被用到了,module_graph 中也要能找到 b 并且重新建立 edge 才行
export { default as b } from './b';
console.log(b);

不过,这个方案的代价是,我们要么需要存储两份 module_graph 的 edge 及两份 AST(tree-shaking 前后各一份)确保最小拓扑的准确性,要么需要在增量构建的时候基于修改模块重新生成一份最小 graph,前者会增加内存消耗,后者会增加时间消耗,Mako 既然站在巨人的肩膀上,就希望能试着找到一个更加均衡的方案。

实现思路

既然问题核心是 module_graph 的修改不可逆而 tree-shaking 又依赖原始信息,那倘若我们让 module_graph 变得可逆呢?

# 当前流程示意

                               ┌────────────────┐
 context          ┌────────────►  module_graph  ◄────────────┐
                  │            └────────▲───────┘            │
                  │                     │                    │
                  │                     │                    │
              add │ module              │                    │
                  │ & ast         prune │ module        read │ module
                  │                     │ & ast              │ & ast
                  │                     │                    │
                  │                     │                    │
                  │                     │                    │
                  │                     │                    │
                  │                     │                    │
             ┌────┴────┐       ┌────────┴───────┐     ┌──────┴───────┐     ┌────────┐
    flow     │  build  ├───────►  tree-shaking  ├─────►  gen chunks  ├─────►  emit  │
             └─────────┘       └────────────────┘     └──────────────┘     └────────┘

# 新版流程示意

                              ┌────────────────┐
context          ┌────────────►  module_graph  ◄────────────┐
                 │            └────────▲───────┘            │
                 │                     │                    │
                 │                     │                    │
             add │ module              │                    │
                 │ & ast          mark │ module        read │ module
                 │                     │ & ast              │ & ast             ┌────────┐
                 │                     │                    │                   │  emit  │
                 │                     │                    │                   └────▲───┘
                 │                     │                    │                        │
                 │                     │                    │                        │
                 │                     │                    │             ┌──────────┴──────────┐
            ┌────┴────┐       ┌────────┴───────┐     ┌──────┴───────┐     │  clone ast & apply  │
   flow     │  build  ├───────►  tree-shaking  ├─────►  gen chunks  ├─────►     tree-shaking    │
            └─────────┘       └────────────────┘     └──────────────┘     └─────────────────────┘

在新流程中,无论首次还是增量构建,tree-shaking 都不再直接修改 module_graph,而是在 module info 和 graph edge 上做标记,直到 generate 阶段再复制一份 ast 来应用 tree-shaking 的改动,这样就能做到 module_graph 的信息始终与磁盘文件一致,不用再去磁盘上读没有修改过的模块,而在 generate 阶段的拷贝 ast 内存占用也是即用即释放的(也会做方法级别的缓存避免做重复 tree-shaking),不会常驻内存。

方案实现

module_graph 改造

主要有 3 点,用来记录 tree-shaking(或者说 optimization)需要的信息并且改造读取的方法:

  1. Dependency 增加 is_used 标记,它目前作为 edge 上的信息储存在 module_graph 里
  2. ModuleInfo 增加 optims 字段,值是枚举值,本期只支持 OptimsType::UselessStmt(id) 一种,用来记录 tree-shaking 要删除的语句
  3. 读取 dependencies 的相关方法都要做改造,过滤掉 is_usedfalse 的 edge,让 Code Splitting 的逻辑尽量不感知 module_graph 的改造

farm_tree_shake 改造

目前是分析 + 删除逻辑一体的,需要把逻辑拆成 optmize 和 generate 两个阶段:

  1. optimize 阶段只做标记,利用 DependencyModuleInfo 把要优化的内容都放进 module_graph 里,标记要支持缓存,没改过的 AST 就不需要重新标记了,避免 fullbuild 有多余的消耗
  2. generate 阶段根据 module_graph 里记录的信息对传入 AST 做优化,优化要支持缓存,原因同上

generate 改造

这块目前还没完全理清楚,可能还缺一些细节,目前的大致的思路:

  1. generate_hot_update_chunks 里根据 updated_modules 和 module_graph 里的已有标记信息计算增量 tree-shaking 的最小范围
  2. 在最小范围内重新执行 tree-shaking 并更新 module_graph 上的标记信息,这样 generate_hmr_chunk 就能基于新的标记做 ast_to_code 了

任务拆分

WIP

afc163 commented 9 months ago

如果 tree-shaking 误删了内容只能部署后才能发现

这个问题现在比较致命。

PeachScript commented 9 months ago

评审记录:

  1. 记录 module 被引入的 symbol,便于后续增量 tree-shaking 时确认范围 from @stormslowly
  2. 新方案不能破坏 generate 的 transform 的并行,否则性能会变差 from @sorrycc @stormslowly
  3. statement id 记录及优化应用的方案需要再思考一下,因为 cjs 处理会影响 id 的正确性 from @stormslowly a. 思路1:把 generate 阶段对 ast 的操作挪到 tree-shaking apply 之后去做,同时要解 entry 变并行(chunk 内的模块是串行生成的) from @sorrycc b. 思路2:用 span 替代 id,但要看 span 是否会发生变化 from @stormslowly

延伸问题:

  1. [下次周会单独讨论] update 里是否还需要拆分前置 transform 和 generate transform,收益是可以只锁一次 module_graph 把 fullbuild 拆出去 from @stormslowly