浅析Vite2.0-依赖预打包

Posted 十一点我就睡

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅析Vite2.0-依赖预打包相关的知识,希望对你有一定的参考价值。

浅析Vite2.0-依赖预打包

开始

最近在做业务的时候,了解到了一个叫imove开源项目,比较适合我现在做的业务 ,便了解了一下,发现它也借鉴了Vite的思想:即利用浏览器支持ESM 模块的特点,让我们的import/export 代码直接在浏览器中跑起来。结合之前社区的讨论,同时也让我对Vite有了兴趣,遂对它的代码进行了一些研究。
如果你对Vite还没有大概的了解,可以先看看这篇中文文档:关于Vite的一些介绍。
在我看来比较重要的点是:

Vite 以 原生 ESM 方式服务源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入的代码,即只在当前屏幕上实际使用时才会被处理
同时我关注 到Vite 2.0 发布了 ,其中几个特性还是比较有意思,接下来就分析一下 更新的特性之一:基于 esbuild 的依赖预打包 。

依赖预打包的原因

关于这一点,Vite的文档上已经说得比较清楚了
1.CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
2.Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。

整体流程

首先 在使用vite创建的项目中,我们可以看到有如下几个命令:

  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "serve": "vite preview"
  },

可以得知,本地运行时启动的就是默认命令vite。
在vite项目中找到对应的cli.ts 代码(为了看起来更清晰,本文档中贴出来的代码相比原文件做了删减)


cli
  .command(\'[root]\') // default command
  .alias(\'serve\')
  .action(async (root: string, options: ServerOptions & GlobalCLIOptions) => {
    const { createServer } = await import(\'./server\')
    const server = await createServer({ root })
    await server.listen()

我们可以看到vite本地运行的时候,简单来说,就是在创建服务。
当然更具体的来讲,createServer这个方法中做的事包括: 初始化配置,HMR(热更新) ,预打包 等等,我们这次重点关注的是预打包。
来看看这一块的代码:

    // overwrite listen to run optimizer before server start
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (port: number, ...args: any[]) => {
      await container.buildStart({}); // REVIEW 简单测试了下 为空函数 貌似没什么卵用?
      await runOptimize() 
      return listen(port, ...args)
    }) as any
    const runOptimize = async () => {
        if (config.optimizeCacheDir) server._optimizeDepsMetadata = await optimizeDeps(config);
    }
  }

上面的代码中我们可以了解到,具体的 预打包代码的实现逻辑就是在 optimizeDeps 这个方法中。同时 config.optimizeCacheDir 默认为node_modules/.vite,Vite 会将预构建的依赖缓存到这个文件夹下,判断是否需要使用到缓存的条件,我们后面随着代码深入讲到。
预构建的流程在我看来,分为三个步骤

第一步 判断缓存是否失效

判断缓存是否失效的重要依据是 通过getDepHash这个方法生成的hash值,主要就是按顺序查找 const lockfileFormats = [‘package-lock.json’, ‘yarn.lock’, ‘pnpm-lock.yaml’] 这三个文件,若有其中一个存在,则返回其文件内容。再通过 += 部分config值,生成文件的hash值。
简单来说,就是通过判断项目的依赖是否有改动,从而决定了缓存是否有效。
其次还有 browserHash,主要用于优化请求数量,避免太多的请求影响性能。

function getDepHash(root: string, config: ResolvedConfig): string {
  let content = lookupFile(root, lockfileFormats) || \'\';
  // also take config into account
  // only a subset of config options that can affect dep optimization
  content += JSON.stringify(
    {
      mode: config.mode,
      root: config.root,
      resolve: config.resolve,
      assetsInclude: config.assetsInclude,
  )
  return createHash(\'sha256\').update(content).digest(\'hex\').substr(0, 8)
}

通过下面的代码可以看到,对依赖的缓存具体路径都写在optimized这个字段中,optimized 中的file,src分别代表缓存路径和源文件路径,needsInterop代表是否需要转换为ESM

  // cacheDir 默认为 node_modules/.vite
  const dataPath = path.join(cacheDir, \'_metadata.json\') 
  const mainHash = getDepHash(root, config)
// data即存入 _metadata.json的文件内容 主要包括下面三个字段
  const data: DepOptimizationMetadata = {
    hash: mainHash,  // mainHash 利用文件签名以及部分config属性是否改变,判断是否需要重新打包
    browserHash: mainHash, // browserHash 主要用于优化请求数量,避免太多的请求影响性能
    optimized: {}  // 所有依赖项
        //eg: "optimized": {"axios": 
        //{"file": "/Users/guoyunxin/github/my-react-app/node_modules/.vite/axios.js",
      //"src": "/Users/guoyunxin/github/my-react-app/node_modules/axios/index.js",
      //"needsInterop": true }
 }
  // update browser hash
  data.browserHash = createHash(\'sha256\')
    .update(data.hash + JSON.stringify(deps))
    .digest(\'hex\')
    .substr(0, 8)

第二步 收集依赖模块路径

收集依赖模块路径的核心方法是 scanImports 其本质上还是通过esbuildService.build方法 以index.html文件为入口,构建出一个临时文件夹。在build.onResolve的时候拿到其所有的依赖,并在最后构建完成时,删除本次的构建产物。

export async function scanImports(
  config: ResolvedConfig
): Promise<{
  deps: Record<string, string>
  missing: Record<string, string>
}> {
  entries = await globEntries(\'**/*.html\', config)
  const tempDir = path.join(config.optimizeCacheDir!, \'temp\')
  const deps: Record<string, string> = {}
  const missing: Record<string, string> = {}
  const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
  await Promise.all(
    entries.map((entry) =>
      esbuildService.build({
        entryPoints: [entry]
        })
    )
  )
  emptyDir(tempDir)
  fs.rmdirSync(tempDir) 
  return {
    deps, //依赖模块路径
     missing // missing为 引入但不能成功解析的模块
  }
}

最终得到的数据结构为

deps =  {
   react: ‘/Users/guoyunxin/github/my-react-app/node_modules/react/index.js’,
   ‘react-dom’: ‘/Users/guoyunxin/github/my-react-app/node_modules/react-dom/index.js’,
   axios: ‘/Users/guoyunxin/github/my-react-app/node_modules/axios/index.js’
   }

第三步 esbuild 打包模块

最终打包的产物都是会在.vite/_esbuild.json 文件中
以react-dom为例 通过 inputs中的文件打包构建出的产物为 .vite/react-dom.js

   "outputs":{
     "node_modules/.vite/react-dom.js": {
      "imports": [
        {
          "path": "node_modules/.vite/chunk.FM3E67PX.js",
          "kind": "import-statement"
        },
        {
          "path": "node_modules/.vite/chunk.2VCUNPV2.js",
          "kind": "import-statement"
        }
      ],
      "exports": [
        "default"
      ],
      "entryPoint": "dep:react-dom",
      "inputs": {
        "node_modules/scheduler/cjs/scheduler.development.js": {
          "bytesInOutput": 22414
        },
        "node_modules/scheduler/index.js": {
          "bytesInOutput": 189
        },
        "node_modules/scheduler/cjs/scheduler-tracing.development.js": {
          "bytesInOutput": 9238
        },
        "node_modules/scheduler/tracing.js": {
          "bytesInOutput": 195
        },
        "node_modules/react-dom/cjs/react-dom.development.js": {
          "bytesInOutput": 739631
        },
        "node_modules/react-dom/index.js": {
          "bytesInOutput": 205
        },
        "dep:react-dom": {
          "bytesInOutput": 45
        }
      },
      "bytes": 772434
    },

     }

以下为具体打包实现流程

export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.server.force,
  asCommand = false,
  newDeps?: Record<string, string> // missing imports encountered after server has started
): Promise<DepOptimizationMetadata | null> {
  
  const esbuildMetaPath = path.join(cacheDir, \'_esbuild.json\')
  await esbuildService.build({
    entryPoints: Object.keys(flatIdDeps), // 以收集到的依赖包为入口 即 Object.keys(deps)
    metafile: esbuildMetaPath, // _esbuild.json中保存着构建的结果 output 
    plugins: [esbuildDepPlugin(flatIdDeps, flatIdToExports, config)]
  })

  const meta = JSON.parse(fs.readFileSync(esbuildMetaPath, \'utf-8\'))
  
  for (const id in deps) {
    const entry = deps[id]
    data.optimized[id] = {
      file: normalizePath(path.resolve(cacheDir, flattenId(id) + \'.js\')),
      src: entry,
      needsInterop: needsInterop(id, idToExports[id], meta.outputs)
    }
  }
  writeFile(dataPath, JSON.stringify(data, null, 2)) // 
  return data
}

结尾

本次的文档相交于之前自己研究的axios core-js sentry来说,复杂度会显得稍高一些,而且现有可以查到的关于vite的文档基本都是1.x版本的,可以借鉴参考的也不多。相比起之前研究的源码,本次的显得会难一些,所以也是花费了较多的时间来做,还好最后还是写出来了 233。
之前研究源码,都是兴趣使然,选的方向都比较随意。最近1v1过后,思考了一下技术体系的问题,所以后续应该会是以兴趣+体系化的方式来选择要研究的源码。同时最近3个多月,更新了5篇技术文档。也慢慢开始有了一些关于写技术文档的一些思考,这也算是一些’副作用’吧。
你如果有什么疑问或者建议都欢迎在下方留言。

以上是关于浅析Vite2.0-依赖预打包的主要内容,如果未能解决你的问题,请参考以下文章

.4-浅析webpack源码之convert-argv模块

前端开发 浅析入门webpack的详细构建打包过程

浅析 -- webpack

导致资产预编译在heroku部署上失败的代码片段

Vite 原理浅析

预下载所有依赖项