vite 深入浅出

在这里插入图片描述

简介

vite(轻量,轻快的意思) 是一个由原生 ES Module 驱动的 Web 开发前端构建工具。

浏览器原生 ESM:浏览器支持的 JavaScript 模块化标准,可以直接使用 <script type="module"> 标签加载模块,无需打包或转译。

在开发环境下基于浏览器原生 ES Module 的支持实现了 no-bundle 服务。另一方面借助 esbuild 超快的编译速度来做第三方库构建和 ts/jsx 语法编译,从而能够有效提高开发效率。在生产环境下基于 rollup 打包来构建代码。

除了开发效率,在其他维度上 vite 也表现不错:

  • 模块化方面:vite 基于浏览器原生 ESM 的支持实现模块加载,并且无论是开发环境还是生产环境,都可以将其他格式的产物(如 CommonJS )转换为 ESM。

  • 语法转译方面:vite 内置了对 TypeScriptJSXSass 等高级语法的支持,也能够加载各种各样的静态资源,如 imageWorker 等等。

  • 产物质量方面:vite 基于成熟的打包工具 Rollup 实现生产环境打包,同时可以配合 TerserBabel 等工具链,可以极大程度保证构建产物的质量。

优点

工具名称开发环境(Dev)热更新 (HMR)生产环境(Production)
Webpack会先打包生成 bundle,再启动开发服务器HMR 时需要把改动模块及相关依赖全部编译打包生成 bundle
vite先启动开发服务器,利用新一代浏览器的 ESM 能力,无需打包,直接请求所需模块并实时编译HMR时只需让浏览器重新请求该模块,同时利用浏览器的缓存(源码模块协商缓存,依赖模块强缓存)来优化请求;使得无论应用大小如何,HMR 始终能保持快速更新。通过成熟的 rollup 打包工具来生成 bundle

vite 在开发环境下冷启动无需打包,无需分析模块之间的依赖,同时也无需在启动开发服务器前进行编译,启动时还会使用 esbuild 来进行预构建。下图基于原生 ESM 的开发服务流程图。

在这里插入图片描述

webpack 在启动后会做一堆事情,经历一条很长的编译打包链条,从入口开始需要逐步经历语法解析、依赖收集、代码转译、打包合并、代码优化,最终将高版本的、离散的源码编译打包成低版本、高兼容性的产物代码,这些都是 IO 操作,在 Node 运行时下性能必然是有问题。下图基于 bundle 的开发服务流程图。

在这里插入图片描述

性能利器—esbuild

格式转换

由于 vite 是基于浏览器原生的 ESM 规范来实现的,这就要求整个项目中涉及的所有源代码必须符合 ESM 规范。而在实际开发过程中,业务代码我们可以严格按照 ESM 规范来编写,但第三方依赖就无法保证了。
举个🌰:作为一个流行的 JavaScript 实用工具库 lodash 是以 CommonJS 的规范导出的。在开发环境下 vite 会对 lodash 进行依赖转换,这里我们可以通过配置 optimizeDeps: { exclude: ['lodash']} 在预构建中强制排除依赖项。配置完之后,编写一段测试代码:

// App.vue
import _ from 'lodash'
console.log(_.cloneDeep({}))

我们在 App.vue 中以 ESM 的方式导入 lodash 并调用其中的方法,通过控制台可以看到由于导出规范的不同使用 ESM 方式导入会报错。
在这里插入图片描述

依赖转换之后可以使用 ESM 规范引入:

在这里插入图片描述

减少 HTTP 请求数量

vite 会将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。例如,lodash-es 有超过 600 个内置模块。当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求。如果不做处理就直接使用,那么就会引发请求瀑布流,这对页面性能来说,简直就是一场灾难。通过预构建 lodash-es 成为一个模块,我们就只需要一个 HTTP 请求。

在这里插入图片描述
在这里插入图片描述

文件缓存系统

默认情况下,预构建结果会保存到 node_modules/.vite/deps 目录下。它根据几个源来决定是否需要重新运行预构建步骤: package.json 中的 dependencies 列表、包管理器的 lockfile,例如 package-lock.json, yarn.lock ,或者 pnpm-lock.yaml 可能在 vite.config.js 相关字段中配置过的。如果这些都没有变,那么 vite 会复用上一次 预构建的结果。如果不想让 vite 复用上一次预构建的结果,我们可以通过配置 server: { force: true },使得每次启动的时候都强制进行预构建。

依赖模块强缓存

vite 会将项目中的依赖库通过外部引入的方式加载,而不是将其打包到最终的构建文件中。这样可以利用浏览器缓存,减少资源请求,提高页面加载速度。在请求时,可以通过修改请求的版本号 v=xxxx 来规避强缓存。

在这里插入图片描述

其中 immutable 的含义是就算用户刷新页面,浏览器也不会发起请求去服务,浏览器会直接从本地磁盘或者内存中读取缓存并返回 200 状态。

源码模块协商缓存

vite 会对源码模块采用协商缓存策略。

在这里插入图片描述

其中 no-cache 并不意味着“不缓存”,而是允许缓存,但是必须首先向源服务器提交验证请求,并且通常是通过使用 ETag 完成的。

Bundler 工具

早期 vite 1.x 版本中使用 rollup 来做这件事情,但 esbuild 的性能实在是太恐怖了,vite 2.x 果断采用 esbuild 来完成第三方依赖的预构建,至于性能到底有多强。这里引用一张来自 esbuild 官网的图片。

在这里插入图片描述

从上图可以看出来相较于其他的打包工具 esbuild 完全是碾压的存在,既然 esbuild 性能这么出众那为什么 vite 不把它用做生产环境的打包工具呢?具体有如下几个原因:

  • vite 当前的插件 API 与使用 esbuild 作为打包器并不兼容。rollup 的插件 API 和基础设施更加完善,因此在生产环境中,使用 rollup 打包会更稳定。

  • 不提供操作打包产物的接口,像 rollup 中灵活处理打包产物的能力(如 renderChunk 钩子)在 esbuild 当中完全没有。

  • 不支持自定义 Code Splitting 策略。传统的 Webpackrollup 都提供了自定义拆包策略的 API,而 esbuild 并未提供,从而降级了拆包优化的灵活性。

尽管 esbuild 有如此多的局限性,但依然不妨碍 vite 在开发阶段使用它成功启动项目并获得极致的性能提升,生产环境处于稳定性考虑当然是采用功能更加丰富、生态更加成熟的 rollup 作为依赖打包工具了。

TS(X) 和 JS(X) 编译工具

vite 已经将 esbuild 的 Transformer 能力用到了生产环境

TS(X)/JS(X) 单文件编译上面,vite 也使用 esbuild 进行语法转译,也就是将 esbuild 作为 Transformer 来用。需要注意的是 esbuild 并没有实现 TS 的类型系统,在编译 TS(X)/JS(X) 文件时仅仅抹掉了类型相关的代码,暂时没有能力实现类型检查。

代码压缩工具

vite 从 2.6 版本开始默认使用 esbuild 来进行生产环境的代码压缩,包括 JS 代码和 CSS 代码

这里有一个🌰比较不同最新版本
JavaScript minifiers 在压缩速度和压缩质量方面的区别。

在这里插入图片描述

由于优异的性能 esbuild (基于 Golang 开发)完成,而不是传统的 webpack/rollup,也不会有明显的打包性能问题,反而是 vite 项目启动飞快(秒级启动)的一个核心原因。总的来说,viteesbuild 作为自己的性能利器,将 esbuild 各个垂直方向的能力(Bundler、Transformer、Minifier)利用的淋漓尽致,给 vite 的高性能提供了有利的保证。

构建基石—rollup

rollup 在 vite 中的重要性一点也不亚于 esbuild,它既是 vite 用作生产环境打包的核心工具,同时为了在生产环境中也能取得优秀的产物性能。在打包阶段 vite 到底基于 rollup 做了哪些事情?

CSS 代码分割

如果某个异步模块中引入了一些 CSS 代码,vite 就会自动将这些 CSS 抽取出来生成单独的文件,这个 CSS 文件将在该异步 chunk 加载完成时自动通过一个 <link> 标签载入,该异步 chunk 会保证只在 CSS 加载完毕后再执行,避免发生页面先加载出来,样式后加载出来,导致出现闪屏的状况。举个🌰:

// About.vue
<template>
  <div @click="switchTab(tab)" v-for="(tab, index) in tabData" :key="index" >
    {{ tab.name }}
  </div>
  <component :is="currentTab.tabComp"></component>
</template>

<script setup lang="ts">
import { reactive, markRaw, defineAsyncComponent } from 'vue';
const A = defineAsyncComponent(() => import('./A.vue'))
const B = defineAsyncComponent(() => import('./B.vue'))
const tabData = reactive([
  { name: 'A组件', tabComp: markRaw(A) },
  { name: 'B组件', tabComp: markRaw(B) },
]);
const currentTab = reactive({
  tabComp:tabData[1].tabComp
})
const switchTab = (tab) => {
  currentTab.tabComp = tab.tabComp;
};
</script>
// A.vue
<template>
  <div class="name">
    我是A组件的内容
  </div>
</template>

<script setup lang="ts">
import '../styles/index.css'
import '../utils/index'
</script>
// B.vue
<template>
  <div class="name">
    我是B组件的内容
  </div>
</template>

<script setup lang="ts">
import '../utils/index'
</script>
// utils/index.js
console.log('我是模块C的内容')
// styles/index.css
.name {
  color: red;
}

在这里插入图片描述

异步 Chunk 加载优化

在实际项目中,通常会存在共用 chunk (被两个或以上的其他 chunk 共享的 chunk)。
以上面的代码为🌰:在无优化的情境下,当异步 chunk B 被导入时,浏览器将必须请求和解析B,然后它才能弄清楚它需要共用 index.ts。通过控制台可以看到额外的网络往返。

在这里插入图片描述

vite 将使用一个预加载步骤自动重写代码来分割动态导入调用,以实现当B被请求时 index.ts 也将同时被请求。

在这里插入图片描述

构建过程

对于一次完整的构建过程而言, rollup 会先进入到 build 阶段,解析各模块的内容及依赖关系,然后进入 output 阶段,完成打包及输出的过程。对于不同的阶段,rollup 插件会有不同的插件工作流程,拆解一下 rollup 插件在 buildoutput 两个阶段的详细工作流程。

build 阶段工作流

在这个阶段主要进行模块代码的转换、AST 解析以及模块依赖的解析,那么这个阶段的 Hook 对于代码的操作粒度一般为模块级别,也就是单文件级别

在这里插入图片描述

  1. 首先经历 options 钩子进行配置的转换,得到处理后的配置对象。

  2. 随之 rollup 会调用 buildStart 钩子,正式开始构建流程。(从 input 配置指定的入口文件开始)

  3. rollup 先进入到 resolveId 钩子中解析文件路径。

  4. rollup 通过调用 load 钩子加载模块内容。

  5. 紧接着 rollup 执行所有的 transform 钩子来对模块内容进行进行自定义的转换,比如 babel 转译。

  6. 现在 rollup 拿到解析后的模块内容,进行 AST 分析,得到所有的 import 内容,调用 moduleParsed 钩子:

    1. 如果是普通的 import,则执行 resolveId 钩子,继续回到步骤3。

    2. 如果是动态 import,则执行 resolveDynamicImport 钩子解析路径,如果解析成功,则回到步骤4加载模块,否则回到步骤3通过 resolveId 解析路径。

  7. 直到所有的 import 都解析完毕,rollup 执行buildEnd 钩子,build 阶段结束。

output 阶段工作流

ouput Hook (官方称为 Output Generation Hook),则主要进行代码的打包,对于代码而言,操作粒度一般为 chunk级别(一个 chunk 通常指很多文件打包到一起的产物)。

在这里插入图片描述

  1. 执行所有插件的 outputOptions 钩子函数,对 output 配置进行转换。

  2. 执行 renderStart 钩子,正式开始打包。

  3. 并发执行所有插件的 banner、footer、intro、outro 钩子,这四个钩子功能很简单,就是往打包产物的固定位置(比如头部和尾部)插入一些自定义的内容,比如协议声明内容、项目介绍等等。

  4. 从入口模块开始扫描,针对动态 import 语句执行 renderDynamicImport 钩子,来自定义动态 import 的内容。

  5. 对每个即将生成的 chunk,执行 augmentChunkHash 钩子,来决定是否更改 chunk 的哈希值。

  6. 如果没有遇到 import.meta 语句,则进入下一步,否则:

    1. 对于 import.meta.url 语句调用 resolveFileUrl 来自定义 url 解析逻辑

    2. 对于其他 import.meta 属性,则调用 resolveImportMeta 来进行自定义的解析。

  7. 接着 rollup 会生成所有 chunk 的内容,针对每个 chunk 会依次调用插件的 renderChunk 方法进行自定义操作,也就是说,在这里时候你可以直接操作打包产物了。

  8. 最后会调用 generateBundle 钩子,这个钩子的入参里面会包含所有的打包产物信息,包括 chunk (打包后的代码)、asset(最终的静态资源文件)。你可以在这里删除一些 chunk 或者 asset,最终这些内容将不会作为产物输出。

插件系统

由于 vite 的插件有一套简单的顺序控制机制,用户可以通过 enforce: 'pre'(在核心插件之前被调用) 和 enforce: 'post'(在核心插件之后被调用) 是用来强制修改插件的执行顺序。如果没有定义 enforce 属性那么插件将在 vite 核心插件之后被调用。

plugins: [
  testPlugin('post'),
  testPlugin(),
  testPlugin('pre')
],
import type { PluginOption } from 'vite'

export default function vitePluginTemplate(enforce?: 'pre' | 'post'): PluginOption {
   return {
    // 插件名称
    name: 'rollup-plugin-test',

    enforce,

    // 在每次开始构建时调用,只会调用一次
    buildStart (options) {
      console.log('buildStart', enforce)
    },
    // 在每个传入模块请求时被调用,创建自定义确认函数,可以用来定位第三方依赖
    resolveId (source, importer, options) {
      console.log('resolveId', enforce, source)
    },
    // 服务器关闭时
    buildEnd () {
      console.log('buildEnd', enforce)
    },

    // 指明它们仅在 'build' 或 'serve' 模式时调用
    apply: 'serve', // apply 亦可以是一个函数
    
    // 可以在 vite 被解析之前修改 vite 的相关配置。钩子接收原始用户配置 config 和一个描述配置环境的变量env
    config (config, env) {
      return {
        resolve: {
          alias: {
            '@aaa': enforce ? enforce : '/src/styles'
          }
        }
      }
    },
    // 在解析 vite 配置后调用。使用这个钩子读取和存储最终解析的配置。当插件需要根据运行的命令做一些不同的事情时,它很有用
    configResolved (config) {
      console.log(config.resolve)

    },
    // 主要用来配置开发服务器,为 dev-server (connect 应用程序) 添加自定义的中间件
    configureServer (server: any) {
      server.middlewares.use((req, res,next) => {
        if(req.url === '/test') {
            res.end('hello vite plugin')
        } else {
          next()
        }
      })
    },
    
    // 转换 index.html 的专用钩子。钩子接收当前的 HTML 字符串和转换上下文
    transformIndexHtml (html) {
      console.log(html)
      return html.replace('<div id="app"></div>', '<div id="root"></div>')
    },

    // 执行自定义HMR更新,可以通过ws往客户端发送自定义的事件
    handleHotUpdate (ctx) {
      ctx.server.ws.send({
        type: 'custom',
        event: 'test',
        data: {
          text: 'hello vite'
        }
      })
    }
  }
}

源码浅析

createServer

在执行 npm run dev 时在源码内部会调用 createServer 方法创建一个服务,这个服务利用中间件(第三方)支持了多种能力(如 跨域、静态文件服务器等),并且内部创建了 watcher 持续监听着文件的变更,进行实时编译和热重载。

// packages/vite/src/node/server/index.ts
export async function createServer(
    inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
    // 加载项目目录的配置文件 vite.config.js
    // 如果没有找到配置文件,则直接会中止程序。
    const config = await resolveConfig(inlineConfig, 'serve')

    // 创建http服务
    const httpServer = await resolveHttpServer(serverConfig, middlewares, httpsOptions)

    // 创建ws服务
    const ws = createWebSocketServer(httpServer, config, httpsOptions)

    // 创建watcher,设置代码文件监听
    const watcher = chokidar.watch(
      path.resolve(root),
      resolvedWatchOptions,
    ) as FSWatcher

    // 文件监听变动,websocket向前端通信
    watcher.on('change', async (file) => {
      // 进行实时编译和热重载
      await handleHMRUpdate(file, server)
    })
    
    // 创建插件容器,用于在构建的各个阶段调用插件的钩子
    // PluginContainer 内部调用每个插件的 buildStart 方法
    const container = await createPluginContainer(config, moduleGraph, watcher)

    // 创建server对象
    const server: ViteDevServer = {
      config,
      middlewares,
      httpServer,
      watcher,
      ws,
      listen,
      ...
    }

    // 注册各种中间件
    // request timer
    if (process.env.DEBUG) {
      middlewares.use(timeMiddleware(root))
    }

    const initServer = async () => {
      initingServer = (async function () {
        await container.buildStart({})
        // optimize: 预构建
        await initDepsOptimizer(config, server)
      })()
      return initingServer
    }
    
    // 监听端口,启动服务
    httpServer.listen = (async (port: number, ...args: any[]) => {
      await initServer()
      return listen(port, ...args)
    }) as any
    
    return server
}

runOptimizeDeps

第一次启动时,对项目依赖进行构建这里就会使用 esbuild.build 去编译文件,其中 esbuildDepPlugin 就是打包的插件

import { build } from 'esbuild'

export async function runOptimizeDeps() {
  const plugins = [...pluginsFromConfig]
  if (external.length) {
    plugins.push(esbuildCjsExternalPlugin(external, platform))
  }
  plugins.push(esbuildDepPlugin(flatIdDeps, external, config, ssr))

  const result = await build()
}

在这里插入图片描述

浏览器请求

在这里插入图片描述

可以看到 pluginContainer 会执行插件中的钩子。对于不同的资源会有不同的插件去处理。

HMR流程

打包工具实现热更新的思路都大同小异:主要是通过 WebSocket 创建浏览器和服务器的通信监听文件的改变,当文件被修改时,服务端发送消息通知客户端修改相应的代码,客户端对应不同的文件进行不同的操作的更新。

在这里插入图片描述

总结

以上针对 vite 从简单的原理、优点、源码、钩子函数、生命周期,经行了一个大体的介绍,文章字数较多,阅读需要花费较长的时间,但是读完之后一定会让你对 vite 这个前端构建工具有了更深层次的了解。由于篇幅限制具体的配置可以参考官网。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值