vlambda博客
学习文章列表

前端框架源码解读之Vite

前端工具链十年盘点:


Webpack、Rollup 、Esbuild、Vite ?

  • webpack: 基于 JavaScript 开发的前端打包构建框架,通过依赖收集,模块解析,生成 chunk,最终输出生成的打包产物,是一个 BundleBased的框架,优点是 大而全,缺点是 配置繁琐。
  • Rollup: Rollup 是专门针对类库进行打包,它的优点是小巧而专注,现在很多我们熟知的库都都使用它进行打包,比如:Vue、React 和 three.js 等。

  • Esbuild: 一个基于 Go 编写的高性能构建工具,和其他构建工具相比,速度快到  10-100x,其内置了一些 Loader 能解析编译常见的 JS(X)、TS(X)等文件,同时支持通过插件的形式处理其他类型的文件。

  • Vite: Vite 基于_ESMBased_devServer 在开发环境实现了快速启动、按需编译、即时模块热更新等能力,同时针对同一份代码,在生产环境通过 Rollup 进行打包,生成线上产物。

Vite 简介

背景驱动

目前比较成熟的前端开发构建工具,如 webpack 等,基本是通过“打包”的方式来进行源码构建,即通过对源码进行依赖收集、构建处理,最终生成可在浏览器运行的 JS 文件,然而随着项目增长,他们存在以下问题

  1. 打包构建时间也会随着增长,项目本地启动缓慢
  1. 更新缓慢,即使使用 HMR 开发,也需要几秒的时间代码变更才能反映到页面上,严重影响开发体验

得益于现在前端生态系统的快速发展,Vite 基于下面两个新特性去解决上述存在的问题

  1. 浏览器开始支持 原生 ES 模块
  1. 越来越多 JavaScript 工具使用编译型语言如 Go 等进行编写,加快了构建速度

核心功能



前端框架源码解读之Vite


前端框架源码解读之Vite

  • 本地开发环境,利用浏览器支持原生 ESM 文件的特性,不对源代码进行打包操作,浏览器直接动态引入资源,并在 devServer 对请求的资源进行处理,最终返回浏览器可运行的内容
  • 依赖预构建,首次启动的时候,通过 Esbuild 对项目的依赖进行预构建,并缓存在本地,后续浏览器请求的时候可以直接返回

  • 更高效的HMR模块,利用浏览器的缓存特性,优化资源的请求,使得无论应用大小如何,HMR 始终能保持快速更新

  • 生产产物构建时,基于 Rollup 进行打包,并提供了一套  构建优化  的  构建命令,开箱即用。

Vite 核心模块原理

本次分享主要介绍最核心的两个功能的实现原理

  • 依赖预构建
  • 浏览器模块加载流程

源码初识

源码版本:v2.8.2

之前有简单了解过 Webpack 的源码,看的一头雾水,这一层层的 callback 都是些啥?然而 vite 框架的源码看起来就很简洁明了,非常易懂

./src
├── client # 客户端运行时WEB SOCKET以及HMR相关的代码
│ ├── client.ts
│ ├── env.ts
│ ├── overlay.ts
│ └── tsconfig.json
└── node # 本地服务器相关代码
├── __tests__
├── build.ts # 生产环境rollup build代码
├── certificate.ts
├── cli.ts # cli,入口
├── config.ts
├── constants.ts
├── http.ts
├── importGlob.ts
├── index.ts # 导出出口
├── logger.ts
├── optimizer # 依赖预构建
├── packages.ts
├── plugin.ts
├── plugins # 插件
├── preview.ts # build构建后,在预览模式下启动Vite Server,以模拟生产部署
├── server # server文件夹,dev环境主要代码
├── ssr
├── tsconfig.json
└── utils.ts

7 directories, 18 files

我们主要关注server目录下的代码,框架通过在本地启动一个 http+connect 的服务器,然后在启动之前做一些优化操作主入口在src/server/index.tscreateServer函数中,这个函数里做了以下几件事情

前端框架源码解读之Vite


流程初始化


1)调用resolveConfig函数,解析合并各种配置

2)初始化一个http+connect服务器

3)创建插件容器 ,createPluginContainer方法,把插件的各个钩子函数串联起来,后续在请求处理的过程中直接执行挂载好的钩子函数

4)生成一个server对象,包含配置信息、服务器信息、一些辅助函数等

5)配置一系列内置中间件,各个中间件做的事情,可以参考文章https://www.modb.pro/db/966326)返回 server 对象

调用 server 的 listen 方法

1)运行插件containerbuildStart钩子,进而运行所有插件的buildStart钩子

2)进行依赖预构建,运行runOptimize函数。

3)开启服务,监听端口

请求处理流程

1)主要处理流程在tansformMiddleware中间件处理,这部分后面的内容会详细介绍

依赖预构建

进行依赖预构建有两个目的:

  1. CommonJS 和 UMD 兼容性: 开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
  1. 性能:Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。例如将 lodash 中的小模块打包成一个大的文件

参数配置

首先看一下,vite 配置中关于 optimizeDeps 的入参

export interface DepOptimizationOptions {
/**
* 入口文件,默认从html文件进行解析收集依赖,如果配置了的话,就从配置文件开始进行解析
*/
entries?: string | string[]
/**
* 需要进行预构建的文件
*/
include?: string[]
/**
* 不需要进行预构建的依赖
*/
exclude?: string[]
/**
* 预构建是通过esbuild进行的,所以可以自定义配置esbuild参数
*/
esbuildOptions?: Omit<
EsbuildBuildOptions,
| 'bundle'
| 'entryPoints'
| 'external'
| 'write'
| 'watch'
| 'outdir'
| 'outfile'
| 'outbase'
| 'outExtension'
| 'metafile'
>
}

预构建结果

预构建的结果默认保存在node_modules/.vite中,具体预构建的依赖列表在_metadata.json 文件中,其中_metadata.json 的内容为一个 json 结构

{
// 配置的hash值
hash : afcda65e ,
/**
* 主要用于浏览器获取预构建的 npm 依赖时,添加的查询字符串
* 在依赖变化时,浏览器能更新缓存
*/
browserHash : c369dd06 ,
optimized : { // 预构建的优化列表
react : {
// 构建后的文件地址
file : /Users/zhachunliu/Desktop/own/demo/vite-demo/vite-react-project/node_modules/.vite/react.js ,
// 原始文件地址
src : /Users/zhachunliu/Desktop/own/demo/vite-demo/vite-react-project/node_modules/react/index.js ,
// 记录那些在依赖预构建时,使用了commonjs语法的依赖
// 如果使用了commonjs语法,那么 needsInterop 为 true
needsInterop : true
},
react-dom : {
file : /Users/zhachunliu/Desktop/own/demo/vite-demo/vite-react-project/node_modules/.vite/react-dom.js ,
src : /Users/zhachunliu/Desktop/own/demo/vite-demo/vite-react-project/node_modules/react-dom/index.js ,
needsInterop : true
},
lodash : {
file : /Users/zhachunliu/Desktop/own/demo/vite-demo/vite-react-project/node_modules/.vite/lodash.js ,
src : /Users/zhachunliu/Desktop/own/demo/vite-demo/vite-react-project/node_modules/lodash/lodash.js ,
needsInterop : true
},
react/jsx-dev-runtime : {
file : /Users/zhachunliu/Desktop/own/demo/vite-demo/vite-react-project/node_modules/.vite/react_jsx-dev-runtime.js ,
src : /Users/zhachunliu/Desktop/own/demo/vite-demo/vite-react-project/node_modules/react/jsx-dev-runtime.js ,
needsInterop : true
}
}
}

预构建过程

入口文件:src/node/optimizer/index.ts,入口函数:optimizeDeps,构建过程如下

前端框架源码解读之Vite


  1. 调用 getDepHash()函数去计算当前依赖相关的的 hash 值,影响依赖预构建 hash 值的内容有
    1. 包管理器的 lockfile,例如 package-lock.json,yarn.lock,或者 pnpm-lock.yaml
    2. vite.config.js 中的部分相关配置,如 plugins、optimizeDeps 的 include 和 exclude 等
  1. 读取本地_metadata.json 中的 hash 值,判断和计算出来的是否一致,一致且未设置强制构建的话,则直接结束预构建过程,否则需要进入预构建过程

  2. 通过({ deps, missing } = await ``scanImports``(config));进行依赖扫描,得到需要处理的依赖,deps 是一个对象,是依赖的包名和文件系统中的路径的映射,如下图所示

前端框架源码解读之Vite

其中, scanImports方法会扫描根目录下的所有 .html 文件或者用户配置对 optimizeDeps.entries 文件,然后找到文件中所有的 script 标签,这样就找到了入口 js 文件,之后调用 esbuild,通过配置的插件,就可以一层层的找到对应的依赖项了


  1. 使用 es-module-lexerparse处理所有的 deps,获得其中 exportsData内容 ,并得到依赖 id 到 exportsData的映射,用于之后 esbuild构建时进行依赖图分析并打包到一个文件里面,parse 解析后的结构如下图所示

    前端框架源码解读之Vite


  1. 调用 esbuild进行依赖的预构建,并将构建之后的文件写入缓存目录 node_modules/.vite,得益于 esbuild 比传统构建工具快 10-100 倍的速度,所以依赖预构建也是非常快的
  2. 前端框架源码解读之Vite

  1. 将 metadata 信息写进本地缓存目录下,后续可以直接使用缓存的依赖

依赖访问过程

在进行了依赖预构建之后,如何访问这些已经构建的依赖呢

1)在加载资源文件的时候,会通过vite:import-analysis插件进行依赖解析,碰到已经进行预构建的依赖,直接替换,将import React from 'react'替换成import __vite__cjsImport2_react from /node_modules/.vite/react.js?v=0f16c3f0这样的形式

2)在浏览器去请求资源的时候,通过resolvePlugin插件去解析,获得真正的本地文件,匹配到对应的本地缓存资源

模块加载

对于浏览器请求,针对一个文件的访问,vite 会如何进行处理呢?

主要由以下两个中间件来统一处理请求的内容,并在中间件处理的流程中调用 vite 插件容器的相关钩子函数

  • transformMiddleware:核心中间件处理代码
  • indexHtmlMiddleware:html 相关请求处理中间件

vite 插件体系

在这里,我们先了解一下 vite 的插件体系,Vite 插件扩展了设计出色的 Rollup 接口,带有一些 Vite 独有的配置项。

因此,你只需要编写一个 Vite 插件,就可以同时为开发环境和生产环境工作。vite 的插件其实就是定义一个对象,该对象包含了一系列的 hook 函数配置

export default function myPlugin() {
const virtualModuleId = '@my-virtual-module'
const resolvedVirtualModuleId = '\0' + virtualModuleId

return {
name: 'my-plugin', // 必须的,将会在 warning 和 error 中显示
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export const msg = from virtual module `
}
}
}
}

Rollup 插件兼容性

相当数量的 Rollup 插件将直接作为 Vite 插件工作,但并不是所有的,因为有些插件钩子在非构建式的开发服务器上下文中没有意义。

一般来说,只要 Rollup 插件符合以下标准,它就应该像 Vite 插件一样工作:

  • 没有使用 moduleParsed钩子。
  • 它在打包钩子和输出钩子之间没有很强的耦合。

和rollup保持一致的通用钩子以下钩子在服务器启动时被调用:

  • options
  • buildStart

以下钩子会在每个传入模块请求时被调用:

  • resolveId
  • load

  • transform

以下钩子在服务器关闭时被调用:

  • buildEnd
  • closeBundle

Vite 独有钩子

Vite 插件也可以提供钩子来服务于特定的 Vite 目标。这些钩子会被 Rollup 忽略。

  • config
  • configResolved

  • transformIndexHtml

  • handleHotUpdate

具体插件执行过程

1)在 dev 环境模拟了一套和 rollup 保持一致的插件运行环境,确保在开发环境和生产环节的核心环节执行同样的流程

2)vite 通过createPluginContainer创建了一个插件容器,将每个插件中对应的 hook 收集起来

3)最终在各个生命周期阶段,执行对应的已经收集好的钩子

模块请求加载过程

前端框架源码解读之Vite


GET /

当访问页面的时候,实际是有一个 GET / => /index.html 的重定向进入 indexHtmlMiddleware 这个过程,主要做了一件事情,注入 dev 环境需要的一些依赖,@vite/client 主要用来和服务器进行 ws 通信并处理一些 hmr 相关的工作,@react/refresh这段代码,是 vite-plugin-react 插件注入的代码,用来处理 dev 环境的一些能力

前端框架源码解读之Vite

GET /@vite/client

前面讲到,@vite/client 里面的代码主要用于与服务器进行 ws 通信来进行 hmr 热更新、以及重载页面等操作。

这个请求会直接进入 transformMiddleware 中间件中,进入中间件的处理过程:中间件会调用transformRequest(url, server, options = {})函数

  1. @vite/client 是如何映射到对应的内容呢,在调用 pluginContainer.resolveId的过程中会遇到 aliasPlugin 插件的钩子,执行名称替换,最终替换成 vite/dist/client/client.mjs
  1. 继续将改写过的路径传给下一个插件,最终进入resolvePlugin插件的tryNodeResolve函数,最终解析获得该文件的 id 为/Users/zhachunliu/.nvm/versions/node/v14.17.0/lib/node_modules/vite/dist/client/client.mjs

  2. 最终通过pluginContainer.load获取加载本地文件,然后通过pluginContainer.transform进行代码转换,将转换后的代码通过send方法发送给浏览器

GET /src/main.tsx

针对普通的 tsx 文件的请求,流程基本上和上面介绍的GET /@vite/client一致,不同点在于使用的插件钩子内容不一样,因为需要对 tsx 文件进行处理成 js

  1. 通过 resolveId 钩子函数,将/src/main.tsx 映射到本地文件系统
  1. 调用 load 钩子函数,加载本地文件到内存中

  2. 通过 vite:react-babel 插件,将 jsx 语法进行转换,转换成 js 代码

  3. 通过 vite:esbuild 插件,进行代码格式化

  4. 通过 vite:import-analysis 插件,将代码中所有的 import 内容,转换成对应的本地文件,方便后续直接请求

  5. 返回结果

其他的所有请求,都是经过类似的插件处理流程,最终返回给浏览器一段可执行的 JS 代码,就不一一介绍了。

vite 调试工具

vite-plugin-inspect(插件调试工具,强推)

在学习、调试或创作插件时,建议在你的项目中引入vite-plugin-inspect。它可以帮助你检查 Vite 插件的中间状态。安装后,你可以访问localhost:3000/__inspect/来检查你项目的模块和栈信息。请查阅vite-plugin-inspect 文档中的安装说明。


Vite debug 模式

通过vite --force --debug命令,可以明确的了解到,启动过程和请求过程,经历了什么插件,具体的执行流程等,方便调试


参考资料

  • 前端工具链十年盘点:
  • 如何调试 vite 源码:https://maximomussini.com/posts/debugging-javascript-libraries

  • 源码理解:https://jishuin.proginn.com/p/763bfbd5f00e