Vite原理分析

全栈大佬的修炼之路

共 11424字,需浏览 23分钟

 · 2021-01-30


本文由字节跳动折曜原创,授权前端铁蛋公众号发表,原文链接:https://juejin.im/post/6881078539756503047



Vite是什么


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

Vite(读音)[法语],法语,快的意思是一个由原生ES模块驱动的Web开发工具。在开发环境下基于浏览器原生ES导入开发,在生产环境下进行汇总打包。


Vite的特点


  • 闪电般快速的冷服务器启动-闪电般的冷启动速度

  • 即时热模块更换(HMR)-即时热模块更换(热更新)

  • 真正的按需编译-真正的按需编译

为了实现上述特点,Vite要求项目完全由ES模块模块组成,common.js模块不能直接在Vite上使用。因此不能直接在生产环境中使用。在打包上依旧还是使用rollup等传统打包工具。因此Vite目前更像是一个webpack-dev-server的开发工具。


ES模块


ES Module的更多介绍以vite自带的demo为示例。

<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>

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



值得一提的是我们平时在Webpack中写的mjs格式的代码最终被Webpack打包成cjs。最终在浏览器上还是以cjs的形式运行的。所以并不是真正的mjs。


浏览器兼容性


Vite采用了ES模块来实现模块的加载。目前基于web标准的ES模块已经覆盖了超过90%的浏览器。




Webpack&Vite原理对比


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



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


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

不可避免的,当我们修改模块中的一个子模块b.js,整个bundle.js都需要重新打包,随着项目规模的扩大,重新打包(热更新)的时间越来越长。我们常用如thread-loadercache-loader代码分片等方法进行优化。但通过项目规模的进一步扩大,热更新速度又将变慢,又将开始新一轮的优化。通过项目规模的不断扩大,基于bunder的项目优化也将达到一定的极限。



Webpack之所以慢,是因为Webpack会使多个资源构成一个或多个捆绑。如果我们跳过打包的过程,当需要某个模块时再通过请求去获取是不是能完美解决这个问题呢?

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



Vite实现

请求拦截原理


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

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


而快的首要是他使用了go作为另一种语言(go这样的静态语言会比动态语言快很多)。

node_modules模块的处理


首先说一下基于ES模块模块的局限性,在我们平时写代码时。怎么不是相对路径的引用,又直接引用一个node_modules模块时,我们都是以如下的格式进行引用。

import vue from 'vue'

如Webpack&gulp等打包工具会帮我们找到模块的路径。但浏览器只能通过相对路径去寻找。为了解决这个问题,Vite采取了一些特殊处理。以Vite官方演示如何,当我们请求时本地主机:3000

Vite先返回index.html代码,渲染index.html后发送请求src / main.js。main.js代码如下。


import { createApp } from 'vue'import App from './App.vue'import './index.css'createApp(App).mount('#app')

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


  • 如何将代码中的/:id转换为/ @ modules /:id

Vite对ES模块进行形式化的js文件模块的处理使用了。Lexer会返回js文件中引入的模块并以交换形式返回。Vite通过该变量判断是否为一个node_modules模块。。

// 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    const importer = removeUnRelatedHmrQuery(      resolver.normalizePublicPath(ctx.url)    )    ctx.body = rewriteImports(      root,      content!,      importer,      resolver,      ctx.query.t    )  }})

我们还能有另一个形式进行一个ES模块形式的引入,那就是直接使用脚本标签,对于脚本标签引入的模块也会有对应的处理。

 
 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文件下找到对应模块

浏览器发送路径为/ @ modules /:id的对应请求后。会被Vite客户端做一层拦截,最终找到对应的模块代码进行返回。

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) => {      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)    if (nodeModulePath) {      return serve(id, nodeModulePath, 'node_modules')    }})

  • .vue文件的处理

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


如上图App.vue获取脚本,App.vue?type = 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模块模块返回。

// src/node/utils/pathUtils.tsconst imageRE = /\.(png|jpe?g|gif|svg|ico|webp)(\?.*)?$/const mediaRE = /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/const fontsRE = /\.(woff2?|eot|ttf|otf)(\?.*)?$/iexport const isStaticAsset = (file: string) => {  return imageRE.test(file) || mediaRE.test(file) || fontsRE.test(file)}
// src/node/server/serverPluginAssets.tsapp.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 Reload)原理


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


服务端原理


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


客户端原理


Vite的websocket相关代码在处理html中时被编写代码中。

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服务并与服务端建立了连接。Vite会接受到来自客户端的消息。通过不同的消息触发一些事件。做到浏览器端的即时热模块更换(热更新)。

// Listen for messagessocket.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      }      // imported CSS      const importQuery = path.includes('?') ? '&import' : '?import'      await import(`${path}${importQuery}&t=${timestamp}`)      console.log(`[vite] ${path} updated.`)      break    case 'style-remove':      removeStyle(payload.id)      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模块,在使用某些模块时。由于模块依赖了另一些模块,依赖的模块又基于另一些模块。会出现页面初始化时一次发送多个模块请求的情况。此处以lodash-es实际上,一共发送了651个请求。一共花费1.53s。



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



优化原理性webpack的dll插件,提前将package.json中依赖项打包成一个esmodule模块。这样在页面初始化时能减少大量请求。



优化后仅发送了14个请求



顺便提一嘴,有的人肯定会问:如果我的组件封装很深,一个组件import了十个组件,十个组件又import了十个组件怎么处理。这是粗略的提一下我的想法:


  1. 首先可以看到请求lodash时651个请求只耗时1.53s。这个耗时是完全可以接受的。

  2. Vite是完全按需加载的,在页面初始化时只会请求初始化页面的一些组件。(使用一些如dynamic import的优化)

  3. ES模块是有一些优化的,浏览器会给请求的模块做一次缓存。当请求路径完全相同时,浏览器会使用浏览器缓存的代码。关于ES模块的更多信息可以看https://segmentfault.com/a/1190000014318751

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


Vite vs Webpack


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


冷启动速度对比


从左到右依次是:vue-cli3 + vue3的演示,vite 1.0.0-rc + vue 3的演示,vue-cli3 + vue2的演示。-cli3启动Vue2大概需要5s左右,vue-cli3启动Vue3需要4s左右,而vite只需要1s左右的时间。从理论上讲Vite是ES模块实现的。增加。而Webpack随着代码体积的增加启动时间是要明显增加的。


热更新速度对比


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


React下的可行性


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



往期推荐



围观

你不知道的 JSON.stringify() 的威力

热文

面试官连环追问:数组拍平(扁平化) flat 方法实现

热文

过年回家,还怕抢不到票?程序员教你如何抢票

热文

一名【合格】前端工程师的自检清单

热文

窥探原理:手写一个 JavaScript 打包器


浏览 19
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报