Vite开发阶段发生了什么?

2,062 阅读14分钟

2222年了,众所周知的是,Vite已然成为当下最流行的构建工具之一,凭借它与众不同的特性,受到了无数前端程序员们的青睐。

1.Vite的优势在哪里?

​ Vite的主要优势实际上是在开发阶段给人的体验与其他构建工具有着极大的差异,我们可以单拿DevServer的启动时间来举例,在开发阶段我们启动基于Vite的开发服务器一般都是秒开,这通常和我们项目的体积没有关联;而对基于webpack构建的项目来说,启动项目的时间通常和项目的体积是成正相关的。造成这种现象的原因是Vite的DevServer并不基于bundle,而是利用现代浏览器的ES模块化特性去进行打包。使浏览器接管打包的工作,Vite的工作是在浏览器拿到源码以后进行按需加载源码,即用即加载;而不像webpack的工作机制,先根据我们的模块化结构绘制模块依赖图,然后再去执行接下来的流程。

​ 除了DevServer以外,其实还有很多优势造就了它开发阶段与众不同的特质,比如说更快的HMR以及依赖预构建,接下来我们更加详细的分析Vite在开发阶段的一些细节。

2.Vite在开发阶段做了哪些事情?

首先我们可以先过一遍Vite的执行流程,我们这里将执行流程分为DevServer前和后,这也刚好对应我们的主题,Vite在我们开发阶段做了些什么,原理是什么?

首先就是DevServer之前Vite会进行一个预构建的操作,也就是官网所提到的依赖预构建,预构建主要为我们事先将package.json中所依赖的第三方包以及将我们在配置文件中所使用optimizeDeps选项include的文件等进行预加载,将这些加载好的资源放在node_modules/.vite/deps文件夹下。

接着就是我们的DevServer启动时,也就是我们经常所说的开发服务器,这时Vite主要会开启服务器,发起一个HTTP请求,接着它会进行转换并且按需提供源码,这依赖于我们的ESM,因此不管后续有多少route增加,都不会影响我们的启动时间。

其次在DevServer启动时也会开启一个WebSocket服务用来监听我们的文件状态,当文件有改动时文件监听执行回调,这时WebSocket将变动信息推送给客户端,获得更新文件以后执行更新。

看到这里实际上就是主要的执行流程,那我们可以知道,重点实际上就是依赖预构建与HMR以及Dev Server启动时,所以我们按照这个顺序去逐个解读。

3.依赖预构建

​ Vite的依赖预构建是开发阶段的一种优化,顾名思义,是在DevServer启动之前将需要预构建的依赖进行提前构建,然后在后续用到这些依赖时再去动态的应用预先在内存中的依赖。

​ 上面提到的,需要构建的依赖,有的小伙伴可能会有疑问,到底什么是需要构建的依赖呢?Vite中我们将哪些内容可以作为依赖预构建的目标呢?

​ 这里使用官网给出的例子,在一个页面中使用lodash-es这个库,这是一个含有上百个内置模块的第三方库,也就是说如果我们直接在一个需要使用它的地方导出就会有上百个HTTP请求发出,这是一个十分耗费性能的操作,如果同时有其他请求并行时会造成网络堵塞,导致页面卡顿。但是如果通过依赖预构建就可以很好的解决这个问题,因为它可以将lodash-es转换为一个模块,将上百个请求变为1个请求。下图就是我们有预构建和无预构建时的网络请求情况。

企业微信20220929-091759@2x.png

​ 看完上面的例子不知道你是否get到了依赖预构建的目标「需要构建的依赖」,这里一共有两类,第一类就是项目的第三方依赖,也就是dependencies下的第三方库;其二就是在配置文件中通过optimizeDeps选项所include进来的包,同理也可以在这里配置不进行依赖预构建的依赖包。

​ 最终其产物会被缓存带node_modules/.Vite这个文件夹中,后续会根据我们所提到的「需要构建的依赖」的变化去更新或重新预构建。当然也可以通过-- force去强制依赖预构建。

其他的更多配置请转至官网学习

3.1 依赖预构建解决了什么问题

CommonJS和UMD的兼容 &性能&缓存

​ 前面提到Vite是基于ESM去构建项目,因此Vite需要将所有通过CommonJS或其他模式发布的依赖转换为ESM的类型

​ 性能实际上就是我们本章初始提到的例子, 将多个繁杂的内部模块的 ESM 依赖关系转换为单个模块,以减少HTTP请求的次数,提高页面的加载速度。

总结成四个字实际上就是就是适配和性能,下面主要来看看Vite时如何围绕这两个概念去做的。

3.1.1 依赖预构建的核心实现

首先我们可以全面的分析一下依赖预构建的主要流程,然后再去挑几个比较核心的去展开了解。

3.1.2 主要流程

  1. 根据package.json以及配置文件中的相关信息生成hash值
  2. 获取_matadata.json文件的的信息,关于_matadata.json是什么我们一会单独说
  3. 判断是否强制预构建(--force)
  4. 如果强制预构建,对比_matadata.json文件中的hash和上次构建的hash是否相同,如果相同直接返回上次预构建的内容,如果不同则看下一步,刚好也就是第三步「是」的下一个流程
  5. 清空/创建缓存文件夹,缓存文件内创建package.json文件
  6. 判断是否存在newDeps依赖列表
  7. 如果有则为其deps = newDeps赋值(deps就是我们需要预构建的模块列表)
  8. 如果没有则进行依赖扫描操作
  9. 拿到依赖列表以后使用esbuild构建模块
  10. 最终将预构建数据写入到_matadata.json中
  11. 最终返回预构建数据

3.1.3 _matadata.json

hash通常是需要预构建的文件内容生成,用于防止开发服务器启动时重复构建相同的依赖

browserHash 由 hash和在运行时发现的额外的依赖生成的,用于让预构建的依赖的浏览器请求无效。

optimized 对所有预构建过的依赖进行信息的记录吗,如路径

needsInterop 主要对依赖包的导入进行分析,由于其本身的模块化规则或需要预构建而进行重写

因此我们看出这个文件在预构建的过程中起的作用主要是记录缓存,决定后续是否进行依赖预构建,以及预构建的产物。

{
  "hash": "848508af",
  "browserHash": "33a1c9c5",
  "optimized": {
    "element-plus": {
      "src": "../../element-plus/es/index.mjs",
      "file": "element-plus.js",
      "fileHash": "657736b4",
      "needsInterop": false
    },
    "vue": {
      "src": "../../vue/dist/vue.runtime.esm-bundler.js",
      "file": "vue.js",
      "fileHash": "11c12c0b",
      "needsInterop": false
    }
  },
  "chunks": {
    "chunk-JQP6LVYW": {
      "file": "chunk-JQP6LVYW.js"
    }
  }
}

3.1.4 依赖扫描

从我们上面的步骤得知,是在我们判断没有newDeps存在时进行的操作,核心实现方法为scanImports()

let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
    ;({ deps, missing } = await scanImports(config))
} else {
    deps = newDeps
    missing = {}
}

首先我们要清楚依赖扫描这个过程的作用是干什么的,是因为Vite在依赖预构建的这个过程并不是每个模块都被构建的,而是在源码中抓取裸模块「bare import」,表示期望从node_modules解析。

所以我们明确一点依赖扫描是一定是要从入口处进行向下层依赖进行分析,在整个依赖树中去寻找导入项。实际上这个寻找的过程也是一个深度优先遍历算法,我们需要找到每一份文件中所包含的import语句,对于JS文件是这样的,当遇到import语句时判断目标是第三方的依赖还是我们自己本地引入的文件,但是如果遇到其他文件呢?比如vue文件、html文件或者其他的资源文件呢?

1.实际上依赖扫描的过程只关注js部分,不管是html文件还是vue文件或者jsx文件都只关注js部分

2.Vite内部实现搜索依赖实际上是利用了打包工具的特性,因为在打包时就会从入口文件开始,找出所有的依赖项,这个过程也是一个深遍历的过程,这里使用的是esbuild构建工具去进行的,esbuild不同于其他构建工具,它是一款基于go语言的开发的打包工具,不同于传统的基于js的解释运行和单线程模型,而是采用编译运行和多线程模式,合理的调度了cpu资源,所以很快

3.对于其他资源文件,打包工具会直接将其忽略,不参与后续流程

scanImports

上文提到处理依赖扫描的入口函数在scanImports(),这个函数主要去通过config文件中的配置项去决定入口文件的位置entries变量,这个变量后续会成为esbuildScanPlugin插件的参数,分别为config.optimizeDeps.entriesconfig.build.rollupOptions.input

esbuildScanPlugin

ESbuild是根据这个插件去搜寻依赖的,将其作为插件在ESbuild开始构建整个项目时获取预构建的依赖

esbuildScanPlugin方法返回一个插件对象,其根对各种不同类型的文件都做了不同的处理。其涵盖了所有可能出现的文件。

源码160行

3.1.5 依赖编译

上面的步骤我们拿到了预构建模块列表,接下来一步就是将这些信息通过ESbuild进行编译,将最终的产物拼接到_matedata.json文件中,最终返回预构建数据

需要注意的是我们需要将预构建模块列表的模块通过es-module-lexer转换为抽象语法树,这些信息都将作为ESbuild插件esbuildOptions的参数进行编译操作。

const result = await build({
    absWorkingDir: process.cwd(),
    entryPoints: Object.keys(flatIdDeps),
    bundle: true, // 这里为 true,可以将有许多内部模块的 ESM 依赖关系转换为单个模块
    format: 'esm',
    target: config.build.target || undefined,
    external: config.optimizeDeps?.exclude,
    logLevel: 'error',
    splitting: true,
    sourcemap: true,
    outdir: cacheDir,
    ignoreAnnotations: true,
    metafile: true,
    define,
    plugins: [
        ...plugins,
        esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr), //esbuildDepPlugin
    ],
    ...esbuildOptions,
})
const meta = result.metafile!
rocess.cwd(), cacheDir)
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,
      cacheDirOutputPath
    )
  }
}
writeFile(dataPath, JSON.stringify(data, null, 2))
return data

4. Dev Server与热更新

关于Dev Server,各大构建工具都有成熟的实践,Vite的Dev Server实际上就是利用了新生代浏览器的ESM特性,Vite开启一个本地服务器去拦截import引用所发起的请求,经过一系列的处理再以ESM格式返回给浏览器,其在这个过程中没有对我们的文件进行编译打包。

4.1 Vite的devServer启动速度为什么快

其实当我们了解了Vite的dev server的执行原理以后会发现不是因为Vite快,webpack也并不是「慢」,而是他们各自不同的特性,不同的处理流程导致的。

​ 首先传统的打包工具例如Webpack的执行流程实际上是先对所有的模块进行依赖的解析,绘制依赖关系图,再去打包构建启动服务器,DevServer的启动必须要等所有的依赖模块构建完成,同理当我们修改了模块中的内容时,整个bundle就会重新打包。这样一来随着项目体积的逐渐增大,启动的时间也会变得越来越久。

​ 相比之下再来看Vite这边,它其实是利用浏览器的ESM特性,当import时,浏览器会发起HTTP请求,代码执行到对应的模块时再去请求对应的模块文件,没有用到的部分不会进行加载,不会参与构建,实现了按需加载的效果,随着项目的体积增大,也并不会对其启动速度构成影响。

下图为官网的Vite与传统构建工具的dev server原理对比图

企业微信20220928-173607@2x.png

企业微信20220928-173616@2x.png

4.2 HMR热更新

关于热更新,是开发阶段的一个比较重要的实现,其核心思想其实都是利用了webSocket,使其在浏览器和服务器之间建立一个通信桥梁,监听我们文件的变动,当文件改变时服务端会发送消息通知浏览器修改相应的代码,接着浏览器做出相应的更新。

4.2.1 Vite热更新的流程

Vite的热更新过程可以分为以下几步:

  1. 启动Vite服务器
  2. 创建WebSocket服务并启动文件监听
  3. 文件被更改时执行回调并生成文件更改信息
  4. 服务端通过WebSocket像浏览器发送消息
  5. 浏览器获取到更新文件以后执行更新

4.2.2 chokidar

在Vite开启dev server时,也会开启WebSocket服务,Vite利用node中的chokidar对文件进行的修改进行监听

  const ws = createWebSocketServer(httpServer, config, httpsOptions)
  const { ignored = [], ...watchOptions } = serverConfig.watch || {}
  const watcher = chokidar.watch(path.resolve(root), {
    ignored: [
      '**/node_modules/**',
      '**/.git/**',
      ...(Array.isArray(ignored) ? ignored : [ignored])
    ],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true,
    ...watchOptions
  }) as FSWatcher
  ...通过watcher.on对文件进行监听

4.2.3 createWebSocketServer

上面代码实际上是在createServe「Vite开启dev server的方法」中的逻辑,createWebSocketServer这个方法实际上主要是创建了WebSocket服务,并且返回封装好的on、off、send、close方法用来推送服务和关闭服务。

export function createWebSocketServer(
  server: Server | null,
  config: ResolvedConfig,
  httpsOptions?: HttpsServerOptions
): WebSocketServer {
  let wss: WebSocket
  let httpsServer: Server | undefined = undefined
  const hmr = isObject(config.server.hmr) && config.server.hmr
  const wsServer = (hmr && hmr.server) || server
  if (wsServer) {
    wss = new WebSocket({ noServer: true })
    wsServer.on('upgrade', (req, socket, head) => {
      if (req.headers['sec-websocket-protocol'] === HMR_HEADER) {
        wss.handleUpgrade(req, socket as Socket, head, (ws) => {
          wss.emit('connection', ws, req)
        })
      }
    })
  } else {
    // Vite dev server in middleware mode
    wss = new WebSocket(websocketServerOptions)
  }
  wss.on('connection', (socket) => {})
  wss.on('error', (e: Error & { code: string }) => {})
  return {
    on: wss.on.bind(wss),
    off: wss.off.bind(wss),
    send(payload: HMRPayload) {},
    close() {}
  }
}

4.2.4 热更新的执行

接收到文件改动以后需要执行的回调,这里执行的回调实际上执行的是我们前面提到的chokidar创建的watchr,接着需要修改文件缓存和执行热更新两个主要操作。

  watcher.on('change', async (file) => {
    file = normalizePath(file)
    if (file.endsWith('/package.json')) {
      return invalidatePackageData(packageCache, file)
    }
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)
    if (serverConfig.hmr !== false) {
      try {
        await handleHMRUpdate(file, server)
      } catch (err) {
        ws.send({
          type: 'error',
          err: prepareError(err)
        })
      }
    }
  })

在监听到文件变动以后会调用moduleGraph.onFileChange(file),moduleGraph是用来记录模块的依赖图,onFileChange函数主要是用来清空被修改文件的所代表的模块缓存失效

  onFileChange(file: string): void {
    const mods = this.getModulesByFile(file)
    if (mods) {
      const seen = new Set<ModuleNode>()
      mods.forEach((mod) => {
        this.invalidateModule(mod, seen)
      })
    }
  }

  invalidateModule(
    mod: ModuleNode,
    seen: Set<ModuleNode> = new Set(),
    timestamp: number = Date.now()
  ): void {
    mod.lastInvalidationTimestamp = timestamp
    mod.transformResult = null
    mod.ssrTransformResult = null
    invalidateSSRModule(mod, seen)
  }

4.2.5 handleHMRUpdata

接着就是handleHMRUpdata的函数的执行,这个模块主要是对目标文件更改进行监听,根据文件的类型「如是否是配置文件」,使客户端刷新或者重载文件。

  1. 获取目标file
  2. 通过if (isConfig || isConfigDependency || isEnv) 判断是否是此类文件,是的话重启server
  3. 通过startWith判断是否改变的事Vite本身,如果是的话ws发送full-reloaded通知
  4. 接着通过getModuleByFile()文件转换为ModuleNode格式,并且构成一个hmrContext的对象
  5. 接着将config.plugin中的更新挂载在hmrContext的modules上。
  6. 如果hmrContext.modules.length>0也就是所有插件更新时,执行updataModules()方法,否则判断文件类型是否是html类型,如果是则发起错误日志,否则通过ws发起full-reloaded通知

这个方法源码链接贴在这里,可以看看,挺不错

4.2.6 handleMessage

接着就是在客户端去处理WebSocket的消息「比如上一部分的full-reloaded就是在这里处理」,首先判断如果不是ssr的模式,就会去执行handleMessage这个方法,这个方法会根据不同的消息类型去处理不同的操作,具体的消息类型对应的事件如下: connected:通过socket建立连接

update:更新部分代码

custom:自定义事件

full-reload:全局更新

prune:热更新以后清除

error:错误处理事件

以上我们简单分析了一下Vite在开发阶段做的一些事情,并没有做一个很详细的解析,通过本篇文章可能会更加方便的让你理解Vite的依赖预构建以及热更新相关的知识。