Vite 原理分析

2,887 阅读11分钟

作者:折曜

文章作者授权本账号发布,未经允许请勿转载

Vite 是什么

作者原话: Vite,一个基于浏览器原生 ES Modules 的开发服务器。利用浏览器去解析模块,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。

Vite(读音类似于[weɪt],法语,快的意思) 是一个由原生 ES Module 驱动的 Web 开发构建工具。在开发环境下基于浏览器原生 ES module 开发,在生产环境下基于 Rollup 打包。

Vite 的特点

  • Lightning fast cold server start - 闪电般的冷启动速度
  • Instant hot module replacement (HMR) - 即时热模块更换(热更新)
  • True on-demand compilation - 真正的按需编译

为了实现上述特点,Vite 要求项目完全由 ES Module 模块组成,common.js 模块不能直接在 Vite 上使用。因此如何兼容一些未提供 es module 格式代码的 sdk 成为了能否在生产环境的关键问题。在这个问题被解决前,打包依旧还是需要使用 rollup 等传统打包工具。因此 在目前来说 Vite 更像是一个类似于 webpack-dev-server 的开发工具。

Webpack vs Vite

我们以 vite 与 vue-cli 创建的模板项目为例

冷启动速度对比

冷启动速度对比

从左到右依次是: vue-cli3 + vue3 的demo, vite 1.0.0-rc + vue 3的demo, vue-cli3 + vue2的demo。

在这个 gif 中已经可以明显感受到 vite 的优势了。vue-cli3 启动Vue2大概需要5s左右,vue-cli3 启动Vue3需要4s左右,而vite 只需要1s 左右的时间。

从理论上讲 Vite 是ES module 实现的。随着项目的增大启动时间也不会因此增加。而 webpack 随着代码体积的增加启动时间是要明显增加的。

热更新速度对比

Vite 热更新速度很难用图直接比较(在项目较小时热更新速度都挺快的),只能从理论上讲讲,因为 Vite 修改代码后只是重新请求修改部分的代码不受代码体积的影响,而且使用了esbuild这种理论上快 webpack 打包几十倍的工具。所以相比于 webpack 这种每次修改都需要重新打包 bundle 的项目是能明显提升热更新速度的。

原理对比

当我们使用如 webpack 的打包工具时,经常会遇到改动一小行代码,webpack 常常需要耗时数秒甚至十几秒进行重新打包。这是因为 webpack 需要将所有模块打包成一个一个或者多个模块。这就是以 webpack 为代表的 bundle方案。

webpack 原理

以下面代码为例,当我们使用 webpack 类的打包工具时。最终会将所有的代码打包入一个 bundle.js 的文件中。

// a.js 
export const a = 10
// b.js 
export const b = 20;
// main.js 
import { a } from 'a.js'
import { b } from 'b.js'
export const getNumber = () => {
  return a + b;
}
// bundle.js
const a = 10;
const b = 20;
const getNumber = () => {
  return a + b;
}
export { getNumber };

以 bundle方案 为基本原理的工具(webpack rollup)总是避免不了一个核心问题。当我们修改了 bundle 模块中的一个子模块, 整个 bundle 文件都会重新打包然后输出,随着项目的扩大,整个项目需要打包的资源也增多了,打包的时间也会越来越长。

我们常用 thread-loader, cache-loader 等方法进行优化。但随着项目规模进一步扩大,热更新速度又将变慢。随之而来的又是新一轮的优化。随着项目规模的不断扩大,基于 bundle 方案构建的项目优化也将达到一定的极限。

滚雪球

webpack 之所以慢,是因为 webpack 会将许多资源构成一个或者多个 bundle (耗费大量打包时间)。如果我们跳过打包的过程,当需要某个模块时再通过请求去获取是不是能完美解决这个问题呢?

bundleless

因此,Vite来了。一个由原生 ES Module 驱动的 Web 开发构建工具,完全做到按需加载,一劳永逸的解决了热更新慢的问题!

Vite 原理实现

前置知识

ES Modules

ES Modules 是浏览器支持的一种模块化方案,允许在浏览器实现模块化。以 Vite 创建的模版为例子,代码如下

<template>
  <img alt="Vue logo" src="./assets/logo.png" />
  <HelloWorld msg="Hello Vue 3.0 + Vite" />
</template>

<script>
  import HelloWorld from './components/HelloWorld.vue'
  export default {
    name: 'App',
    components: {
      HelloWorld
    }
  }
</script>

当浏览器解析 import HelloWorld from './components/HelloWorld.vue' 时,会往当前域名发送一个请求获取对应的资源

请求详情

值得一提的是,我们平时在 Webpack 中写的 esm 格式的代码最终会在 webpack 中被打包成一个巨型的 bundle.js,(因为浏览器不支持 commonjs, esm又有兼容性问题,打包成一个巨型的bundle.js文件是较好的方案)所以自然的一些 esm 的特性也不能被使用。

Vite 采用了 ES Module 来实现模块的加载。目前基于 web 标准的ES Module 已经覆盖了超过90%的浏览器。且作为 ECMA 标准,未来会有更多浏览器支持ECMA规范

caniuse 兼容性

ES build

Vite 对 js/ts 的处理没有使用如 glup, rollup 等传统打包工具,而是使用了 esbuild。esbuild 是一个全新的js打包工具,支持如babel, 压缩等的功能,他的特点是快(比 rollup 等工具会快上几十倍)!你可以点击这里了解更多关于 esbuild 的知识。

benchmark

而快的主要原因是他使用了 go 作为底层语言(go 这样的静态语言会比 动态语言 快很多)、native code 和一些专门针对编译速度上的优化。

golang 更快的原因

请求拦截原理

Vite 的基本实现原理,就是启动一个 koa 服务器拦截由浏览器请求 ES Module 的请求。通过请求的路径找到目录下对应的文件做一定的处理最终以 ES Modules 格式返回给客户端

拦截流程

node_modules 模块的处理

首先说一下 基于 ES Module 模块的局限性,在我们平时写代码时。如果引用一个 node_modules 模块时,我们都是是以如下的写法。

import vue from 'vue'

如 webpack & rollup(rollup有对应插件) 等打包工具会帮我们找到模块的路径。但浏览器只能通过相对路径去寻找。为了解决这个问题,Vite对其做了一些特殊处理。以 Vite 官方 demo 为例,当我们请求 localhost:3000 时,Vite 默认返回 localhost:3000/index.html 的代码。而后发送请求 src/main.jsmain.js 代码如下。

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

createApp(App).mount('#app')

请求详情

可以观察到浏览器请求 vue.js 时, 请求路径是 @modules/vue.js。 在 Vite 中约定若 path 的请求路径满足 /^\/@modules\// 格式时,被认为是一个 node_modules 模块。在 Vite 中,对于 node 模块的请求有着对应的处理。

  • /:id 转化为 /@modules/:id

Vite 对 ES module 形式的 js 文件模块的处理使用了 ES Module Lexer 处理。Lexer 会找到代码中以 import 语法导入的模块并以数组形式返回。Vite 通过该数组的值获取判断是否为一个 node_modules 模块。若是则进行对应改写成 @modules/:id 的写法。

// Plugin for rewriting served js.
// - Rewrites named module imports to `/@modules/:id` requests, e.g.
//  "vue" => "/@modules/vue"
export const moduleRewritePlugin: ServerPlugin = ({
 root,
 app,
 watcher,
 resolver
}) => {
  app.use(async (ctx, next) => {
    await initLexer //初始化 ES Module Lexer,获取代码中导入的 modules 
    // 判断是否是一个 node_modules
    const importer = removeUnRelatedHmrQuery(
     resolver.normalizePublicPath(ctx.url)
    )
    // 改写 imports 
    ctx.body = rewriteImports(
      root,
     content!,
     importer,
     resolver,
     ctx.query.t
    )
  })
}

对于以 script 标签导入的 esm 模块也会有对应的处理。以正则表达式查找是否有 <script type='module'> 格式的代码,找到后以类似的逻辑处理

const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/  

async function rewriteHtml(importer: string, html: string) {
  await initLexer
  html = html!.replace(scriptRE, (matched, openTag, script) => {
    if (script) {
      //...
    } else {
      const srcAttr = openTag.match(srcRE)
      if (srcAttr) {
        // register script as a import dep for hmr
        const importee = resolver.normalizePublicPath(
          cleanUrl(slash(path.resolve('/', srcAttr[1] || srcAttr[2])))
        )
        ensureMapEntry(importerMap, importee).add(importer)
      }
      return matched
    }
  })
  return injectScriptToHtml(html, devInjectionCode)
}
  • 通过 /@modules/:id 在 node_modules 文件下找到对应模块

浏览器发送 path 为 /@modules/:id 的对应请求后。会被 Vite 客户端做一层拦截。/@modules/:id => id ,而后以 reslove 模块去获取对应的模块代码返回。

export const moduleRE = /^\/@modules\//
// plugin for resolving /@modules/:id requests.
app.use(async (ctx, next) => {
  if (!moduleRE.test(ctx.path)) {
    return next()
  }
  // path maybe contain encode chars
  const id = decodeURIComponent(ctx.path.replace(moduleRE, ''))
  ctx.type = 'js'
  const serve = async (id: string, file: string, type: string) => {
    // 在代码中做一个缓存,下次访问相同路径直接从 map 中获取 304 返回
    moduleIdToFileMap.set(id, file)
    moduleFileToIdMap.set(file, ctx.path)
    debug(`(${type}) ${id} -> ${getDebugPath(root, file)}`)
    await ctx.read(file)
    return next()
  }
 }
  // 兼容 alias 情况
  const importerFilePath = importer ? resolver.requestToFile(importer) : root
  const nodeModulePath = resolveNodeModuleFile(importerFilePath, id)
  // 如果是个 node_modules 的模块,读取文件。
  if (nodeModulePath) {
   return serve(id, nodeModulePath, 'node_modules')
  }
})

.vue 模块的处理

当 Vite 遇到一个 .vue 后缀的文件时。由于 .vue 模板文件的特殊性,它被拆分成 template, css, script 模块三个模块进行分别处理。最后会对 script, template, css 发送多个请求获取。

请求详情

如上图中请求 App.vue 获取script 代码 , App.vue?type=template 获取 template, App.vue?type=style。这些代码都被插入在 app.vue 返回的代码中。

代码注入

if (descriptor.customBlocks) {
  descriptor.customBlocks.forEach((c, i) => {
    const attrsQuery = attrsToQuery(c.attrs, c.lang)
    const blockTypeQuery = &blockType=${qs.escape(c.type)}
    let customRequest = publicPath + ?type=custom&index=${i}${blockTypeQuery}${attrsQuery}
    const customVar = block${i}
    code += `\nimport ${customVar} from ${JSON.stringify(customRequest)}\n`
    code += `if (typeof ${customVar} === 'function') ${customVar}(__script)\n`
  })
}

if (descriptor.template) {
  const templateRequest = publicPath + `?type=template`
  code += `\nimport { render as __render } from ${JSON.stringify(templateRequest)}`
  code += `\n__script.render = __render``
}

code += `\n__script.__hmrId = ${JSON.stringify(publicPath)}`
code += `\n__script.__file = ${JSON.stringify(filePath)}`
code += `\nexport default __script`

静态资源(statics & asset & JSON )的加载

当请求的路径符合 imageRE, mediaRE, fontsRE 或 JSON 格式,会被认为是一个静态资源。静态资源将处理成 ES Module 模块返回。

// src/node/utils/pathUtils.ts
const imageRE = /\.(png|jpe?g|gif|svg|ico|webp)(\?.*)?$/
const mediaRE = /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/
const fontsRE = /\.(woff2?|eot|ttf|otf)(\?.*)?$/i
export const isStaticAsset = (file: string) => {
  return imageRE.test(file) || mediaRE.test(file) || fontsRE.test(file)
}

// src/node/server/serverPluginAssets.ts
app.use(async (ctx, next) => {
  if (isStaticAsset(ctx.path) && isImportRequest(ctx)) {
    ctx.type = 'js'
    ctx.body = export default ${JSON.stringify(ctx.path)}
    return
  }
  return next()
})

export const jsonPlugin: ServerPlugin = ({ app }) => {
  app.use(async (ctx, next) => {
    await next()
    // handle .json imports
    // note ctx.body could be null if upstream set status to 304
    if (ctx.path.endsWith('.json') && isImportRequest(ctx) && ctx.body) {
      ctx.type = 'js'
      ctx.body = dataToEsm(JSON.parse((await readBody(ctx.body))!), {
        namedExports: true,
        preferConst: true
      })
    }
  })
}

热更新(Hot Module Replacement)原理

Vite 的热加载原理,其实就是在客户端与服务端建立了一个 websocket 连接,当代码被修改时,服务端发送消息通知客户端去请求修改模块的代码,完成热更新。

服务端原理

服务端做的就是监听代码文件的改变,在合适的时机向客户端发送 websocket 信息通知客户端去请求新的模块代码。

客户端原理

Vite 中客户端的 websocket 相关代码在处理 html 中时被写入代码中。可以看到在处理 html 时,vite/client 的相关代码已经被插入。

export const clientPublicPath = `/vite/client`
const devInjectionCode = `\n<script type="module">import "${clientPublicPath}"</script>\n`
async function rewriteHtml(importer: string, html: string) {
  return injectScriptToHtml(html, devInjectionCode)
}

当request.path 路径是 /vite/client 时,请求获取已经提前写好的关于 websocket 的代码。因此在客户端中我们创建了一个 websocket 服务并与服务端建立了连接。

Vite 会接受到来自客户端的消息。通过不同的消息触发一些事件。做到浏览器端的即时热模块更换(热更新)。以下面为例,分为 connect、vue-reload、vue-rerender 等时间。分别触发组件vue 的重新加载,render等(具体实现并不是关注的重点)。

// Listen for messages
socket.addEventListener('message', async ({ data }) => {
  const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
  if (payload.type === 'multi') {
    payload.updates.forEach(handleMessage)
  } else {
    handleMessage(payload)
  }
})

async function handleMessage(payload: HMRPayload) {
  const { path, changeSrcPath, timestamp } = payload as UpdatePayload
  console.log(path)
  switch (payload.type) {
    case 'connected':
      console.log(`[vite] connected.`)
      break
    case 'vue-reload':
      queueUpdate(
        import(`${path}?t=${timestamp}`)
          .catch((err) => warnFailedFetch(err, path))
          .then((m) => () => {
            __VUE_HMR_RUNTIME__.reload(path, m.default)
            console.log(`[vite] ${path} reloaded.`)
          })
      )
      break
    case 'vue-rerender':
      const templatePath = `${path}?type=template`
      import(`${templatePath}&t=${timestamp}`).then((m) => {
        __VUE_HMR_RUNTIME__.rerender(path, m.render)
        console.log(`[vite] ${path} template updated.`)
      })
      break
    case 'style-update':
      // check if this is referenced in html via <link>
      const el = document.querySelector(`link[href*='${path}']`)
      if (el) {
        el.setAttribute(
          'href',
          `${path}${path.includes('?') ? '&' : '?'}t=${timestamp}`
        )
        break
      }
      const importQuery = path.includes('?') ? '&import' : '?import'
      await import(`${path}${importQuery}&t=${timestamp}`)
      console.log(`[vite] ${path} updated.`)
      break
    case 'js-update':
      queueUpdate(updateModule(path, changeSrcPath, timestamp))
      break
    case 'custom':
      const cbs = customUpdateMap.get(payload.id)
      if (cbs) {
        cbs.forEach((cb) => cb(payload.customData))
      }
      break
    case 'full-reload':
      if (path.endsWith('.html')) {
        // if html file is edited, only reload the page if the browser is
        // currently on that page.
        const pagePath = location.pathname
        if (
          pagePath === path ||
          (pagePath.endsWith('/') && pagePath + 'index.html' === path)
        ) {
          location.reload()
        }
        return
      } else {
       location.reload()
      }
  }
}

基于Vite的一些优化

Vite 基于的 ES module,在使用某些模块时。由于模块依赖了另一些模块,依赖的模块又基于另一些模块。会出现页面初始化时一次发送数百个模块请求的情况。

这里以 lodash-es 为例,一共发送了651个请求。一共花费1.53s。

lodash加载

Vite 为了优化这个情况,提供了一个 optimize 指令。我们可以直接使用 vite optimize 使用它

optimize指令

Optimize 原理类似于 webpack 的 dll-plugin loader ,他可以提前将 package.json 中的 dependencies 中的模块打包成多个 esmodule 模块。以 lodash-es 为例,他将整个 lodash-es 打包处理成一个 es-module。这样在获取模块时能减少大量请求。

减少请求数目

可以看到,在优化后仅发送了14个请求。

lodash优化过后加载

顺便提一下,有一个比较容易看出的问题是:如果我的组件嵌套很深,一个组件 import 了十个组件,十个组件又 import了 十个组件如何处理呢。

  1. 首先可以看到请求 lodash 时 651 个请求只耗时 1.53s。这个耗时是完全可以接受的。
  2. Vite 是完全按需加载的,在页面初始化时只会请求初始化页面的一些组件。(使用一些如 dynamic import 的优化)
  3. ES module 是有一些优化的,浏览器会给请求的模块做一次缓存。当请求路径完全相同时,浏览器会使用浏览器缓存的代码。关于ES module 的更多信息可以看 segmentfault.com/a/119000001…
  4. Vite 只是一个用于开发环境的工具,生产环境仍会打包成一个 commonJS 格式的 bundle 进行调用。
  5. es module 使用 bundleless 的方式可以充分利用 http2 提供的多路复用能力。 资源被拆分成多个并行请求肯定会比请求一个 bundle 来的快。

正基于上面这些原因,Vite 启动的项目在刚进入页面时会发送大量请求。但是它耗费的时候是完全可以接受的(会比 webpack 打包快)。而且由于缓存的原因,当修改代码时,只会请求修改部分的代码(发送请求会附上一个t=timestamp的参数)。

React下的可行性

已经说了这么多,是不是很想在React中也尝试Vite呢? 由于社区的贡献,Vite 已经支持 react 开发了。你可以使用 npm init vite-app --template react 尝试使用。

react

Q&A

最后大家提出的问题做一些总结

Vite(esm 的构建方式) 有什么缺点吗?

  1. 目前 Vite 还是使用的 es module 模块不能直接使用生产环境(兼容性问题)。
  2. 生产环境使用 rollup 打包会造成开发环境与生产环境的不一致。
  3. 很多 第三方 sdk 没有产出 ems 格式的的代码,这个需要自己去做一些兼容。

能在生产环境中直接使用 esm 吗?

其实目前的主要问题可能还是兼容性问题。如果你的项目不需要兼容 IE11 等低版本的浏览器,自然是可以使用的。但是更通用的方案可能还是类似 ployfill.io 的原理实现, 提前构建好 bundle.js 与 es module 两个版本的代码,根据浏览器的实际兼容性去动态选择导入哪个模块。

对于一些 没有产出 commonjs 的模块,如何去兼容呢。

首先业界是有一些如 lebab 的方法可以将 commjs 代码快速转化为 esm 的,但是对于一些格式不规范的代码,可能还是需要单独处理。

会不会有 类似与 @types 这种社区帮助去专门给一些 sdk 提供一些 es 的包呢?

可能更多还是需要社区的一些帮助。

参考文章