程序员求职经验分享与学习资料整理平台

网站首页 > 文章精选 正文

为什么大厂不能直接用 Vite?Bundle-less 的思考和实践分享

balukai 2025-02-17 11:10:22 文章精选 20 ℃

原文来源于:ByteDance Web Infra ,作者:杨兴元

如有侵权,联系删除


随着 Snowpack、Vite 等利用提倡 no-bundle 的构建工具逐渐兴起,同时现代浏览器对原生 ESM 的普遍支持,Bundle-less 的概念席卷前端圈,那么我们如何理解 Bundle-less?究竟是炒概念还是能够真正地给业界带来收益?下面就来分享一下我对于 Bundle-less 的理解以及在这个方向上做过的一些探索。


如何理解 Bundle-less?


Bundle-less,也被叫做 Bundless,我觉得可以从这几个角度去理解:


  1. 首先是拆包,弱化传统意义上的打包概念,由单 bundle 拆分为数十或者上百个 bundle,这样可以更好地利用 HTTP2 的多路复用优势和提升缓存命中率。
  2. 然后是对于项目源代码不进行 bundle(no-bundle),在开发阶段可以省略 bundle 的开销,如 Vite、Snowpack、WMR,这层含义相信大家都比较熟悉了。
  3. 再者是依赖产物的模块化分发。对于庞大的外部依赖,一方面打包成本比较高,另一方面文件数量可能非常多,打包几乎是一个必选项,甚至需要多个 NPM 包合并打包。因此,针对依赖的打包也是非常重要的优化点,一般可以通过预打包 + 模块化缓存来进行优化,目前也有一些优化案例,如 Vite 中基于 Esbuild 的预打包器、基于 ESM 的 CDN 服务,如 Skypack、esm.sh、jspm 等,当然也有一些新的自研方案,后文会详细介绍。


一、拆多少包更合适?


对于究竟拆多少包这个问题,大家的概念都一直比较模糊,打的包太多或者太少都可能出现加载性能的问题,比如过多的嵌套 import 导致网络瀑布流的产生、bundle 太少不能充分利用 HTTP2 下并发请求的优势。


针对这个问题,我们曾做过一系列的性能测试,最后得出的结论如下:


  1. 对于总产物资源大小相同的情况,资源加载分成的 chunk 数量在 10 - 25 之间进行并行加载性能最佳。
  2. 一次资源加载需要的依赖引用深度尽量等于 1 时加载性能最好。




  1. 由于不打包的情况下项目的请求数量和请求深度问题都不可控,因此不适合在生产构建中采用 no-bundle 方案。


二、No-bundle 服务


1.代表方案 Vite


在开发环境中,Vite 主要做了两件事情:


  • 基于 Esbuild 打包外部依赖
  • 创建 HTTP Server 以响应浏览器中


    浏览器的 import map 兼容性: https://caniuse.com/?search=importmap

    相关 Polyfill: es-module-shims(地址) 、SystemJS


    2. 模块合并


    首先基于 Esbuild 将项目进行预打包(性能考虑),需要开启 metafile 配置,在 onEnd 钩子或者 build API 的返回值中可以获取构建元信息,即 meta 对象,由 inputs 字段可以解析出模块依赖图。


    // meta 对象
    {  
      "inputs": {
        // 当前模块路径
        "../node_modules/.pnpm/object-assign@4.1.1/node_modules/object-assign/index.js": {
          // 模块大小
          "bytes": 2108,
          // 依赖模块数组
          "imports": []
        },
        "../node_modules/.pnpm/react@17.0.2/node_modules/react/cjs/react.development.js": {
          "bytes": 72141,
          "imports": [
            {
              "path": "../node_modules/.pnpm/object-assign@4.1.1/node_modules/object-assign/index.js",
              "kind": "require-call"
            }
          ]
        },
      }
    }


    根据当前的模块依赖图信息,我们可以将项目中用到的依赖进行分组,通过特定的依赖分组算法产出一些依赖组的信息,算法细节比较复杂,篇幅原因就不展开了。


    3. 多包(combo)模式打包


    Combo 模式打包即把多个依赖包打包到一起,主要会产生如下的问题:


    • 多包出现导出名称冲突
    • Subpath 问题大量出现的问题


    3.1不同包的导出名冲突问题


    首先需要解决命名导出的问题,整体由两部分构成,在构建时阶段注入一些带有包名前缀的 specifier,运行时根据包名取出这些 specifier,从根本上解决导出名冲突问题。


    3.1.1 构建时注水


    导入导出检测


    在构建之前需要探测 NPM 包所有的导出,包含以下的情况:


    • ESM
      • Named/Default export,通过 es-module-lexer 扫描
      • export * from 'xxx',通过 esbuild 预打包,开启 metafile 模式,在 metafile 中获取所有的 export 名
    • CJS
      • 尝试通过直接 require 拿到所有的导出名
      • 若 require 失败,降级到 AST 解析,分析所有的导出名


    构造入口模块


    在拿到所有导出名的基础之上,构建虚拟模块,交由 bundler 进行打包,格式如下:


    export { 包名_导出字段名 } from '包名'


    也就是在 NPM 包每个导出名前面加上包名_,完成注水过程,以防止重名。


    3.1.2 运行时脱水 (Hydrate)


    如上包名_导出字段名 的这种导出在业务中是直接使用的,我们需要在模块系统中进行运行时拦截(脱水过程),把真正的导出字段名取出。


    如以下的导入:


    // 在以 esm 的方式对依赖进行 external 后,webpack 产物中的引入代码
    import * as __WEBPACK_EXTERNAL_MODULE_react_router_ from 'react-reouter'


    我们将会改写成以下的代码:


    // 1. 换成临时变量
    import * as __WEBPACK_EXTERNAL_MODULE_react_router_$0 from "react-router";
    // 2. 对原来的变量重新赋值,通过 __EDEN_COMBO_HYDRATE__ 工具函数将导出名去掉包名前缀
    // 如 react_router_Router => Router
    var __WEBPACK_EXTERNAL_MODULE_react_router_ = __EDEN_COMBO_HYDRATE__(
      __WEBPACK_EXTERNAL_MODULE_react_router_$0,
      "react_router"
    );


    3.2大量 subpath 问题


    做实际落地项目的过程中发现第三方包中使用大量的 subpath,如下图的依赖分组结果所示:



    这种情况下 import map 的体积会很大,因为每个 subpath 都会对应一个远程地址。解决方案是进行 subpath 合并打包,如@
    babel/runtime/helper/esm/assertThisInitialized、@
    babel/runtime/helpers/esm/inheritsLoose,原始产物中的引入代码如下:


    import * as __WEBPACK_EXTERNAL_MODULE_babel_runtime_helers_esm_inheritsLoose from '@babel/runtime/helpers/esm/inheritsLoose';
    import * as __WEBPACK_EXTERNAL_MODULE_babel_runtime_helers_esm_assertThisInitialized from '@babel/runtime/helpers/esm/assertThisInitialized';


    改写成如下的形式:


    import * as _babel_runtime_ from '@babel/runtime';
    var __WEBPACK_EXTERNAL_MODULE_babel_runtime_helers_esm_inheritsLoose = __EDEN_COMBO_HYDRATE__(
      _babel_runtime_,
      '_babel_runtime_helers_esm_inheritsLoose'
    );
    var __WEBPACK_EXTERNAL_MODULE_babel_runtime_helers_esm_assertThisInitialized = __EDEN_COMBO_HYDRATE__(
      _babel_runtime_,
      '_babel_runtime_helers_esm_assertThisInitialized'
    );


    这样对于 @babel/runtime 只留下一个 importmap 的 key-value 对,有效减少 import map 的体积。


    4. 产物 Tree Shaking


    在 Esbuild 预打包阶段扫描源文件所用到的 specifier,然后构建对应的虚拟模块交给打包器进行打包:


    export { cloneDeep } from 'lodash-es'
    export { Spin } from '@douyinfe/semi-ui'


    这样可以做到一定程度的 Tree Shaking。


    5. Polyfill 安全


    根据不同的 runtimeTarget 要求(Eden 默认自带以下前三种 runtimeTarget,包括 Modern、PCLegacy、MobileLegacy)和产物模块格式,注入不同的 Polyfill 内容:


    • 现代浏览器: 注入 import map 的 polyfill 即可
    • PC 端老旧浏览器: PC 端默认 browserlist 下所有第三方包所需的 Polyfill + Systemjs 产物
    • 移动端老旧浏览器: 移动端默认 browserlist 下所有第三方包所需的 Polyfill + Systemjs 产物
    • 自定义 runtimeTarget 或模块格式: 指定 browserlist 下所有第三方包所需的 Polyfill,如果是 Systemjs 格式,则加入 Systemjs 的 Polyfill


    Polyfill 内容在所有第三方包编译完成后进行累计去重,然后统一打包成一个 Chunk,通过 script 标签注入到页面中:



    6. 产物本地化


    依赖产物本地化有两个好处:


    • 可以在本地调试依赖的产物代码
    • 可以将依赖产物代码同业务代码一同部署(私有化部署)


    具体的做法如下:


    在使用编译服务将依赖包打包完成并上传 CDN 后,构建插件会重新请求 CDN 的资源,并将资源写入到本地磁盘中。



    然后,构建插件通过在本地 Dev Server 加入中间件来对本地临时目录启用静态资源服务。


    与此同时,插入到 HTML 中的 import map 的远程 CDN 产物被改写为本地静态资源服务的地址。


    因此,最后项目中访问到的第三方包资源即为临时目录中的产物代码。


    小结


    本文从拆包数量、no-bundle 服务和依赖产物的模块化分发三个角度介绍了 Bundle-less 目前的具体概念及应用,不知道你对 Bundle-less 有没有新的理解呢?

最近发表
标签列表