速度与激情之 Vite 初体验

avatar
公众号「 微医大前端技术 」

梁晓莹,一只喜欢游泳&&读书的猪猪女孩。

大家最近学习 Vue3 学废了吗?尤雨溪尤大大马不停蹄地又给大家送来了专门为 Vue3 打造的开发利器 — Vite。你是否在开发过程中使用 Webpack 觉得不那么丝滑,是否等待启动编译可以喝好几口热水?本文将带领大家简单了解 Vite 的基本知识和作用,让我们更好的开启 Vue3 开发之旅~ 首先,学习 Vite 之前得至少有 2 部分的知识储备:1)掌握 ES Modules 特性 2)了解 Http2 标准,限于篇幅,这里就不过多赘述啦~

一、问题来源

1.1 Webpack 槽点

如果应用比较复杂,那么使用 Webpack 的开发过程就相对没有那么舒适。

  - Webpack Dev Server 冷启动时间会比较长
  - Webpack HMR 热更新的反应速度比较慢

d46ab5bcbb9aa6b56d07530f073a7230.jpeg

1.2 回顾 Webpack 初衷

【之前技术环境】我们使用 Webpack 打包应用代码,最后生成一个 bundle.js,主要有两个原因:

  - 浏览器环境并不很好的来支持模块化
  - 零散的模块文件会产生大量的 HTTP 请求

1.3 思考现在

bundle 太大,要采用各种 Code Splitting,压缩代码,去除的插件,提取的第三方库,so tired~ 【当前技术环境】是否能解决 Webpack 当时的难点?thinking~~

二、解决思路

2.1 ES Module

  • 随着浏览器的对 ES 标准支持的逐渐完善,第一个问题已经慢慢不存在了。现阶段绝大多数浏览器都是支持 ES Modules 的。

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

// 当 html 里嵌入 ES module 的 script 标签时候,浏览器会发起 http 请求,请求 http server 托管的 main.js ;
// index.html
<script type="module" src="/src/main.js"></script>


// 使用 export 导出模块, import 导入模块:
// main.js 
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

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

直接访问 index.html,报错: image.png 在浏览器里使用 ES module 是使用 http 请求拿到模块的,所以 file 协议的请求不允许。

2.2 模块解析

那我们就在本地起一个静态服务,再来打开一下 index.html 来看下 image.png image.png 报错:找不到模块 vue;原因:"/", "./", or "../"开头的 import 相对/绝对路径,才是合法的。

import vue from 'vue'

也就是说浏览器中的 ESM 是获取不到导入的模块内容的。 平时我们写代码,如果不是引用相对路径的模块,而是引用 node_modules 的模块,都是直接 import xxx from 'xxx',由 Webpack 等工具来帮我们处理 js 间的相互依赖关系,找这个模块的具体路径进行打包,但是浏览器不知道你项目里有 node_modules,它只能通过相对路径或者绝对路径去寻找模块。

那咋办??? 所以 Vite 的一个任务就是启动一个 web server 去代理这些模块,Vite 这里是借用了 koa 来启动了一个服务

export function createServer(config: ServerConfig): Server {
  // ...
  const app = new Koa<State, Context>()
  const server = resolveServer(config, app.callback())
  
  // ...
  const listen = server.listen.bind(server)
  server.listen = (async (port: number, ...args: any[]) => {
    if (optimizeDeps.auto !== false) {
      await require('../optimizer').optimizeDeps(config)
    }
    return listen(port, ...args)
  }) as any
  
  server.once('listening', () => {
    context.port = (server.address() as AddressInfo).port
  })
  
  return server
}

那这就引出了 Vite 的一个实现核心 - 拦截浏览器对模块的请求并返回处理后的结果 我们来看下 Vite 是怎么处理的?

2.3 /@module/前缀

通过工程下的 main.js 和开发环境下的实际加载的 main.js 对比,发现 main.js 内容发生了改变,由

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

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

变成了

import { createApp } from '/@modules/vue.js'
import App from '/src/App.vue'
import '/src/index.css?import'

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

为了解决 import xxx from 'xxx' 报错的问题,Vite 对这种资源路径做了一个统一的处理,加一个/@module/前缀。 我们在 src/node/server/serverPluginModuleRewrite.ts 源码这个 koa 中间件里可以看到 Vite 对 import 都做了一层处理,其过程如下:

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

如何支持 /@module/?/src/node/server/serverPluginModuleResolve.ts 里可以看到大概的处理逻辑是

  • 在 koa 中间件里获取请求 ctx.body
  • 判断路径是否以 /@module/ 开头,如果是取出包名
  • 去 node_module 里找到这个库,基于 package.json 返回对应的内容

2.4 文件编译

上面我们提到的是对普通 js module 的处理,那对于其他文件,比如 vuecssts等是如何处理的呢? 我们以 vue 文件为例来看一下,在 Webpack 里我们是使用的 vue-loader 对单文件组件进行编译,实际上 Vite 同样的是拦截了对模块的请求并执行了一个实时编译。 通过工程下的 App.vue 和开发环境下的实际加载的 App.vue 对比,发现内容发生了改变 原本的 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>
<style>
  body {
    background: #fff;
  }
</style>

变成了

import HelloWorld from '/src/components/HelloWorld.vue'

const __script = {
    name: 'App',
    components: {
        HelloWorld
    }
}

import "/src/App.vue?type=style&index=0"
import {render as __render} from "/src/App.vue?type=template"
__script.render = __render
__script.__hmrId = "/src/App.vue"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)
__script.__file = "/Users/liangxiaoying/myfile/wy-project/vite-demo/src/App.vue"
export default __script

这样就把原本一个 .vue 的文件拆成了三个请求(分别对应 script、style 和 template) ,浏览器会先收到包含 script 逻辑的 App.vue 的响应,然后解析到 template 和 style 的路径后,会再次发起 HTTP 请求来请求对应的资源,此时 Vite 对其拦截并再次处理后返回相应的内容。

实际上在看到这个思路之后,对于其他的类型文件的处理几乎都是类似的逻辑,根据请求的不同文件类型,做出不同的编译处理。 实际上 Vite 就是在按需加载的基础上通过拦截请求实现了实时按需编译

2.5 HTTP 2

  • 零散模块文件在HTTP 1.x 确实会产生大量的 HTTP 请求,而大量的 HTTP 请求在浏览器端就会并发请求资源的问题;但是这些问题随着HTTP 2的出现,也就不复存在了。
  • why?

HTTP 1.x 中,如果想并发多个请求,必须使用多个 TCP 链接,且浏览器为了控制资源,还会对单个域名有 6-8 个的 TCP 链接请求限制; HTTP 2 则可以使用多路复用,代替原来的序列和阻塞机制。所有请求都是通过一个 TCP 连接并发完成。 image.png

三、三大作用

即 Vite 的 3 大核心功能:Static Server + HMR + Compile

3.1 快速的冷启动

image.png 社区:比如可以借助各种 cli :vue-cli、create-react-app 等等

当我们对比使用 vue-cli-service serve 的时候,会有明显感觉。 因为 Webpack Dev Server 在启动时,需要先 build—遍,而 build 的过程是需要耗费很多时间的。 image.png 而 Vite 则完全不同,当我们执行 Vite serve 时(npm run dev),内部直接启动了 Web Server,并不会先编译所有的代码文件。 那仅仅是启动 Web Server,速度上自然就蹭蹭蹭的 up↑。 那么及时请求的编译呢?关于支持 JSX, TSX,Typescript 编译到原生 JS —— Vite 引入了EsBuild,是使用 Go 写的,直接编译为 Native 代码,性能要比 TSC 好二三十倍,所以就不用担心啦~ 当然也会用上缓存,具体这里暂时不扩展。 image.png

3.2 即时的热模块更新

社区:Webpack HMR 等

热更新的时候,Vite 只需要立即编译当前所修改的文件即可,所以 响应速度非常快。 image.png 而 Webpack 修改某个文件过后,会自动以这个文件为入口重写 build—次,所有的涉及到的依赖也都会被加载一遍,所以反应速度会慢很多。

image.pngimage.png

3.3 真正的按需编译

社区:需要开发者自行在代码中引入其他插件 impor('xx.js') 实现 dynamic-import;如@babel/plugin-syntax-dynamic-import

但是像 Webpack 这类工具的做法是将所有模块提前编译、打包进 bundle 里,换句话说,不管模块是否会被执行,都要被编译和打包到 bundle 里。随着项目越来越大打包后的 bundle 也越来越大,打包的速度自然也就越来越慢。

Vite 利用现代浏览器原生支持 ESM 特性,省略了对模块的打包。

对于需要编译的文件,Vite 采用的是另外一种模式:即时编译。 也就是说,只有具体去请求某个文件时才会编译这个文件。 所以,这种「即时编译」的好处主要体现在:按需编译。

四、核心思路

4.1 初始启动静态服务

初始执行命令 npm run dev --> 实际就是启动了 /src/node/server/index.ts 如上文提到启动了一个 koa server, 该文件还使用了 chokidar 库创建一个 watcher,来监听文件变动:

export function createServer(config: ServerConfig): Server {
  // 启动静态 server:
  const app = new Koa<State, Context>()
  const server = resolveServer(config, app.callback())
  
  ......
  
  const listen = server.listen.bind(server)
  server.listen = (async (port: number, ...args: any[]) => {
    ...
  }) as any
  
  
  // 其中关键 1:使用 chokidar 对文件进行递归监听:监听到文件变动可对不同模块进行相应处理
  const watcher = chokidar.watch(root, {
    ignored: ['**/node_modules/**', '**/.git/**'],
    ...
  }) as HMRWatcher
  
  // 其中关键 2:执行各类插件
  const resolvedPlugins = [
    // rewrite and source map plugins take highest priority and should be run
    // after all other middlewares have finished
    sourceMapPlugin,
    moduleRewritePlugin,
    htmlRewritePlugin, // 处理 html 文件
    // user plugins
    ...toArray(configureServer),
    envPlugin,
    moduleResolvePlugin,
    proxyPlugin,
    clientPlugin, // 输出客户端执行代码
    hmrPlugin, // 处理热模块更新
    ...(transforms.length || Object.keys(vueCustomBlockTransforms).length
      ? [
          createServerTransformPlugin(
            transforms,
            vueCustomBlockTransforms,
            resolver
          )
        ]
      : []),
    vuePlugin, // 处理单文件组件
    cssPlugin, // 处理样式文件
    enableEsbuild ? esbuildPlugin : null,
    jsonPlugin,
    assetPathPlugin,
    webWorkerPlugin,
    wasmPlugin,
    serveStaticPlugin
  ]
  resolvedPlugins.forEach((m) => m && m(context))
}

4.2 监听消息,拦截部分请求

我们可以看到初始第一个请求如下: image.png 那么这个文件哪里来的?这就是经过 clientPlugin 【/src/node/server/serverPluginClient.ts】处理输出的:

export const clientPublicPath = `/vite/client` // 当前的文件名称
const legacyPublicPath = '/vite/hmr' // 历史版本的名称
...

export const clientPlugin: ServerPlugin = ({ app, config }) => {
 // clientCode 替换配置的信息,用于最后 body 输出:
 const clientCode = fs
    .readFileSync(clientFilePath, 'utf-8')
    .replace(`__MODE__`, JSON.stringify(config.mode || 'development'))
    ...
app.use(async (ctx, next) => {
  	// 请求路径是/vite/client,返回响应:200,响应文本是处理好的 clientCode
    if (ctx.path === clientPublicPath) {
      // 设置 socket 配置信息
      let socketPort: number | string = ctx.port
      ...
      if (config.hmr && typeof config.hmr === 'object') {
        // hmr option 有最高优先级
        ...
      }
      ctx.type = 'js'
      ctx.status = 200
      // 返回整合好的 body
      ctx.body = clientCode.replace(`__HMR_PORT__`, JSON.stringify(socketPort))
    } else {
      if (ctx.path === legacyPublicPath) { // 历史版本 /vite/hmr
        console.error('xxxx')
      }
      return next()
    }
  }) 
}

请求/vite/client 实际就是 /src/client/client.ts 文件,即返回 body = clientCode = client.ts 文件内容; 那么它做啥了呢??? 使用 websoket 处理消息,快速编译,达到实时热更新:

const socketProtocol =
  __HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
// 启动 websocket 通信,可实时处理消息,实现 HMR
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')

...

监听消息:

socket.addEventListener('message', async ({ data }) => {
  const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
  handleMessage(payload)
})

处理消息:

async function handleMessage(payload) {
    const { path, changeSrcPath, timestamp } = payload;
    switch (payload.type) {
        case 'connected': // socket 连接成功
            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': // js 更新
            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')) {
                ...
            } else {
                location.reload();
            }
    }
}

咦?那设立了 message 监听,那 message 又是谁发出来的呢?

4.3 不同插件,监听文件变化,返回消息

例如:cssPlugin 【/src/node/server/serverPluginCss.ts】

// 处理 css 文件,监听 css 文件变动
export const cssPlugin: ServerPlugin = ({ root, app, watcher, resolver }) => {
  // 输出 css 请求的响应模板
  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
  }
  
  app.use(async (ctx, next) => {
    await next()
    // 处理 .css 的 imports
    ...
    const id = JSON.stringify(hash_sum(ctx.path))
    if (isImportRequest(ctx)) {
      const { css, modules } = await processCss(root, ctx)
      ctx.type = 'js'
      // 用`?import`去重写 css 文件为一个 js 模块,插入 style 标记,链接到实际原始 url
      ctx.body = codegenCss(id, css, modules)
    }
  })
  watcher.on('change', (filePath) => {
    // 筛出 css 文件,更新 css 请求文件
    if (文件更新) {
      watcher.send({ // 发送消息
        type: 'style-update',
        path: publicPath,
        changeSrcPath: publicPath,
        timestamp: Date.now()
      })
    }
  })
  
}

4.4 逻辑小结

  • 将当前项目目录作为静态文件服务器的根目录
  • 拦截部分文件请求
  • 处理代码中 import node_modules 中的模块 b
  • 处理 Vue 单文件组件(SFC)的编译
  • 通过 WebSocket 实现 HMR

五、Snowpack VS Vite

同:

  1. 底层原理:Snowpack v2 和 Vite 均提供基于浏览器原生 ES 模块导入的开发服务器;
  2. 冷启动快速:在开发反馈速度方面,两个项目都具有相似的性能特征;
  3. 开箱即用:避免各种 Loader 和 Plugin 的配置;

Vite 默认情况下支持更多的选择加入功能-例如 TypeScript transpilation、CSS import、CSS Modules 和 postcss 支持(需要单独安装所对应的编译器) 都是现成的,无需配置; snowpack 也是支持 JSX、TypeScript、React、Preact、CSS Modules 等构建,非默认;

  1. 插件:支持很多自定义插件;Vite 关于这部分的官方文档还没有。

异:

  1. 演变:Snowpack 最初不提供 HMR 支持,但在 v2 添加了它,从而使两个项目的范围更加接近。Vite 最初就是参考了 snowpack v1; 双方在基于 ESM 的 HMR 上合作过,尝试建立统一的 api ESM-HMR API 规范, 但因为底层不同还是会略微不同;
  2. 使用:Vite 当前暂时只能给 Vue 3.x.使用+react 等部分模板, 对 vue 支持更棒👍;snowpack 没限制;
  3. 生产打包:Vite 用 rollup,打包体积更小(rollupInputOptions:定义 rollup 的插件功能);snowpack 用 parcel/webpack; - 决定了开发者生产个性化配置的方案不一样;
  4. 偏向:Vue 支持是 Vite 中的一级功能。例如,Vite 提供了一个更细粒度的 HMR 与 Vue 的集成,并且对构建配置进行了微调,以生成最高效的 bundle;
  5. 文档完善性:
  • Vitejs 优点是尤雨溪出品,可能和 Vue3 生态更好的融合。 缺点是文档不完善。目前 star 13.7k;
  • Snowpack 优点是更加成熟,有成熟的 v1 和已经发布正式版的 v2, 支持 react, Vue, preact, svelte 等各类应用,文档也更加完善。目前 star 14.4k。

so。。。如何选择?: => 选 Vite:

  1. 喜欢用 Vue,那么 Vite 提供更好支持;
  2. 诉求是打包 bundle 体积小 ,那么 Vue 使用 rollup👌。

=> 选 Snowpack:

  1. 不喜欢用 Vue,不用 vue-cli,喜欢 react 等;
  2. 大的 team 想要使用各类插件 plugin,想要清晰的文档📚等;
  3. 对 Webpack 比较用的惯,想要开发模式不要 bundle 打包,更快速👀。

本篇文章主要是带领 Vue 开发爱好者学习尤大对于按需编译等方面的想法和新思路,助力童鞋们的高效开发,减少学习路径。期待 Vite 不仅能成为 vue 的配套工具,还能在未来形成更成熟的社区方案,推进技术进步!!!