浅析 Parcel 的 Rust 打包算法 Demo

2023年 8月 10日 51.8k 0

Parcel 是一个类似于 Webpack 、Rollup 的构建工具,相较于这一类构建工具,Parcel 主打的卖点是零配置并开箱即用,虽然某种程度上这种零配置的方式会使得项目定制化变得很困难,但 Parcel 尽量提供了一套自身的构建最佳实践,以后有机会去单独介绍一下 Parcel 的整体构造,这里不展开讲解了。

Parcel 在 2.8.0 的更新中提到使用了一个新的打包算法,相比较于之前速度提升了 2.7 倍,并且体积还减小了 2.5 倍。同时还有其他的比较夸张的性能提升,例如 6 倍的热更新速度,增量构建的再次构建性能提升了10倍。

图片

同时作者强调该算法是由来自 Atlassian 的团队贡献的,他们为此花了大约一年的时间使得其在 parcel v2.8.0 中成为默认的打包算法,该算法带来了巨大的性能提升,并且通过更少的重复包以及更好的浏览器缓存来有效减少了包产物的体积:

This results in both smaller bundles and much faster builds. *For a very large real-world project with over 60,000 assets, overall build time was reduced from over 25 minutes to 9 minutes (2.7x faster). The total bundle size for the whole project went from 952 MB *to 370 MB (2.5x smaller). For comparison, building the same app with webpack takes over 45 minutes.

此处测试项目根据 twitter 信息为 jira。

实际上这个算法在 landing 过程中,它是基于 Parcel 作者 Devon Govett 本身写的一个打包算法原型,并对这个算法本身在 Parcel 的实际场景中做了一些优化。具体可以参考这个 PR: https://github.com/parcel-bundler/parcel/pull/6975 的一些内容:

图片

在这篇文章中,我们先暂时不分析 Parcel 目前的具体的打包策略以及代码逻辑,而是结合这个原型仓库来了解一下 Parcel 最原始的打包算法的运行思路:

图片图片

相比较于 Parcel 本身的打包算法,这个原型 Demo 可以要更简单一点(不过由于代码是 Rust,理解难度对笔者来说也没有很简单),在之后的文章中,我会单独结合 Parcel 目前本身的打包算法(Default Bundlers)来做一下讲解。

Rust 算法 demo

我们可以根据上面 PR 中找到对应的算法原型仓库,这个仓库的地址是: https://github.com/devongovett/bundler-algorithm, 不过由于仓库的一些内容已经实际落地了,因此作者后面将这个仓库给归档了。

这个仓库是基于 rust 写的,不过整体流程上而言并不是特别复杂,我们可以简单调试一下(本地需要有 rust 环境):

git clone https://github.com/devongovett/bundler-algorithm
cd bundler-algorithm
# 使用 nightly 版本的 cargo
rustup install nightly && rustup default nightly

# 运行 demo
cargo run

根据该仓库源码,我们可以将一次打包算法执行流程分为下面几个步骤,注意以下步骤中的具体代码存在一些细节省略的情况,如果你想了解更多的算法细节,可以参考仓库源码来进行阅读。

构建依赖图

首先在 main.rs 文件中的第一步,会先根据项目各文件之间的引用关系构建一个依赖图出来。

这里直接参考 build_graph 这个方法,例如这里项目中存在 html 、js 等不同类型的文件,首先会将这些资源文件作为节点添加到一个图中,然后根据这些文件之间的引用关系去给对应的节点添加对应的边,这样就会形成一个比较初步完善的依赖图。

// 创建 graph 并获取对应的 entries 对象
let (g, entries) = build_graph();

fn build_graph, Dependency>, Vec) {
  let mut g = Graph::new();
  let mut entries = Vec::new();

  let html = g.add_node(Asset {
    name: "a.html",
    asset_type: AssetType::HTML,
    size: 10
  });
  
  let js = g.add_node(Asset {
    name: "a.js",
    asset_type: AssetType::JavaScript,
    size: 10
  });

  // ...一些资源的初始化过程,这里节省篇幅跳过...
  g.add_edge(html, js, Dependency {
    is_async: false
  });
  
  entries.push(html);
  entries.push(html2);

  return (g, entries);
}

由于 build_graph 方法中代码逻辑都比较单一,因此上面贴的源码中有一些重复逻辑例如资源的初始化等的省略。

最后这个方法会构建出一个如下图所示的依赖图出来,注意下面还有一些依赖之间的引用关系是异步的(这里可以理解为动态导入),同时这里我们给每个静态资源都标注一个唯一的 asset_id,下文图中标注的序号均与此处对应:

图片图片

其中 Parcel 会默认将 Html 资源作为依赖图的入口文件,因此我们可以看到这张图是存在两个入口的,分别为 a.html 和 b.html 。

遍历图创建独立的 bundle

这里的 bundle 可以简单理解为最终 打包 输出的文件,这个可以结合 Webpack 里面的一些概念来理解。

在图创建完成之后,这一步主要目的则是根据 graph 提供的一些信息(例如 entry、资源之间的依赖关系等)去创建出最后打包出来的 bundle 类型,这里首先会对下面三种情况创建一个单独的 bundle:

  • 入口文件,例如这里的 html 入口文件
  • 不同类型资源之间的引用,例如当 js 文件中引用到图片资源的时候
  • 资源之间存在 异步 引用时,例如某个 js 动态导入另外的 js 文件

下面我将根据这几种情况来展开讲讲:

首先是根据入口文件去创建 bundle,这里逻辑很简单,遍历一下前面 build_graph 方法生成的 entries 数组(实际上这里是个 Rust 的 Vec ),然后往 bundle_roots 中插入对应的 entry 以及 bundle_id 。

这里的 bundle_id 对应前面 graph 图中标记的序列号,例如 a.html 是 0,b.html 是 1。具体的实现参考以下逻辑:

let mut bundle_roots = HashMap:new();
let mut reacheable_bundles = HashSet::new();
let mut bundle_graph = Graph::new();

// 遍历 entries,往 bundle_roots 中插入对应的 entry 信息以及对应的 bundle_id
for entry in &entries {
  let bundle_id = bundle_graph.add_node(Bundle::from_asset(*entry, &g[*entry]));
  bundle_roots.insert(*entry, (bundle_id, bundle_id));
}

这里应用到实际开发中的场景可以联想到我们开发一个单页(SPA) 或者多页应用(MPA)时,在打包产物中通常会出现一个或者多个 html 入口文件,这里的情况也是类似的。

添加完 html 入口文件之后,接下来就会用深度优先搜索算法(DFS)去遍历整个图,对以下两种情况再生成单独的 bundle:

  • 不同类型资源之间的引用,对被引用的资源创建一个 bundle,例如 a.js 中引用到了 style.css 那么 style.css 会被处理成一个单独的 bundle
  • 资源之间存在 异步 引用时,对被引用的资源创建一个 bundle,例如 a.js 是异步引用的 async.js ,那么 async.js 会被处理成一个单独的 bundle

以下为遍历图的主要代码逻辑,具体可以参考 DfsEvent::TreeEdge(u, v) ,这里是遍历图中的各个相连子节点:

let mut stack = LinkedList::new();

depth_first_search(&g, entries, |event| {
  match event {
    // ...
    DfsEvent::TreeEdge(u, v) => {
      let asset_a = &g[u];
      let asset_b = &g[v];
      // 当资源类型发生变化的时候,创建一个新的 bundle
      if asset_a.asset_type != asset_b.asset_type {
        // 举个例子,这里 a.js -> style.css
        // 这里 bundle_group_id 是 a.html,asset_b 是 style.css
        // style.css 是不同类型的资源被引用,会被拆单独的 bundle 出来
        let (_, bundle_group_id) = stack.front().unwrap();
        let bundle_id = bundle_graph.add_node(Bundle::from_asset(v, asset_b));
        bundle_roots.insert(v, (bundle_id, *bundle_group_id));
        bundle_graph.add_edge(*bundle_group_id, bundle_id, 0);
        return
      }
      
      // 当存在异步依赖的时候,创建一个新的 bundle
      let dependency = &g[g.find_edge(u, v).unwrap()];
      // 举个例子,这里 a.js -> async.js 是异步依赖导入
      if dependency.is_async {
        // 因此这里 async.js(这里的 asset_b) 会被处理成一个单独的 bundle
        let bundle_id = bundle_graph.add_node(Bundle::from_asset(v, asset_b));
        bundle_roots.insert(v, (bundle_id, bundle_id));
        for (b, _) in &stack {
            let a = &g[*b];
            if a.asset_type != asset_b.asset_type {
              break
            }
            reachable_bundles.insert((*b, v));
          }
      }
    }
    // ...
  }
});

在经历过上面两次操作之后,我们最开始的 Graph 就会创建以下几个单独的 bundle 出来,对应的文件分别为:

  • 作为入口文件的 a.html 和 b.html 各自形成一个 bundle
  • 被 html 文件引用的 a.js 和 b.js 以及被 js 文件引用的 style.css 各自形成一个 bundle
  • 被异步引用的 async.js 文件形成一个 bundle

并且这些 bundle 之间还存在一定的依赖关系(这里的序号为最初始依赖图中的序号):

图片图片

上面 async2.js 这个异步的 js 资源没有形成单独的 bundle 原因在于其是被其它 js 资源同步引入的。

处理所有资源到对应 bundle

在上一接中利用初始化的依赖图分析出了最初的几个独立的 bundle,当然还剩下一些其它的资源还没处理,例如 async2.js 和 shared.js 这两个 asset。

因此这一步的操作就是将这些还没有处理成 bundle 的 assets 全部都处理到 bundles 中去,同时整合一下上一步遍历出来的独立 bundle。

首先这一步会根据前面的到的独立 bundle 去建立一个可访问的节点,这里会用轮询上一节生成的独立 bundle,也就是这里的 bundle_roots ,同时根据每个 bundle_root 在最原始的依赖图中进行一次 DFS 查找,然后剪枝掉一些同样是 bundle_root 的 bundle,把剩余不是 asset 的添加到可访问节点中来,实现参考如下:

let mut reachable_nodes = HashSet::new();
for (root, _) in &bundle_roots {
  depth_first_search(&g, Some(*root), |event| {
    if let DfsEvent::Discover(n, _) = &event {
      if n == root {
        return Control::Continue
      }

      // 如果命中了 bundle_root 就跳过
      if bundle_roots.contains_key(&n) {
        return Control::::Prune;
      }
      // 否则就添加到可访问节点中来
      reachable_nodes.insert((*root, *n));
    }
      Control::Continue
    });
}

举个例子来说,例如 a.js 是个单独的 bundle,但他同步引用了一个同类型的 async2.js ,同时 async2.js 又引用了一个 shared.js 。

图片图片

那么这里就会形成一个以a.js为根节点,分别到 async2.js 和 shared.js 的两个可访问节点。

同理,在上图中我们还能找到这样的一些访问节点:

图片图片

拿到这些可访问节点以后,这里会像最开始的的时候,根据这些节点再去形成一个可访问节点的依赖图,这个图对比最初的依赖图会小一点,这个依赖图的主要作用是为了帮助后续决定具体哪些 asset 被放到哪个 bundle 中去,这个过程可以和 Webpack 的拆包策略一起理解。

// 根据拿到的可访问的节点,构造一个 graph 出来
let reachable_graph = Graph::::from_edges(&reachable_nodes);

这里的依赖图实际上就是个去除一些 root_bundle 后的缩减版的依赖图:

图片图片

在完成可访问节点依赖图的构建之后,下一步开始利用这个依赖图将所有的 asset 都处理进 bundle 中去。其中这里每个 asset 都会基于其和可访问的 bundle_roots 之间的关系被放到单独 bundle 中去,这么做的目的是为先创建尽可能没有重复代码的 bundle graph。

// 用于存储 entry asset id 到 bundle id 的映射
let mut bundles: HashMap = HashMap::new();

for asset_id in g.node_indices() {
  let reachable: Vec = reachable_graph.neighbors_directed(asset_id, Incoming).collect();
  let reachable: Vec = reachable.iter().cloned().filter(|b| {
      (&reachable).into_iter().all(|a| !reachable_bundles.contains(&(*a, *b)))
  }).collect();
  
  // 根据上面的每个 asset 对应的可访问的 bundle 去生成一个 bundle_graph
  if let Some((bundle_id, _)) = bundle_roots.get(&asset_id) {
    // 如果 asset 是个 bundle_root,那么会给这个 bundle_root 在 bundle_graph 添加上对应的节点
    bundles.entry(vec![asset_id]).or_insert(*bundle_id);
    for a in &reachable {
      if *a != asset_id {
          bundle_graph.add_edge(bundle_roots[a].1, *bundle_id, 0);
       }
    }
  } else if reachable.len() > 0 {
    // 如果 asset 对于多个 bundle 都是可访问的
    // 例如 shared.js(6) 就同时被 a.js(2) 和 b.js(5) 可访问
    // 这里可以根据这些 asset 组合为该 asset 创建一个新的 bundle 
  }
}

这一步的代码逻辑会很复杂(从 rust 代码的角度来看,毕竟笔者不是很懂 rust QAQ),因此这里笔者并没有贴完所有的代码。但我这里会结合具体的代码来举例讲解一下这一步的具体操作:

首先这一步会遍历所有的 asset ,找到那些目前还没有形成 bundle 的 asset,如下图所示,经历过前一个步骤之后,没有形成 root bundle 的 asset 的只有 async2.js 以及 shared.js 。

图片图片

这里针对 async2.js 这个 asset 来讲解一下,对于 async2.js 来说,对它可访问的 bundle 有 a.js (2)、async.js (3)。这里由于 async2.js 对于 async.js 的父 bundle a.js 同样是可访问的,这里在处理可访问节点的时候会把 async.js 这个 asset 给移除掉,相当于 async2.js 实际可访问的 bundle 只有 a.js ,这里这样处理的作用是为了后续 asset 的 bundle 合并操作,可以参考如下代码:

for asset_id in g.node_indices() {
  let reachable: Vec = reachable_graph.neighbors_directed(asset_id, Incoming).collect();
  // 这一步筛掉 async2.js
  let reachable: Vec = reachable.iter().cloned().filter(|b| {
      (&reachable).into_iter().all(|a| !reachable_bundles.contains(&(*a, *b)))
  }).collect();
}

在这一步筛出对应可访问的 bundle 之后(reachable),接下来就开始去继续构造 bundle_graph ,这上一步中 bundle_graph 中先暂时放置了一些 root_bundle 以及他们之间的引用关系。在这一步,对于没有形成 bundle 的 asset 进行继续的完善。

这一步代码比较复杂,笔者并没有完全贴出来,大概处理过程分成了两个分支:

  • 处理成 root_bundle 的 bundle,需要将他们有依赖关系的 bundle_graph 添加上对应的边
  • 处理 root_bundle 之外的 asset 为 bundle(async2.js 和 shared.js)
  • 如果这个 asset 被多个 root_bundle 依赖(可访问),那么会给这个 asset 创建一个单独的 bundle 并给它在 bundle_graph 加上对应的边,例如这里的 shared.js
  • 如果这个 asset 只被一个 root_bundle 依赖(可访问),那么会直接把这个 asset 添加到 root_bundle 的 bundle 中组成一个新的 bundle,例如这里的 async2.js
for asset_id in g.node_indices() {
   // ... 省略掉获取 reacheable 这个变量过程
   if let Some((bundle_id, _)) = bundle_roots.get(&asset_id) {
     // 1. 处理 root_bundle,这一步可以理解为给 bundle_graph 添加边
     bundles.entry(vec![asset_id]).or_insert(*bundle_id);
      for a in &reachable {
        if *a != asset_id {
          bundle_graph.add_edge(bundle_roots[a].1, *bundle_id, 0);
        }
      }
   } else if reachable.len() > 0 {
     // 2. 处理 root_bundle 之外的 asset 为 bundle
     let source_bundles = reachable.iter().map(|a| bundles[&vec![*a]]).collect();
      
      let bundle_id = bundles.entry(reachable.clone()).or_insert_with(|| {
        let mut bundle = Bundle::default();
        bundle.source_bundles = source_bundles;
        bundle_graph.add_node(bundle)
      });

      let bundle = &mut bundle_graph[*bundle_id];
      bundle.asset_ids.push(asset_id);
      bundle.size += g[asset_id].size;

      // 处理完 asset 为 bundle 之后,同样要添加边
      for a in reachable {
        if a != *bundle_id {
          bundle_graph.add_edge(bundle_roots[&a].1, *bundle_id, 0);
        }
      }
    }
   }
}

这一步最后会形成一个这样的 bundle_graph ,这里对每个 bundle 进行了重新标号,不同于之前的 asset_id

图片图片

这里我们可以看到, 其中 async2.js 和 root_bundle a.js 形成了一个新的 bundle,而另外一个 asset shared.js 也形成了一个单独的 bundle,它们之间的依赖关系(graph 的边)也能比较清晰的看到。

合并小的公共 bundle

上一步我们基本上将所有的 asset 都全处理成了 bundle 并成功构建出了一个 bundle_graph ,实际上现在构建大头基本上已经完成了。

下面的步骤基本就是对 bundle_graph 做一个优化,这一步的处理对比前面的步骤就很简单了。

在开始介绍代码之前,这一步我们可以理解为 webpack 的 chunkSplit 配置中的 splitChunks.minSize 。在 Parcel 也有对应的配置可以参考,这里的 minBundleSize:

{
  "@parcel/bundler-default": {
    "minBundles": 1,
    "minBundleSize": 3000,
    "maxParallelRequests": 20
  }
}

大致就是对小于 minBundleSize 的 shared_bundle 合并到对应的可访问 bundle 中去,这里的副作用是可能会导致在多个 bundle 中存在一定体积的重复 asset 体积(重复代码),但带来的好处则是可以减少请求数量,这里具体的取舍开始看用户自身的配置。

这一步的代码处理如下:

for bundle_id in bundle_graph.node_indices() {
    let bundle = &bundle_graph[bundle_id];
    // 当这个 bundle 本身为 commen bundle 且他本身的体积小于某个值,这里的 demo 默认写为了 10
    // 那么则可以合并掉这个 bundle
    if bundle.source_bundles.len() > 0 && bundle.size < 10 {
      remove_bundle(&g, &mut bundle_graph, bundle_id);
    }
  }
  
  fn remove_bundle(
    asset_graph: &Graph,
    bundle_graph: &mut Graph,
    bundle_id: NodeIndex
  ) {
    // 在 bundle_graph 中删除掉对应的 bundle
    let bundle = bundle_graph.remove_node(bundle_id).unwrap();
    // 并将该 bundle 合并对其有引用关系的 bundle 中去
    for asset_id in &bundle.asset_ids {
      for source_bundle_id in &bundle.source_bundles {
        let bundle = &mut bundle_graph[*source_bundle_id];
        bundle.asset_ids.push(*asset_id);
        bundle.size += asset_graph[*asset_id].size;
    }
  }

合并超出并行请求限制的公共 bundle

在上一步处理完了最小体积的公共 bundle,这一步要处理的则是最大并行请求的 chunk,举个例子,例如这里拆出来了 a.js 、common-a.js 、common-b.js 这三个 bundle,并且 a.js 同时依赖 common-a.js 和 common-b.js,但现在用户配置允许的最大 bundle 并行请求数目是 2,那么这里就会合并掉这两个 common bundle 中的其中一个到 a.js 中去。

这里在 Parcel 中也有对应的配置项,参考下面的 maxParallelRequests 这个参数:

{
  "@parcel/bundler-default": {
    "minBundles": 1,
    "minBundleSize": 3000,
    "maxParallelRequests": 20
  }
}

这一步的代码处理可以参考如下:

// demo 这里默认的限制请求数量为 3
let limit = 3;
for (_, (bundle_id, bundle_group_id)) in bundle_roots {
    if bundle_id != bundle_group_id {
      continue;
    }
    let mut neighbors: Vec = bundle_graph.neighbors(bundle_group_id).collect();
    if neighbors.len() > limit {
      neighbors.sort_by(|a, b| bundle_graph[*a].size.cmp(&bundle_graph[*b].size));
      // Remove bundles until the bundle group is within the parallel request limit.
      for bundle_id in &neighbors[0..neighbors.len() - limit] {
        // Add all assets in the shared bundle into the source bundles that are within this bundle group.
        let source_bundles: Vec = bundle_graph[*bundle_id].source_bundles.drain_filter(|s| neighbors.contains(s)).collect();
        for source in source_bundles {
          for asset_id in bundle_graph[*bundle_id].asset_ids.clone() {
            let bundle_id = bundles[&vec![source]];
            let bundle = &mut bundle_graph[bundle_id];
            bundle.asset_ids.push(asset_id);
            bundle.size += g[asset_id].size;
          }
        }

        // Remove the edge from this bundle group to the shared bundle.
        bundle_graph.remove_edge(bundle_graph.find_edge(bundle_group_id, *bundle_id).unwrap());

        // If there is now only a single bundle group that contains this bundle,
        // merge it into the remaining source bundles. If it is orphaned entirely, remove it.
        let count = bundle_graph.neighbors_directed(*bundle_id, Incoming).count();
        if count == 1 {
          remove_bundle(&g, &mut bundle_graph, *bundle_id);
        } else if count == 0 {
          bundle_graph.remove_node(*bundle_id);
        }
      }
    }
  }

这里简单对代码逻辑做个讲解,其实这里的逻辑也比较好理解:

  • 先找到对应的 root_bundle(即这里的 bundle_group_id ),因为一般只有 root_bundle 会有多个 bundle 并行请求。
  • 找出包括 root_bundle (不包括 root_bundle)依赖的所有 bundle(即 neighbors 变量),如果这个数目大于这里设置的限制(即 limit),按照neighbors 中的所有 bundle 体积大小,把体积小的 bundle 都合并到其对应的 source_bundles 中去(这里不一定只有 root_bundle,还可能会有其他的 bundle)。一直合并到这些 bundle 的数目小于请求限制即可。
  • 合并到最后,如果只剩下唯一一个 bundle 了,那么直接把这个 bundle 也给合并进剩下的 source_bundle 中去。

总结

在走完最后两步的 bundle 合并之后,那么整个 Rust Demo 的打包算法流程就结束了,实际上这个 Demo 给的资源样例并没有走最后的两步:

图片图片

按照最后的 bundle_graph 生成的图我们可以看到:

  • 最小的 common_bundle 体积也要大于 10
  • 最大的并行请求数目也并没有超过 3 个(图中的几个 bundle 之间还有依赖关系,这里并没有标注出来)

实际上最后生成的整体 bundle 也大概如图所示。

不过这整个流程由于笔者对于 Rust 代码并不是很熟悉,因此中间可能会有些疏漏,不过整体流程来看应该并没有特别大的出入。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论