【工程化】面向未来的前端构建工具 - Vite 原理分析

前端杂货铺

共 8274字,需浏览 17分钟

 ·

2021-05-09 02:46


1 Vite: 一种新的、更快的 web 开发工具。

2 特点:

  1. 快速的冷启动

  2. 即时的模块热更新(保留在完全重新加载页面时丢失的应用程序状态、只更新变更内容、调整样式更加快速)

  3. 真正的按需编译

3 构建项目:

$ yarn create vite-app <project-name>
$ cd <project-name>
$ yarn
$ yarn dev

4 实现机制:

vite 使用 koa[1] 作 web server,使用 clmloader 创建了一个监听文件改动的 watcher,同时实现了一个插件机制,将 koa-app 和 watcher 以及其他必要工具组合成一个 context 对象注入到每个 plugin 中。

context组成结构:

plugin 依次从 context 里获取上面这些组成部分,有的 plugin 在 koa 实例添加了几个 middleware,有的借助 watcher 实现对文件的改动监听,这种插件机制带来的好处是整个应用结构清晰,同时每个插件处理不同的事情,职责分配更清晰。

5 plugin:

  • 用户注入的 plugins —— 自定义 plugin

  • hmrPlugin —— 处理 hmr

  • htmlRewritePlugin —— 重写 html 内的 script 内容

  • moduleRewritePlugin —— 重写模块中的 import 导入

  • moduleResolvePlugin ——获取模块内容

  • vuePlugin —— 处理 vue 单文件组件

  • esbuildPlugin —— 使用 esbuild 处理资源

  • assetPathPlugin —— 处理静态资源

  • serveStaticPlugin —— 托管静态资源

  • cssPlugin —— 处理 css/less/sass 等引用

  • ...

一个用来拦截 json 文件 plugin简单实现:

interface ServerPluginContext { 
  root: string 
  app: Koa 
  server: Server 
  watcher: HMRWatcher 
  resolver: InternalResolver 
  config: ServerConfig 


type ServerPlugin = (ctx:ServerPluginContext)=> void

const JsonInterceptPlugin:ServerPlugin = ({app})=>
  app.use(async (ctx, next) => { 
    await next() 
    if (ctx.path.endsWith('.json') && ctx.body) { 
      ctx.type = 'js' 
      ctx.body = `export default json` 
    } 
  }) 
}

6 运行依赖原理

Vite 通过在一开始将应用中的模块区分为 依赖源码 两类,改进了开发服务器启动时间。

  • 依赖 大多为纯 JavaScript 并在开发时不会变动。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。依赖也通常会以某些方式(例如 ESM 或者 CommonJS)被拆分到大量小模块中。Vite 将会使用 esbuild[2]预构建依赖[3]。Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。

  • 源码 通常包含一些并非直接是 JavaScript 的文件,需要转换(例如 JSX,CSS 或者 Vue/Svelte 组件),时常会被编辑。同时,并不是所有的源码都需要同时被加载。(例如基于路由拆分的代码模块)。Vite 以 原生 ESM[4] 方式服务源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入的代码,即只在当前屏幕上实际使用时才会被处理。

6.1 依赖 ES module

要了解 vite 的运行原理,首先要知道什么是ES module,参考:JavaScript modules 模块 - MDN[5]

目前流览器对其的支持如下:主流的浏览器(IE11除外)均已经支持

其最大的特点是在浏览器端使用 export import的方式导入和导出模块,在 script 标签里设置 type="module" ,然后使用模块内容。

6.2 示例:

 // **module sciprt**允许在浏览器中直接运行原生支持模块
<script type="module">
  // index.js可以通过export导出模块,也可以在其中继续使用import加载其他依赖 
  // 当遇见import依赖时,会直接发起http请求对应的模块文件。
  import { fn } from  ./index.js;
  fn();
</script>

当 html 里嵌入上面的 script 标签时候,浏览器会发起 http 请求,请求 htttp server 托管 index.js ,在 index.js 里,我们用 export 导出 fn 函数,在上面的 script 中能获取到 fn 的定义。

export function fn(
    alert('hello world');
};

7 在 vite 中的作用

打开运行中的 vite 项目,访问 view-source 可以发现 html 里有段这样的代码:

<script type="module">
  import { createApp } from '/@modules/vue'
  import App from '/App.vue'
  createApp(App).mount('#app')
</script>

从这段代码中,我们能 get 到以下几点信息:

  • 从 http://localhost:3000/@modules/vue 中获取 createApp 这个方法

  • 从 http://localhost:3000/App.vue 中获取应用入口

  • 使用 createApp 创建应用并挂载节点

createApp 是 vue3.X 的 api,只需知道这是创建了 vue 应用即可,vite 利用 ES module,把 “构建 vue 应用” 这个本来需要通过 webpack 打包后才能执行的代码直接放在浏览器里执行,这么做是为了

  1. 去掉打包步骤

  2. 实现按需加载

7.1 去掉打包步骤

打包的概念是开发者利用打包工具将应用各个模块集合在一起形成 bundle,以一定规则读取模块的代码——以便在不支持模块化的浏览器里使用。

为了在浏览器里加载各模块,打包工具会借助胶水代码用来组装各模块,比如 webpack 使用 map 存放模块 id 和路径,使用 __webpack_require__ 方法获取模块导出。

vite 利用浏览器原生支持模块化导入这一特性,省略了对模块的组装,也就不需要生成 bundle,所以打包这一步就可以省略了。

7.2 实现按需打包

前面说到,webpack 之类的打包工具会将各模块提前打包进 bundle 里,但打包的过程是静态的——不管某个模块的代码是否执行到,这个模块都要打包到 bundle 里,这样的坏处就是随着项目越来越大打包后的 bundle 也越来越大。

开发者为了减少 bundle 大小,会使用动态引入 import() 的方式异步的加载模块( 被引入模块依然需要提前打包),又或者使用 tree shaking 等方式尽力的去掉未引用的模块,然而这些方式都不如 vite 的优雅,vite 可以只在需要某个模块的时候动态(借助 import() )的引入它,而不需要提前打包。

8 vite 如何处理 ESM

既然 vite 使用 ESM 在浏览器里使用模块,那么这一步究竟是怎么做的?

上文提到过,在浏览器里使用 ES module 是使用 http 请求拿到模块,所以 vite 必须提供一个 web server 去代理这些模块,上文中提到的 koa 就是负责这个事情,vite 通过对请求路径的劫持获取资源的内容返回给浏览器,不过 vite 对于模块导入做了特殊处理。

8.1 @modules 是什么?

通过工程下的 index.html 和开发环境下的 html 源文件对比,发现 script 标签里的内容发生了改变,由

<script type="module">
  import { createApp } from 'vue'
  import App from '/App.vue'
  createApp(App).mount('#app')
</script>

变成了

<script type="module">
  import { createApp } from '/@modules/vue'
  import App from '/App.vue'
  createApp(App).mount('#app')
</script>
  1. 在 koa 中间件里获取请求 body

  2. 通过 es-module-lexer 解析资源 ast 拿到 import 的内容

  3. 判断 import 的资源是否是绝对路径,绝对视为 npm 模块

  4. 返回处理后的资源路径:"vue" => "/@modules/vue"

这部分代码在 serverPluginModuleRewrite 这个 plugin 中,

8.2 为什么需要 @modules?

如果我们在模块里写下以下代码的时候,浏览器中的 esm 是不可能获取到导入的模块内容的:

import vue from 'vue'

因为 vue 这个模块安装在 node_modules 里,以往使用 webpack,webpack遇到上面的代码,会帮我们做以下几件事:

  • 获取这段代码的内容

  • 解析成 AST

  • 遍历 AST 拿到 import 语句中的包的名称

  • 使用 enhanced-resolve 拿到包的实际地址进行打包,

但是浏览器中 ESM 无法直接访问项目下的 node_modules,所以 vite 对所有 import 都做了处理,用带有 @modules 的前缀重写它们。

从另外一个角度来看这是非常比较巧妙的做法,把文件路径的 rewrite 都写在同一个 plugin 里,这样后续如果加入更多逻辑,改动起来不会影响其他 plugin,其他 plugin 拿到资源路径都是 @modules,比如说后续可能加入 alias 的配置:就像 webpack alias 一样:可以将项目里的本地文件配置成绝对路径的引用。

8.3 怎么返回模块内容

在下一个 koa middleware 中,用正则匹配到路径上带有 @modules 的资源,再通过 require('xxx') 拿到 包的导出返回给浏览器。

以往使用 webpack 之类的打包工具,它们除了将模块组装到一起形成 bundle,还可以让使用了不同模块规范的包互相引用,比如:

  • ES module (esm) 导入 cjs

  • CommonJS (cjs) 导入 esm

  • dynamic import 导入 esm

  • dynamic import 导入 cjs

关于 es module 的坑可以看这篇文章(https://zhuanlan.zhihu.com/p/40733281[6])。

起初在 vite 还只是为 vue3.x 设计的时候,对 vue esm 包是经过特殊处理的,比如:需要 @vue/runtime-dom 这个包的内容,不能直接通过 require('@vue/runtime-dom')得到,而需要通过 require('@vue/runtime-dom/dist/runtime-dom.esm-bundler.js') 的方式,这样可以使得 vite 拿到符合 esm 模块标准的 vue 包。

目前社区中大部分模块都没有设置默认导出 esm,而是导出了 cjs 的包,既然 vue3.0 需要额外处理才能拿到 esm 的包内容,那么其他日常使用的 npm 包是不是也同样需要支持?答案是肯定的,目前在 vite 项目里直接使用 lodash 还是会报错的。

不过 vite 在最近的更新中,加入了optimize命令,这个命令专门为解决模块引用的坑而开发,例如我们要在 vite 中使用 lodash,只需要在vite.config.js(vite 配置文件)中,配置optimizeDeps对象,在include数组中添加 lodash。

// vite.config.js
module.exports = {
  optimizeDeps: {
    include: ["lodash"]
  }
}

这样 vite 在执行 runOptimize 的时候中会使用 rollup 对 lodash 包重新编译,将编译成符合 esm 模块规范的新的包放入 node_modules 下的 .vite_opt_cache 中,然后配合 resolver 对 lodash 的导入进行处理:使用编译后的包内容代替原来 lodash 的包的内容,这样就解决了 vite 中不能使用 cjs 包的问题,这部分代码在 depOptimizer.ts 里。

不过这里还有个问题,由于在 depOptimizer.ts 中,vite 只会处理在项目下 package.json 里的 dependencies 里声明好的包进行处理,所以无法在项目里使用

import pick from 'lodash/pick'

的方式单使用 pick 方法,而要使用

import lodash from 'lodash'

lodash.pick()

的方式,这可能在生产环境下使用某些包的时候对 bundle 的体积有影响。

返回模块的内容的代码在:serverPluginModuleResolve.ts 这个 plugin 中。

9 vite 热更新的实现

vite/hmr 是 vite 处理热更新的关键,在 serverPluginHmr plugin 中,对于 path 等于 vite/hmr 做了一次判断:

app.use(async (ctx, next) => {
  if (ctx.path === '/vite/hmr') {
      ctx.type = 'js'
      ctx.status = 200
      ctx.body = hmrClient
  }
 }

hmrClient 是 vite 热更新的客户端代码,需要在浏览器里执行,这里先来说说通用的热更新实现,热更新一般需要四个部分:

  • 首先需要 web 框架支持模块的 rerender/reload

  • 通过 watcher 监听文件改动

  • 通过 server 端编译资源,并推送新模块内容给 client 。

  • client 收到新的模块内容,执行 rerender/reload

vite 也不例外同样有这四个部分,其中客户端代码在:client.ts 里,服务端代码在 serverPluginHmr 里,对于 vue 组件的更新,通过 vue3.x 中的 HMRRuntime 处理的。

10 尤雨溪发言

Vite,一个基于浏览器原生 ES imports 的开发服务器。利用浏览器去解析 imports,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随起随用。同时不仅有 Vue 文件支持,还搞定了热更新,而且热更新的速度不会随着模块增多而变慢。针对生产环境则可以把同一份代码用 rollup 打。虽然现在还比较粗糙,但这个方向我觉得是有潜力的,做得好可以彻底解决改一行代码等半天热更新的问题。

参考资料

[1]

koa: https://www.npmjs.com/package/koa

[2]

esbuild: https://esbuild.github.io/

[3]

预构建依赖: https://cn.vitejs.dev/guide/dep-pre-bundling.html

[4]

原生 ESM: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules

[5]

JavaScript modules 模块 - MDN: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules

[6]

https://zhuanlan.zhihu.com/p/40733281: https://zhuanlan.zhihu.com/p/40733281


浏览 70
点赞
评论
收藏
分享

手机扫一扫分享

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

手机扫一扫分享

分享
举报