本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!open in new window

> 这是第 105 篇不掺水的原创,想获取更多原创好文,请搜索公众号关注我们吧~ 本文首发于政采云前端博客:Vite 特性和部分源码解析open in new window

清音.png

Vite 的特性

Vite 的主要特性就是 Bundleless。基于浏览器开始原生的支持 JavaScript 模块功能open in new window,JavaScript 模块依赖于 importexport 的特性,目前主流浏览器基本都支持;

想要查看具体支持的版本可以点击这里open in new window

那这有什么优势呢?

去掉打包步骤

打包是开发者利用打包工具将应用各个模块集合在一起形成 bundle,以一定规则读取模块的代码,以便在不支持模块化的浏览器里使用,并且可以减少 http 请求的数量。但其实在本地开发过程中打包反而增加了我们排查问题的难度,增加了响应时长,Vite 在本地开发命令中去除了打包步骤,从而缩短构建时长。

按需加载

为了减少 bundle 大小,一般会想要按需加载,主要有两种方式:

  1. 使用动态引入 import() 的方式异步的加载模块,被引入模块依然需要提前编译打包;
  2. 使用 tree shaking 等方式尽力的去掉未引用的模块;

而 Vite 的方式更为直接,它只在某个模块被 import 的时候动态的加载它,实现了真正的按需加载,减少了加载文件的体积,缩短了时长;

Vite开发环境主体流程

下图是 Vite 在开发环境运行时加载文件的主体流程。

<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6922f9b694324cb193acdbb482babadb~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:60%;" />

Vite 部分源码解析

总体目录结构

|-CHANGELOG.md
|-LICENSE.md
|-README.md
|-bin
|  |-openChrome.applescript
|  |-vite.js
|-client.d.ts
|-package.json
|-rollup.config.js #打包配置文件
|-scripts
|  |-patchTypes.js
|-src
|  |-client #客户端
|  |  |-client.ts
|  |  |-env.ts
|  |  |-overlay.ts
|  |  |-tsconfig.json
|  |-node #服务端
|  |  |-build.ts
|  |  |-cli.ts #命令入口文件
|  |  |-config.ts
|  |  |-constants.ts #常量
|  |  |-importGlob.ts
|  |  |-index.ts
|  |  |-logger.ts
|  |  |-optimizer
|  |  |  |-esbuildDepPlugin.ts
|  |  |  |-index.ts
|  |  |  |-registerMissing.ts
|  |  |  |-scan.ts
|  |  |-plugin.ts #rollup 插件
|  |  |-plugins   #插件相关文件
|  |  |  |-asset.ts
|  |  |  |-clientInjections.ts
|  |  |  |-css.ts
|  |  |  |-esbuild.ts
|  |  |  |-html.ts
|  |  |  |-index.ts 
|  |  |  |-...
|  |  |-preview.ts
|  |  |-server
|  |  |  |-hmr.ts #热更新
|  |  |  |-http.ts
|  |  |  |-index.ts
|  |  |  |-middlewares #中间件
|  |  |  |  |-...
|  |  |  |-moduleGraph.ts #模块间关系组装(树形)
|  |  |  |-openBrowser.ts #打开浏览器
|  |  |  |-pluginContainer.ts
|  |  |  |-send.ts
|  |  |  |-sourcemap.ts
|  |  |  |-transformRequest.ts
|  |  |  |-ws.ts
|  |  |-ssr
|  |  |  |-__tests__
|  |  |  |  |-ssrTransform.spec.ts
|  |  |  |-ssrExternal.ts
|  |  |  |-ssrManifestPlugin.ts
|  |  |  |-ssrModuleLoader.ts
|  |  |  |-ssrStacktrace.ts
|  |  |  |-ssrTransform.ts
|  |  |-tsconfig.json
|  |  |-utils.ts
|-tsconfig.base.json
|-types
|  |-...                  

server 核心方法

从入口文件 cli.ts,可以看到三个命令对应了 3 个核心的文件&方法;

  1. dev 命令

文件路径:./server/index.ts;

主要方法:createServer;

主要功能:项目的本地开发命令,基于 httpServer 启动服务,Vite 通过对请求路径的劫持获取资源的内容返回给浏览器,服务端将文件路径进行了重写。例如:

项目源码如下:

import { createApp } from &#39;vue&#39;;
import App from &#39;./index.vue&#39;;

经服务端重写后,node_modules 文件夹下的三方包代码路径也会被拼接完整。

import __vite__cjsImport0_vue from &quot;/node_modules/.vite/vue.js?v=ed69bae0&quot;; 
const createApp = __vite__cjsImport0_vue[&quot;createApp&quot;];
import App from &#39;/src/pages/back-sky/index.vue&#39;;

2.build 命令 文件路径:./build.ts ;

主要方法:build;

主要功能:使用 rollup 打包编译

3.optimize 命令

文件路径:./optimizer/index.ts;

主要方法:optimizeDeps;

主要功能:主要针对第三方包,Vite 在执行 runOptimize 的时候中会使用 rollup 对三方包重新编译,将编译成符合 esm 模块规范的新的包放入 node_modules 下的 .vite 中,然后配合 resolver 对三方包的导入进行处理:使用编译后的包内容代替原来包的内容,这样就解决了 Vite 中不能使用 cjs 包的问题。

下面是 .vite 文件夹中的 _metadata.json 文件,它在预编译的过程中生成,罗列了所有被预编译完成的文件及其路径。例如:

{
  &quot;hash&quot;: &quot;31d458ff&quot;,
  &quot;browserHash&quot;: &quot;ed69bae0&quot;,
  &quot;optimized&quot;: {
    &quot;element-plus/lib/utils/dom&quot;: {
      &quot;file&quot;: &quot;/Users/zcy/Documents/workspace/back-sky-front/node_modules/.vite/element-plus_lib_utils_dom.js&quot;,
      &quot;src&quot;: &quot;/Users/zcy/Documents/workspace/back-sky-front/node_modules/element-plus/lib/utils/dom.js&quot;,
      &quot;needsInterop&quot;: true
    },
    &quot;element-plus&quot;: {
      &quot;file&quot;: &quot;/Users/zcy/Documents/workspace/back-sky-front/node_modules/.vite/element-plus.js&quot;,
      &quot;src&quot;: &quot;/Users/zcy/Documents/workspace/back-sky-front/node_modules/element-plus/lib/index.esm.js&quot;,
      &quot;needsInterop&quot;: false
    },
    &quot;vue&quot;: {
      &quot;file&quot;: &quot;/Users/zcy/Documents/workspace/back-sky-front/node_modules/.vite/vue.js&quot;,
      &quot;src&quot;: &quot;/Users/zcy/Documents/workspace/back-sky-front/node_modules/vue/dist/vue.runtime.esm-bundler.js&quot;,
      &quot;needsInterop&quot;: true
    },
    ......
    }
  }
}

模块解析

预构建open in new window是用来提升页面重载速度,它将 CommonJS、UMD 等转换为 ESM 格式。预构建这一步由 esbuildopen in new window 执行,这使得 Vite 的冷启动时间比任何基于 JavaScript 的打包程序都要快得多。

为什么 ESbuild 会更快?

  1. 使用 Go 语言
  2. 重度并行,使用 CPU
  3. 高效使用内存
  4. Scratch 编写,减少使用三方库,避免导致性能不可控

重写导入为合法的 URL,例如 /node_modules/.vite/my-dep.js?v=f3sf2ebd 以便浏览器能够正确导入它们

热更新

热更新主体流程如下:

  1. 服务端基于 watcher 监听文件改动,根据类型判断更新方式,并编译资源
  2. 客户端通过 WebSocket 监听到一些更新的消息类型
  3. 客户端收到资源信息,根据消息类型执行热更新逻辑

下面是服务端热更新的核心 hmr.ts 中的部分判断逻辑;

如果配置文件或者环境文件发生修改时,会触发服务重启,才能让配置生效。

if (file === config.configFile || file.endsWith(&#39;.env&#39;)) {
  // auto restart server 配置&amp;环境文件修改则自动重启服务
  debugHmr(`[config change] ${chalk.dim(shortFile)}`)
  config.logger.info(
    chalk.green(&#39;config or .env file changed, restarting server...&#39;),
    { clear: true, timestamp: true }
  )
  await restartServer(server)
  return
}

html 文件更新时,将会触发页面的重新加载。

if (file.endsWith(&#39;.html&#39;)) { // html 文件更新
  config.logger.info(chalk.green(`page reload `) +         chalk.dim(shortFile), {
    clear: true,
    timestamp: true
  })
  ws.send({
    type: &#39;full-reload&#39;,
    path: config.server.middlewareMode
    ? &#39;*&#39;
    : &#39;/&#39; + normalizePath(path.relative(config.root, file))
  })
} else {
  // loaded but not in the module graph, probably not js
  debugHmr(`[no modules matched] ${chalk.dim(shortFile)}`)
}

Vue 等文件更新时,都会进入 updateModules 方法,正常情况下只会触发 update,实现热更新,热替换;

function updateModules(
  file: string,
  modules: ModuleNode[],
  timestamp: number,
  { config, ws }: ViteDevServer
) {
  const updates: Update[] = []
  const invalidatedModules = new Set&lt;ModuleNode&gt;()
	// 遍历插件数组,关联下面的片段
  for (const mod of modules) {
    const boundaries = new Set&lt;{
      boundary: ModuleNode
      acceptedVia: ModuleNode
    }&gt;()
    // 设置时间戳
    invalidate(mod, timestamp, invalidatedModules)
    // 查找引用模块,判断是否需要重载页面
    const hasDeadEnd = propagateUpdate(mod, timestamp, boundaries)
    // 找不到引用者则会发起刷新
    if (hasDeadEnd) {
      config.logger.info(chalk.green(`page reload `) + chalk.dim(file), {
        clear: true,
        timestamp: true
      })
      ws.send({
        type: &#39;full-reload&#39;
      })
      return
    }
    updates.push(
      ...[...boundaries].map(({ boundary, acceptedVia }) =&gt; ({
        type: `${boundary.type}-update` as Update[&#39;type&#39;],
        timestamp,
        path: boundary.url,
        acceptedPath: acceptedVia.url
      }))
    )
  }
  // 日志输出
  config.logger.info(
    updates
      .map(({ path }) =&gt; chalk.green(`hmr update `) + chalk.dim(path))
      .join(&#39;\n&#39;),
    { clear: true, timestamp: true }
  )
  // 向客户端发送消息,进行热更新操作
  ws.send({
    type: &#39;update&#39;,
    updates
  })
}

上面代码中的 modules 是热更新时需要执行的各个插件

for (const plugin of config.plugins) {
  if (plugin.handleHotUpdate) {
    const filteredModules = await plugin.handleHotUpdate(hmrContext)
    if (filteredModules) {
      hmrContext.modules = filteredModules
    }
  }
}

Vite 会把模块的依赖关系组合成 moduleGraph,它的结构类似树形,热更新中判断哪些文件需要更新也会依赖 moduleGraph;它的文件内容大致如下:

// moduleGraph 返回的 ModuleNode 大致结构
 ModuleNode {
  id: &#39;/Users/zcy/Documents/workspace/back-sky-front/src/pages/back-sky/index.js&#39;,
  file: &#39;/Users/zcy/Documents/workspace/back-sky-front/src/pages/back-sky/index.js&#39;,
  importers: Set {},
  importedModules: Set {
    ModuleNode {
      id: &#39;/Users/zcy/Documents/workspace/back-sky-front/node_modules/.vite/vue.js?v=32cfd30c&#39;,
      file: &#39;/Users/zcy/Documents/workspace/back-sky-front/node_modules/.vite/vue.js&#39;,
      ......
      lastHMRTimestamp: 0,
      url: &#39;/node_modules/.vite/vue.js?v=32cfd30c&#39;,
      type: &#39;js&#39;
    },
    ModuleNode {
      id: &#39;/Users/zcy/Documents/workspace/back-sky-front/src/pages/back-sky/index.vue&#39;,
      file: &#39;/Users/zcy/Documents/workspace/back-sky-front/src/pages/back-sky/index.vue&#39;,
      ......
      url: &#39;/src/pages/back-sky/index.vue&#39;,
      type: &#39;js&#39;
    },
    ModuleNode {
      id: &#39;/Users/zcy/Documents/workspace/back-sky-front/node_modules/element-plus/lib/theme-chalk/index.css&#39;,
      file: &#39;/Users/zcy/Documents/workspace/back-sky-front/node_modules/element-plus/lib/theme-chalk/index.css&#39;,
      importers: [Set],
      importedModules: Set {},
      acceptedHmrDeps: Set {},
      isSelfAccepting: true,
      transformResult: [Object],
      ssrTransformResult: null,
      ssrModule: null,
      lastHMRTimestamp: 0,
      url: &#39;/node_modules/element-plus/lib/theme-chalk/index.css&#39;,
      type: &#39;js&#39;
    },
    ......
  },
  acceptedHmrDeps: Set {},
  isSelfAccepting: false,
  transformResult: {
    code: &#39;import __vite__cjsImport0_vue from &#39; +
      &#39;&quot;/node_modules/.vite/vue.js?v=32cfd30c&quot;; const createApp = &#39; +
      &#39;__vite__cjsImport0_vue[&quot;createApp&quot;];\nimport App from &#39; +
      &quot;&#39;/src/pages/back-sky/index.vue&#39;;\nimport &quot; +
      &quot;&#39;/node_modules/element-plus/lib/theme-chalk/index.css&#39;;\n\nconst app = &quot; +
      &#39;createApp(App);\n\nimport { addHistoryMethod } from &#39; +
      &quot;&#39;/src/pages/back-sky/api/index.js&#39;;\nimport {\n  ElButton,\n  ElDropdown,\n  &quot; +
      &#39;ElDropdownMenu,\n  ElDropdownItem,\n  ElMenu,\n  ElSubmenu,\n  ElMenuItem,\n  &#39; +
      &#39;ElMenuItemGroup,\n  ElPopover,\n  ElDialog,\n  ElRow,\n  ElInput,\n  &#39; +
      &quot;ElLoading,\n} from &#39;/node_modules/.vite/element-plus.js?v=32cfd30c&#39;;\n\n&quot; +
      &#39;app.use(ElButton);\napp.use(ElLoading);\napp.use(ElDropdown);\n&#39; +
      &#39;app.use(ElDropdownMenu);\napp.use(ElDropdownItem);\napp.use(ElMenu);\n&#39; +
      &#39;app.use(ElSubmenu);\napp.use(ElMenuItem);\napp.use(ElMenuItemGroup);\n&#39; +
      &#39;app.use(ElPopover);\napp.use(ElDialog);\napp.use(ElRow);\napp.use(ElInput);\n&#39; +
      &quot;\nconst f = ()=&gt;{\n  return app.mount(&#39;#app&#39;);\n};\n\nconst $backsky = &quot; +
      &quot;document.getElementById(&#39;back-sky&#39;);\nif($backsky) {\n  $backsky.innerHTML &quot; +
      &quot;= &#39;&#39;;\n  $backsky.appendChild(f().$el);\n} else {\n  window.onload = &quot; +
      &quot;function(){\n    document.getElementById(&#39;back-sky&#39;) &amp;&amp; &quot; +
      &quot;document.getElementById(&#39;back-sky&#39;).appendChild(f().$el);\n  };\n}\n\n&quot; +
      &quot;window.addHistoryListener = addHistoryMethod(&#39;historychange&#39;);\n&quot; +
      &quot;history.pushState =  addHistoryMethod(&#39;pushState&#39;);\nhistory.replaceState &quot; +
      &quot;=  addHistoryMethod(&#39;replaceState&#39;);\n\n// 监听hash路由变化,不与onhashchange互相覆盖\n&quot; +
      &#39;addHashChange(()=&gt;{\n  setTimeout(() =&gt; {\n    const $backsky = &#39; +
      &quot;document.getElementById(&#39;back-sky&#39;);\n    if($backsky &amp;&amp; &quot; +
      &quot;$backsky.innerHTML === &#39;&#39;) {\n      $backsky.appendChild(f().$el);\n    }\n &quot; +
      &quot; },0);\n});\n\nfunction addHashChange(callback) {\n  if(&#39;onhashchange&#39; in &quot; +
      &#39;window === false){//浏览器不支持\n    return false;\n  }\n  &#39; +
      &#39;if(window.addEventListener) {\n    &#39; +
      &quot;window.addEventListener(&#39;hashchange&#39;,function(e) {\n      callback &amp;&amp; &quot; +
      &#39;callback(e);\n    },false);\n  }else if(window.attachEvent) {//IE 8 及更早 IE &#39; +
      &quot;版本浏览器\n    window.attachEvent(&#39;onhashchange&#39;,function(e) {\n      callback &quot; +
      &#39;&amp;&amp; callback(e);\n    });\n  }\n  &#39; +
      &quot;window.addHistoryListener(&#39;history&#39;,function(e){\n    callback &amp;&amp; &quot; +
      &#39;callback(e);\n  });\n}\n\n\n&#39;,
    map: null,
    etag: &#39;W/&quot;846-Qa424gJKl3YCqHDWXXsM1mFHRqg&quot;&#39;
  },
  ssrTransformResult: null,
  ssrModule: null,
  lastHMRTimestamp: 0,
  url: &#39;/src/pages/back-sky/index.js&#39;,
  type: &#39;js&#39;
}

原有项目切换

最后我们来看下如何使用 Vite 去打包一个旧的 Vue 项目;

首先我们需要升级 Vue3

npm install vue@next

并为项目添加 vite 配置文件,在根目录下创建 vite.config.js,并为它添加一些基础的配置。

// vite.config.js
// vite2.1.5
const path = require(&#39;path&#39;);
import vue from &#39;@vitejs/plugin-vue&#39;;

export default {
  // 配置选项
  resolve: {
    alias: {
      &#39;@utils&#39;: path.resolve(__dirname, &#39;./src/utils&#39;)
    },
  },
  plugins: [vue()],
};

引用的第三方组件库可能也会需要升级,例如:升 element-ui 至 element-plus

npm install element-plus

Vue3 在 import 时,需使用 createApp 方法进行初始化

import { createApp } from &#39;vue&#39;;
import App from &#39;./index.vue&#39;;
const app = createApp(App);
import {
  ElInput,
  ElLoading,
} from &#39;element-plus&#39;;

app.use(ElButton);
app.use(ElLoading);
......

到这里就可以将项目运行起来了。 注意:Vite 官方不允许省略 .vue 后缀,否则就会报错;

[plugin:vite:import-analysis] Failed to resolve import &quot;./todoList&quot; from &quot;src/pages/back-sky/components/header/index.vue&quot;. Does the file exist?
/components/header/index.vue:2:23
1  |  
2  |  import todoList from &#39;./todoList&#39;;
import todoList from &#39;./todoList.vue&#39;;

最后我们来对比一下该项目两种构建方式时间的对比;

Webpack 冷启动,耗时 7513ms:

⚠ 「wdm」: Hash: 1ad1dd54289cfad8ecbe
Version: webpack 4.46.0
Time: 7513ms
Built at: 2021-05-24 13:59:35

相同项目 Vite 冷启动,耗时 924ms:

&gt; vite
Pre-bundling dependencies:
  vue
  element-plus
  @zcy/zcy-request
  element-plus/lib/utils/dom
(this will be run only when your dependencies or config have changed)
  vite v2.3.3 dev server running at:
  &gt; Local: http://localhost:3000/
  &gt; Network: use `--host` to expose
  ready in 924ms.

二次启动(预编译的依赖已存在),耗时 407ms;

&gt; vite
  vite v2.3.3 dev server running at:
  &gt; Local: http://localhost:3000/
  &gt; Network: use `--host` to expose
  ready in 407ms.

总结

使用 Vite 进行本地服务启动和热更新都会有明显的提效,至于编译打包环节的差异点有哪些?效果如何?你们还踩过哪些坑?留言告诉我吧。

推荐阅读:

What are CJS, AMD, UMD, and ESM in Javascript?open in new window

推荐阅读

我在工作中是如何使用 git 的open in new window

15 分钟学会 Immutableopen in new window

开源作品

  • 政采云前端小报

开源地址 www.zoo.team/openweekly/open in new window (小报官网首页有微信交流群)

招贤纳士

政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 40 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端应用、数据分析及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。

如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变既定的节奏,将会是“5 年工作时间 3 年工作经验”;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com