从Vite源码入手,揭秘bundleless 工具原理

1,031 阅读8分钟

image.png

一、Vite简介

Vite 是由 Vue 作者尤雨溪开发的 Web 开发工具,尤雨溪在微博上推广时对 Vite 做了简短介绍:

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

1、诞生背景

在浏览器支持 ES 模块之前,开发者没有以模块化的方式开发 JavaScript 的原生机制。这也是 “打包” 这个概念出现的原因:使用工具抓取、处理和链接我们的源码模块到文件中,使其可以运行在浏览器中。

随着构建应用越来越大,诸如 webpackRollupParcel 等工具也逐渐显现出性能瓶颈:

  • 启动开发服务器缓慢
  • 热更新缓慢

2、Vite优势

据官网描述,Vite具有以下特点: image.png

用户最为关心且感知较多的是以下两点:

  • 快速的开发服务器启动

当冷启动开发服务器时,基于打包器的方式启动必须先去急切地抓取和构建你的整个应用,然后再提供服务。

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

  • Vite 使用 esbuild 预构建依赖。Esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。
  • Vite 以 原生 ESM 方式服务源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入的代码,即只在当前屏幕上实际使用时才会被处理。

image.png

image.png

  • 飞快的热更新

在 Vite 中,HMR 是在原生 ESM 上执行的。当编辑一个文件时,Vite 只需要精确地使已编辑的模块与其最近的 HMR 边界之间的链失效(大多数时候只需要模块本身),使 HMR 更新始终快速,无论应用的大小。

Vite 同时利用 HTTP 头来加速整个页面的重新加载:源码模块的请求会根据 304 Not Modified 进行协商缓存,而依赖模块请求则会通过 Cache-Control: max-age=31536000,immutable 进行强缓存,因此一旦被缓存它们将不需要再次请求。

想要了解更多ESM概念的同学可以移步聊聊ESM

二、Vite实现原理

从上面的Vite介绍中我们能提取出其具备的两个最大的亮点:

  • Vite 在服务端实现了按需编译
  • Vite 基于 ESM,因此实现了快速启动和即时模块热更新能力

接下来我们使用Vite创建工程,并走进源码揭秘相关特性

1、初始化项目

使用下列命令初始化一个vite-app

npm init vite-app vite-app

cd vite-app

npm install

npm run dev

得到目录结构如下:

image.png

访问localhost:3000得到index.html内容如下:

image.png

查看package.json获取启动命令:

"scripts": {
    "dev": "vite",
    // ...
 }

打开文件node_modules/vite/dist/node/cli.js,找到npm run dev执行的命令,可以看到Vite通过runServe启动了一个koaServer用来实现对浏览器请求的响应:

image.png

2、按需编译原理解析

  • koaServer启动

进入runServer函数, 实现如下:

const server = require('./server').createServer(options)

node_modules/vite/dist/node/server/index.js中可查看createServer实现,精简后的createServer代码如下,:

export function createServer(config: ServerConfig): Server {
  const {
    root = process.cwd(),
    configureServer = [],
    resolvers = [],
    alias = {},
    transforms = [],
    vueCustomBlockTransforms = {},
    optimizeDeps = {},
    enableEsbuild = true
  } = config
  
  // 创建 Koa 实例
  const app = new Koa<State, Context>()
  const server = resolveServer(config, app.callback())
  const resolver = createResolver(root, resolvers, alias)
  
  // 相关上下文信息 
  const context: ServerPluginContext = {
    root,
    app,
    server,
    resolver,
    config,
    port: config.port || 3000
  }

  // 一个简单中间件,扩充 context 上下文内容
  app.use((ctx, next) => {
    Object.assign(ctx, context)
    ctx.read = cachedRead.bind(null, ctx)
    return next()
  })
  const resolvedPlugins = [
    // ...
  ]
  resolvedPlugins.forEach((m) => m && m(context))
  const listen = server.listen.bind(server)
  server.listen = (async (port: number, ...args: any[]) => {
    if (optimizeDeps.auto !== false) {
      await require('../optimizer').optimizeDeps(config)
    }
    const listener = listen(port, ...args)
    context.port = server.address().port
    return listener
  }) as any
  return server
}
  • import路径改写

回到localhost:3000页面,由于index.html中使用<script type="module" src="/src/main.js"></script>引入了main.js,依据 ESM 规范在浏览器 script 标签中的实现,当出现 script 标签 type 属性为 module 时,浏览器将会请求模块相应内容,于是我们拿到如下main.js请求结果:

image.png

细看后发现它和源文件main.js中的内容有一些微妙的差异:

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

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

Vite Server 处理请求时,通过 serverPluginModuleRewrite 这个中间件来给 import from 'A' A 添加 /@module/ 前缀为 from '/@modules/A',源码如下:

image.png

继续跟进node_modules/vite/dist/node/server/serverPluginModuleRewrite.js,发现moduleRewritePlugin主要通过rewriteImports方法来执行resolveImport方法,并进行改写,简化的源码如下:

// ...
ctx.body = rewriteImports(root, content, importer, resolver, ctx.query.t);

function rewriteImports(root, source, importer, resolver, timestamp) {
    // ...
    const resolved = exports.resolveImport(root, importer, id, resolver, timestamp);
    
    // ...
}

通过上述解读,可以总结出Vite中对import的处理:

  • 在 koa 中间件里获取请求 path 对应的 body 内容
  • 通过 es-module-lexer 解析资源 AST,并拿到 import 的内容
  • 如果判断 import 的资源是绝对路径,即可认为该资源为 npm 模块,并返回处理后的资源路径。比如上述代码中,vue → /@modules/vue

对于形如:import App from './App.vue'import './index.css'的处理,与上述情况类似。

  • 文件解析

处理完路径替换后,接下来浏览器根据 main.js 的内容,分别请求:

/@modules/vue.js
/src/App.vue
/src/index.css?import

接下来我们分别看上述三个文件路径在Vite中是如何进行解析的

1. /@modules/vue.js

解析过程可参考node_modules/vite/dist/node/server/serverPluginModuleResolve.js中代码,主要处理如下

  • 在 koa 中间件里获取请求 path 对应的 body 内容

  • 判断路径是否以 /@module/ 开头,如果是,取出包名(这里为 vue.js)

  • 去 node_modules 文件中找到对应的 npm 库,并返回内容

2. /src/App.vue

先看下浏览器请求返回的结果:

image.png

实际App.vue内容如下:

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

对于.vue文件,Vite会对template、style、script分开进行解析,这部分在对serverPluginVue中间件中实现,源码如下:

image.png

这个中间件的实现很简单,即对 .vue 文件请求进行处理,通过 parseSFC 方法解析单文件组件,并通过 compileSFCMain 方法将单文件组件进行内容拆分,对应中间件关键内容可在源码 vuePlugin 中找到。源码中,涉及 parseSFC 具体所做的事情,是调用 @vue/compiler-sfc 进行单文件组件解析,可参考如下精简后的逻辑:

if (!query.type) {
  ctx.body = `
    const __script = ${descriptor.script.content.replace('export default ', '')}
    // 单文件组件中,对于 style 部分的编译,编译为对应 style 样式的 import 请求
    ${descriptor.styles.length ? `import "${url}?type=style"` : ''}
    // 单文件组件中,对于 template 部分的编译,编译为对应 template 样式的 import 请求
    import { render as __render } from "${url}?type=template"
    // 渲染 template 的内容
    __script.render = __render;
    export default __script;
  `;
}

通过内容拆分每个.vue文件会被拆成多个请求,并通过拼接?type=类型来区别处理不同结构的请求资源,如:App.vue会产生HelloWorld.vue 以及 App.vue?type=template 的请求,对这写请求的处理仍在serverPluginVue插件中进行,其中对于 template 的请求,服务使用 @vue/compiler-dom 进行编译 template 并返回内容,精简后的逻辑如下:

if (query.type === 'template') {
	const template = descriptor.template;
	const render = require('@vue/compiler-dom').compile(template.content, {
	  mode: 'module',
	}).code;
	ctx.type = 'application/javascript';
	ctx.body = render;
}

3. /src/index.css?import

样式文件的处理主要在singlePluginVue中间件中进行,对style处理如下:

// style 类型请求
if (query.type === 'style') {
  const index = Number(query.index)
  const styleBlock = descriptor.styles[index]
  if (styleBlock.src) {
    filePath = await resolveSrcImport(root, styleBlock, ctx, resolver)
  }
  const id = hash_sum(publicPath)
  // 调用 compileSFCStyle 方法编译当文件组件样式部分
  const result = await compileSFCStyle(
    root,
    styleBlock,
    index,
    filePath,
    publicPath,
    config
  )
  ctx.type = 'js'
  // 返回样式内容
  ctx.body = codegenCss(`${id}-${index}`, result.code, result.modules)
  return etagCacheCheck(ctx)
}

其中codegenCss方法在 node_modules/vite/dist/node/server/serverPluginCss.js 中:

export function codegenCss(
  id: string,
  css: string,
  modules?: Record<string, string>
): string {
  // 样式代码模板
  let code =
    `import { updateStyle } from "${clientPublicPath}"\n` +
    `const css = ${JSON.stringify(css)}\n` +
    `updateStyle(${JSON.stringify(id)}, css)\n`
  if (modules) {
    code += dataToEsm(modules, { namedExports: true })
  } else {
    code += `export default css`
  }
  return code
}

该方法会在浏览器中执行 updateStyle 方法,源码如下:

const supportsConstructedSheet = (() => {
  try {
    // 生成 CSSStyleSheet 实例,试探是否支持 ConstructedSheet
    new CSSStyleSheet()
    return true
  } catch (e) {}
  return false
})()

export function updateStyle(id: string, content: string) {
  let style = sheetsMap.get(id)
  if (supportsConstructedSheet && !content.includes('@import')) {
    if (style && !(style instanceof CSSStyleSheet)) {
      removeStyle(id)
      style = undefined
    }
    if (!style) {
      // 生成 CSSStyleSheet 实例
      style = new CSSStyleSheet()
      style.replaceSync(content)
      document.adoptedStyleSheets = [...document.adoptedStyleSheets, style]
    } else {
      style.replaceSync(content)
    }
  } else {
    if (style && !(style instanceof HTMLStyleElement)) {
      removeStyle(id)
      style = undefined
    }
    if (!style) {
      // 生成新的 style 标签并插入到 document 挡住
      style = document.createElement('style')
      style.setAttribute('type', 'text/css')
      style.innerHTML = content
      document.head.appendChild(style)
    } else {
      style.innerHTML = content
    }
  }
  sheetsMap.set(id, style)
}

通过以上操作最终完成在浏览器中插入样式。

通过上述源码梳理,我们基本上弄懂了Vite 这种 bundleless 方案的运行原理,结合如下示意图可以更好的理解:

image.png (上图绘制时有处笔误,右侧图片下方文字应该为”bundleless“)

image.png

3、HMR原理解析

Vite的HMR主要围绕以下三点进行展开:

  • 通过 watcher 监听文件改动
  • 通过 server 端编译资源,并推送新模块内容给浏览器
  • 浏览器收到新的模块内容,执行框架层面的 rerender/reload

当浏览器请求 HTML 页面时,服务端通过 serverPluginHtml 插件向 HTML 内容注入一段脚本。如下图所示,我们可以看到,index.html 中就有一段引入/vite/client代码,进行WebSocket 的注册和监听。

image.png

image.png

对于/vite/client请求的处理,服务端由serverPluginClient 插件进行处理:

export const clientPlugin: ServerPlugin = ({ app, config }) => {
  const clientCode = fs
    .readFileSync(clientFilePath, 'utf-8')
    .replace(`__MODE__`, JSON.stringify(config.mode || 'development'))
    .replace(
      `__DEFINES__`,
      JSON.stringify({
        ...defaultDefines,
        ...config.define
      })
    )
  // 相应中间件处理
  app.use(async (ctx, next) => {
    if (ctx.path === clientPublicPath) {
      ctx.type = 'js'
      ctx.status = 200
      // 返回具体内容
      ctx.body = clientCode.replace(`__PORT__`, ctx.port.toString())
    } else {
      // 兼容历史逻辑,并进行错误提示
      if (ctx.path === legacyPublicPath) {
        console.error(
          chalk.red(
            `[vite] client import path has changed from "/vite/hmr" to "/vite/client". ` +
              `please update your code accordingly.`
          )
        )
      }
      return next()
    }
  })
}

返回的 /vite/src/client/client.js 代码在浏览器端主要通过 WebSocket 监听了一些更新的类型(vue 组件更新/vue template 更新/vue style 更新/css 更新/css 移除/js 更新/页面 roload),分别进行处理。 在服务端,通过chokidar创建了一个监听文件改动的watcher来监听文件改动:

const watcher = chokidar.watch(root, {
	ignored: [/node_modules/, /\.git/],
	// #610
	awaitWriteFinish: {
	  stabilityThreshold: 100,
	  pollInterval: 10
	}
}) as HMRWatcher

并通过 serverPluginHmr 发布变动,通知浏览器。

const send = (watcher.send = (payload) => {
        const stringified = JSON.stringify(payload, null, 2);
        exports.debugHmr(`update: ${stringified}`);
        wss.clients.forEach((client) => {
            if (client.readyState === ws_1.default.OPEN) {
                client.send(stringified);
            }
        });
    });
 const handleJSReload = (watcher.handleJSReload = (filePath, timestamp = Date.now()) => {
     //...
      send({
        type: 'multi',
        updates: boundaries.map((boundary) => {
            return {
                type: boundary.endsWith('vue') ? 'vue-reload' : 'js-update',
                path: boundary,
                changeSrcPath: publicPath,
                timestamp
            };
        })
    });
  }
  
  watcher.on('change', (file) => {
        if (!(file.endsWith('.vue') || cssUtils_1.isCSSRequest(file))) {
            // everything except plain .css are considered HMR dependencies.
            // plain css has its own HMR logic in ./serverPluginCss.ts.
            handleJSReload(file);
        }
    });

可以借助下面绘制的「Vite 实现 HMR 流程图」更好的理解这块的逻辑:

image.png

三、总结

本文结合Vite源码对bundleless工具的一些特性进行了剖析,总结一下主要涵盖以下几点:

  • Vite 利用浏览器原生支持 ESM 这一特性,省略了对模块的打包,也就不需要生成 bundle,因此初次启动更快,HMR 特性友好
  • Vite 开发模式下,通过启动 koa 服务器,在服务端完成模块的改写(比如单文件的解析编译等)和请求处理,实现真正的按需编译
  • Vite Server 所有逻辑基本都依赖中间件实现。这些中间件,拦截请求之后,完成了如下内容:
    • 处理 ESM 语法,比如将业务代码中的 import 第三方依赖路径转为浏览器可识别的依赖路径
    • 对 .ts、.vue 等文件进行即时编译
    • 对 Sass/Less 的需要预编译的模块进行编译
    • 和浏览器端建立 socket 连接,实现 HMR

Vite作为bundleless工具的代表,其实借鉴了 snowpack 和 esbuild 相关思想,想更多的了解Vite以及Vite与其他主流构建工具差异的可以参考如下文章: