阅读 154

Web 开发构建工具——Vite 及相关思考

vite 是什么

vite —— 一个由 vue 作者尤雨溪开发的 web 开发工具,它具有以下特点:

  1. 快速的冷启动
  2. 即时的模块热更新
  3. 真正的按需编译

从作者在微博上的发言:

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

中可以看出 vite 主要特点是基于浏览器 native 的 ES module (developer.mozilla.org/en-US/docs/…) 来开发,省略打包这个步骤,因为需要什么资源直接在浏览器里引入即可。

基于浏览器 ES module 来开发 web 应用也不是什么新鲜事,snowpack 也基于此,不过目前此项目社区中并没有流行起来,vite 的出现也许会让这种开发方式再火一阵子。

有趣的是 vite 算是革了 webpack 的命了(生产环境用 rollup),所以 webpack 的开发者直接喊大哥了...

(作者注:本文完成于 vite 早期时候,vite 部分功能和原理有更新。)

\

vite 的使用方式

同常见的开发工具一样,vite 提供了用 npm 或者 yarn 一建生成项目结构的方式,使用 yarn 在终端执行:

$ yarn create vite-app <project-name>
$ cd <project-name>
$ yarn
$ yarn dev
复制代码

即可初始化一个 vite 项目(默认应用模板为 vue3.x),生成的项目结构十分简洁:

|____node_modules
|____App.vue // 应用入口
|____index.html // 页面入口
|____vite.config.js // 配置文件
|____package.json
复制代码

执行 yarn dev 即可启动应用 。

\

vite 启动链路

命令解析

这部分代码在 src/node/cli.ts 里,主要内容是借助 minimist —— 一个轻量级的命令解析工具解析 npm scripts,解析的函数是 resolveOptions ,精简后的代码片段如下。

function resolveOptions() {
    // command 可以是 dev/build/optimize
    if (argv._[0]) {
        argv.command = argv._[0];
    }
    return argv;
}
复制代码

拿到 options 后,会根据 options.command 的值判断是执行在开发环境需要的 runServe 命令或生产环境需要的 runBuild 命令。

if (!options.command || options.command === 'serve') {
    runServe(options)
 } else if (options.command === 'build') {
    runBuild(options)
 } else if (options.command === 'optimize') {
    runOptimize(options)
 }
复制代码

runServe 方法中,执行 server 模块的创建开发服务器方法,同样在 runBuild 中执行 build 模块的构建方法。

最新的版本中还增加了 optimize 命令的支持,关于 optimize 做了什么,我们下文再说。

\

server

这部分代码在 src/node/server/index.ts 里,主要暴露一个 createServer 方法。

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

context 组成如下:

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

\

plugin

上文我们说到 plugin,那么有哪些 plugin 呢?它们分别是:

  • 用户注入的 plugins —— 自定义 plugin
  • hmrPlugin —— 处理 hmr
  • htmlRewritePlugin —— 重写 html 内的 script 内容
  • moduleRewritePlugin —— 重写模块中的 import 导入
  • moduleResolvePlugin ——获取模块内容
  • vuePlugin —— 处理 vue 单文件组件
  • esbuildPlugin —— 使用 esbuild 处理资源
  • assetPathPlugin —— 处理静态资源
  • serveStaticPlugin —— 托管静态资源
  • cssPlugin —— 处理 css/less/sass 等引用
  • ...

我们来看 plugin 的实现方式,开发一个用来拦截 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`
      }
  })
}
复制代码

vite 背后的原理都在 plugin 里,这里不再一一解释每个 plugin 的作用,会放在下文背后的原理中一并讨论。

build

这部分代码在 node/build/index.ts 中,build 目录的结构虽然与 server 相似,同样导出一个 build 方法,同样也有许多 plugin,不过这些 plugin 与 server 中的用途不一样,因为 build 使用了 rollup ,所以这些 plugin 也是为 rollup 打包的 plugin ,本文就不再多提。

\

vite 运行原理

ES module

要了解 vite 的运行原理,首先要知道什么是 ES module,目前流览器对其的支持如下:

主流的浏览器(IE11除外)均已经支持,其最大的特点是在浏览器端使用exportimport的方式导入和导出模块,在 script 标签里设置type="module",然后使用模块内容。

<script type="module">
  import { bar } from './bar.js‘
</script>
复制代码

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

// bar.js 
export const bar = 'bar';
复制代码

在 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. 实现按需加载\

去掉打包步骤

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

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

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

\

实现按需打包

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

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

\

vite 如何处理 ESM

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

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

\

@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>
import
复制代码

\

  1. 在 koa 中间件里获取请求 body
  2. 通过 es-module-lexer 解析资源 ast 拿到 import 的内容
  3. 判断 import 的资源是否是绝对路径,绝对视为 npm 模块
  4. 返回处理后的资源路径:"vue" => "/@modules/vue"

\

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

\

为什么需要 @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 一样:可以将项目里的本地文件配置成绝对路径的引用。

\

怎么返回模块内容

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

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

  • ES module (esm) 导入 cjs
  • CommonJS (cjs) 导入 esm
  • dynamic import 导入 esm
  • dynamic import 导入 cjs

\

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

起初在 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 的时候中会使用 roolup 对 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 中。

\

vite 如何编译模块

最初 vite 为 vue3.x 开发,所以这里的编译指的是编译 vue 单文件组件,其他 es 模块可以直接导入内容。

\

SFC

vue 单文件组件(简称 SFC) 是 vue 的一个亮点,前端届对 SFC 褒贬不一,个人看来,SFC 是利大于弊的,虽然 SFC 带来了额外的开发工作量,比如为了解析 template 要写模板解析器,还要在 SFC 中解析出逻辑和样式,在 vscode 里要写 vscode 插件,在 webpack 里要写 vue-loader,但是对于使用方来说可以在一个文件里可以同时写 template、js、style,省了各文件互相跳转。

与 vue-loader 相似,vite 在解析 vue 文件的时候也要分别处理多次,我们打开浏览器的 network,可以看到:

1 个请求的 query 中什么都没有,另 2 个请求分别通过在 query 里指定了 type 为 style 和 template。

先来看看如何将一个 SFC 变成多个请求,我们从第一次请求开始分析,简化后的代码如下:

function vuePlugin({app}){
  app.use(async (ctx, next) => {
    if (!ctx.path.endsWith('.vue') && !ctx.vue) {
      return next()
    }

    const query = ctx.query
    // 获取文件名称
    let filename = resolver.requestToFile(publicPath)

    // 解析器解析 SFC
    const descriptor = await parseSFC(root, filename, ctx.body)
    if (!descriptor) {
      ctx.status = 404
      return
    }
    // 第一次请求 .vue
    if (!query.type) {
      if (descriptor.script && descriptor.script.src) {
        filename = await resolveSrcImport(descriptor.script, ctx, resolver)
      }
      ctx.type = 'js'
      // body 返回解析后的代码
      ctx.body = await compileSFCMain(descriptor, filename, publicPath)
    }
    
    // ...
}
复制代码

在 compileSFCMain 中是一段长长的 generate 代码:

function compileSFCMain(descriptor, filePath: string, publicPath: string) {
  let code = ''
  if (descriptor.script) {
    let content = descriptor.script.content
    code += content.replace(`export default`, 'const __script =')
  } else {
    code += `const __script = {}`
  }

  if (descriptor.styles) {
    code += `\nimport { updateStyle } from "${hmrClientId}"\n`
    descriptor.styles.forEach((s, i) => {
      const styleRequest = publicPath + `?type=style&index=${i}`
      code += `\nupdateStyle("${id}-${i}", ${JSON.stringify(styleRequest)})`
    })
    if (hasScoped) {
      code += `\n__script.__scopeId = "data-v-${id}"`
    }
  }

  if (descriptor.template) {
    code += `\nimport { render as __render } from ${JSON.stringify(
      publicPath + `?type=template`
    )}`
    code += `\n__script.render = __render`
  }
  code += `\n__script.__hmrId = ${JSON.stringify(publicPath)}`
  code += `\n__script.__file = ${JSON.stringify(filePath)}`
  code += `\nexport default __script`
  return code
}
复制代码

直接看 generate 后的代码:

import { updateStyle } from "/vite/hmr"
updateStyle("c44b8200-0", "/App.vue?type=style&index=0")
__script.__scopeId = "data-v-c44b8200"
import { render as __render } from "/App.vue?type=template"
__script.render = __render
__script.__hmrId = "/App.vue"
__script.__file = "/Users/muou/work/playground/vite-app/App.vue"
export default __script
复制代码

出现了 vite/hmr 的导入,vite/hmr 具体内容我们下文再分析,从这段代码中可以看到,对于 style vite 使用 updateStyle 这个方法处理,updateStyle 内容非常简单,这里就不贴代码了,就做了 1 件事:通过创建 style 元素,设置了它的 innerHtml 为 css 内容。

这两种方式都会使得浏览器发起 http 请求,这样就能被 koa 中间件捕获到了,所以就形成了上文我们看到的:对一个 .vue 文件处理三次的情景。

这部分代码在:serverPluginVue 这个 plugin 里。

css

如果在 vite 项目里引入一个 sass 文件会怎么样?

最初 vite 只是为 vue 项目开发,所以并没有对 css 预编译的支持,不过随着后续的几次大更新,在 vite 项目里使用 sass/less 等也可以跟使用 webpack 的时候一样优雅了,只需要安装对应的 css 预处理器即可。

在 cssPlugin 中,通过正则:/(.+).(less|sass|scss|styl|stylus)$/ 判断路径是否需要 css 预编译,如果命中正则,就借助 cssUtils 里的方法借助 postcss 对要导入的 css 文件编译。

\

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 热更新的客户端代码,需要在浏览器里执行,这里先来说说通用的热更新实现,热更新一般需要四个部分:

  1. 首先需要 web 框架支持模块的 rerender/reload
  2. 通过 watcher 监听文件改动
  3. 通过 server 端编译资源,并推送新模块内容给 client 。
  4. client 收到新的模块内容,执行 rerender/reload

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

\

client 端

在 client 端, WebSocket 监听了一些更新的类型,然后分别处理,它们是:

  • vue-reload —— vue 组件更新:通过 import 导入新的 vue 组件,然后执行 HMRRuntime.reload
  • vue-rerender —— vue template 更新:通过 import 导入新的 template ,然后执行 HMRRuntime.rerender
  • vue-style-update —— vue style 更新:直接插入新的 stylesheet
  • style-update —— css 更新:document 插入新的 stylesheet
  • style-remove —— css 移除:document 删除 stylesheet
  • js-update —— js 更新:直接执行
  • full-reload —— 页面 roload:使用 window.reload 刷新页面

server 端

在 server 端,通过 watcher 监听页面改动,根据文件类型判断是 js Reload 还是 Vue Reload:

watcher.on('change', async (file) => {
    const timestamp = Date.now()
    if (file.endsWith('.vue')) {
      handleVueReload(file, timestamp)
    } else if (
      file.endsWith('.module.css') ||
      !(file.endsWith('.css') || cssTransforms.some((t) => t.test(file, {})))
    ) {
      // everything except plain .css are considered HMR dependencies.
      // plain css has its own HMR logic in ./serverPluginCss.ts.
      handleJSReload(file, timestamp)
    }
  })
复制代码

在 handleVueReload 方法里,会使用解析器拿到当前文件的 template/script/style ,并且与缓存里的上一次解析的结果进行比较,如果 template 发生改变就执行 vue-rerender,如果 style 发生改变就执行 vue-style-update,简化后的逻辑如下:

async function handleVueReload(
    file
    timestamp,
    content
  ) {
    // 获取缓存
    const cacheEntry = vueCache.get(file)

    // 解析 vue 文件                                 
    const descriptor = await parseSFC(root, file, content)
    if (!descriptor) {
      // read failed
      return
    }
    // 拿到上一次解析结果
    const prevDescriptor = cacheEntry && cacheEntry.descriptor
    
    // 设置刷新变量
    let needReload = false // script 改变标记
    let needCssModuleReload = false // css 改变标记
    let needRerender = false // template 改变标记

    // 判断 script 是否相同
    if (!isEqual(descriptor.script, prevDescriptor.script)) {
      needReload = true
    }

     // 判断 template 是否相同
    if (!isEqual(descriptor.template, prevDescriptor.template)) {
      needRerender = true
    }
      
    // 通过 send 发送 socket
    if (needRerender){
      send({
        type: 'vue-rerender',
        path: publicPath,
        timestamp
      })  
    }
  }
复制代码

handleJSReload 方法则是根据文件路径引用,判断被哪个 vue 组件所依赖,如果未找到 vue 组件依赖,则判断页面需要刷新,否则走组件更新逻辑,这里就不贴代码了。\

整体代码在 client.ts 和 serverPluginHmr.ts 里。

\

\

结语

本文分析了 vite 的启动链路以及背后的部分原理,虽然在短时间内 vite 不会替代 webpack,但是能够看到社区中多了一种方案还是很兴奋的,这也是我写下这篇文章的原因。

vite 更新的实在太快了,佩服尤大的勤奋和开源精神,短短一个月就加入了诸如 css 预编译/react支持/通用 hmr 的支持,由于篇幅有限本文不再一一介绍这些新特性,这些新的特性等待读者朋友们自行去探讨了。

文章分类
前端
文章标签