Skip to content

vite 下一代前端开发与构建工具

目录

  1. vite是什么、解决了什么问题
  2. vite原理
  3. vite插件

vite是什么

Vite是一种新型的前端构建工具,是尤雨溪在开发Vue3.0的时候诞生的。类似于webpack+webpack-dev-server。利用浏览器ESM特性导入组织代码,在服务端按需编译返回,完全跳过了打包这个概念;而生产环境则是利用Rollup作为打包工具,号称是下一代的前端构建工具。

  • 极速的服务启动使用原生的ESM文件,无需打包
  • 轻量快速的热重载无论应用程序大小如何,都始终极快的模块热重载(HMR)
  • 丰富的功能对 TypeScript、JSX、CSS 等支持开箱即用。
  • 优化的构建可选 “多页应用” 或 “库” 模式的预配置 Rollup 构建
  • 通用的插件在开发和构建之间共享 Rollup-superset 插件接口。
  • 完全类型化的API灵活的 API 和完整的 TypeScript 类型。

我们为什么需要vite

传统的打包工具如Webpack是先解析依赖、打包构建再启动开发服务器,Dev Server 必须等待所有模块构建完成,当我们修改了 bundle模块中的一个子模块, 整个 bundle 文件都会重新打包然后输出。项目应用越大,启动时间越长。

esbuild

vite是怎么做的

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

  • 依赖 大多为在开发时不会变动的纯 JavaScript。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。依赖也通常会存在多种模块化格式(例如 ESM 或者 CommonJS)。

    Vite 将会使用 esbuild 预构建依赖(处理CommonJs和UMD兼容性、合并http请求性能优化)。esbuild 使用 Go 编写,并且比以 JavaScript 编写的打包器预构建依赖快 10-100 倍。

  • 源码 通常包含一些并非直接是 JavaScript 的文件,需要转换(例如 JSX,CSS 或者 Vue/Svelte 组件),时常会被编辑。同时,并不是所有的源码都需要同时被加载(例如基于路由拆分的代码模块)。

Vite 以 原生 ESM 方式提供源码。这实际上是让浏览器接管了打包程序的部分工作:Vite 只需要在浏览器请求源码时进行转换并按需提供源码。根据情景动态导入代码,即只在当前屏幕上实际使用时才会被处理。

esbuild

ESM

在了解Vite之前,需要先了解下ESM,不同于之前的CJS,AMD,CMD等等,ESM提供了更原生以及更动态的模块加载方案,最重要的就是它是浏览器原生支持的,也就是说我们可以直接在浏览器中去执行import,动态引入我们需要的模块,而不是把所有模块打包在一起。

使用方式

html
<script type="module">
  import message from './message.js'
  console.log(message) // hello world
</script>

也就是说 浏览器可以通过 <script type="module"> 的方式和 import 的方式加载标准的 ES 模块

而且 模块只会执行一次并且默认为defer也支持async

传统的<script>如果引入的JS文件地址是一样的,则JS会执行多次。但是,对于type="module"<script>元素,即使模块地址一模一样,也只会执行一次。例如:

html
<!-- 1.mjs只会执行一次 -->
<script type="module" src="1.mjs"></script>
<script type="module" src="1.mjs"></script>
<script type="module">import "./1.mjs";</script>
<!-- 下面传统JS引入会执行2次 -->
<script src="2.js"></script>
<script src="2.js"></script>

esbuild

Vite底层使用Esbuild实现对.ts、jsx、.js代码文件的转化,所以先看下什么是es-build。

esbuild 号称是新一代的打包工具,提供了与WebpackRollupParcel 等工具相似的资源打包能力,但在时速上达到10~100倍的差距,耗时是Webpack2%~3%

这是Esbuild首页的对比图。

esbuild

为啥这么快

大多数前端打包工具都是基于 JavaScript 实现的,大家都知道JavaScript是解释型语言,边运行边解释。而 Esbuild 则选择使用 Go 语言编写,该语言可以编译为原生代码,在编译的时候都将语言转为机器语言,在启动的时候直接执行即可,在 CPU 密集场景下,Go 更具性能优势。

roolup

在生产环境下,Vite使用Rollup来进行打包

Rollup是基于ESM的JavaScript打包工具。它将小文件打包成一个大文件或者更复杂的库和应用,打包既可用于浏览器和Node.js使用。 Rollup最显著的地方就是能让打包文件体积很小。相比其他JavaScript打包工具,Rollup总能打出更小,更快的包。因为Rollup基于ESM,比Webpack和Browserify使用的CommonJS模块机制更高效。

vite原理

我们先看下vite在项目中的工作方式 拿一个demo项目来举例子

js
// index.html
 <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
 </body>

// main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

项目中分类两种依赖

  1. /srx/main.tsx相对地址的源码
  2. import React from 'react'非相对地址的依赖模块

第二种ESM是不支持的 import 对应的路径只支持 "/""./"或者 "../" 开头的内容,直接使用模块名 import,会立即报错。 那vite的这么处理的呢 我们来看下请求

react被替换成了 /node_modules/.vite/deps/react.js?v=c0a237c5

这就是vite的核心原理: Vite启动一个服务器拦截请求,并在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再以ESM格式返回返回给浏览器。

  1. 命令行启动服务npm run dev后,源码执行cli.ts,调用createServer方法,创建http服务,监听开发服务器端口。

    js
    const { createServer } = await import('./server')
    try {
        const server = await createServer({
            root,
            base: options.base,
            ...
        })
        if (!server.httpServer) {
            throw new Error('HTTP server not available')
        }
        await server.listen()
    }
  2. createServer方法的执行做了很多工作,如整合配置项、创建http服务 、创建WebSocket服务、创建源码的文件监听、插件执行、optimize优化等。

    Vite劫持httpserver默认的listen,在监听端口前先执行预构建

    js
    // overwrite listen to init optimizer before server start
        const listen = httpServer.listen.bind(httpServer)
        httpServer.listen = (async (port: number, ...args: any[]) => {
          if (!isOptimized) {
            try {
              await container.buildStart({})
              initOptimizer()
              isOptimized = true
            } catch (e) {
              httpServer.emit('error', e)
              return
            }
          }
          return listen(port, ...args)
        }) as any
  3. Vite会首先根据本次运行的入口,去扫描其中的依赖,最终根据分析出来的依赖,使用esbuild打包成单文件的bundle,存在node_modules/.vite下,并且,浏览器会给这些第三方依赖设置强缓存,只有当缓存依赖发生改变时,才会重新去更新文件。

  4. 浏览器是无法直接解析tsx、vue文件的,所以Vite还需要做的一件事就是文件编译。当浏览器发起tsx、vue、ts等请求时,Vite会使用esbuild作为文件类型的解析器,最后将编译后的文件返回给浏览器。

为啥需要预编译

依赖预编译,其实是 Vite 2.0 在为用户启动开发服务器之前,先用 esbuild 把检测到的依赖预先构建了一遍。

也许你会疑惑,不是一直说好的 no-bundle 吗,怎么还是走启动时编译这条路线了?

以导入 lodash-es 这个包为例。当你用 import { debounce } from 'lodash' 导入一个命名函数的时候,

可能你理想中的场景就是浏览器去下载只包含这个函数的文件。但其实没那么理想,debounce 函数的模块内部又依赖了很多其他函数,形成了一个依赖图。

当浏览器请求 debounce 的模块时,又会发现内部有 2 个 import,再这样延伸下去,这个函数内部竟然带来了 600 次请求,耗时会在 1s 左右。

再有就是去做模块化的兼容,对 CommonJS 模块进行分析,方便后面需要统一处理成浏览器可以执行的 ES Module

vite插件

使用Vite插件可以扩展Vite能力,比如解析用户自定义的文件输入,在打包代码前转译代码,或者查找第三方模块。

插件的形式

Vite插件扩展自Rollup插件接口,只是额外多了一些Vite特有选项。

Vite插件是一个拥有名称创建钩子(build hook)或生成钩子(output generate hook)的对象

js
export default {
    name: 'demo-plugin',
  	resolveId(id) {},
  	load(id){},
  	transform(code) {}
}

export default function (options) {
  return {
    name: 'demo-plugin',
  	resolveId(id) {},
  	load(id){},
  	transform(code) {}
  }
}

开发时,Vite dev server创建一个插件容器按照Rollup调用创建钩子的规则请求各个钩子函数。

通用钩子

下面钩子会在服务器启动时调用一次:

下面钩子每次有模块请求时都会被调用:

  • resolveId 创建自定义确认函数,常用于定位第三方依赖
  • load 创建自定义加载函数,可用于返回自定义的内容
  • transform 可用于转换已加载的模块内容

下面钩子会在服务器关闭时调用一次:

特有钩子

  • config: 修改Vite配置
  • configResolved:Vite配置确认
  • configureServer:用于配置dev server
  • transformIndexHtml:用于转换宿主页
  • handleHotUpdate:自定义HMR更新时调用

范例:钩子调用顺序测试

js

export default function demoPlugin () {
  // 定义vite插件唯一id
  const virtualFileId = '@demo-plugin'
  // 返回插件对象
  return {
    // 必须的,将会显示在 warning 和 error 中
    name: 'vite-plugin',

    // *以下钩子函数按照实际执行顺序排列*

    /**
     * config 可以在被解析之前修改 Vite 配置
     * Vite独有钩子
     * https://cn.vitejs.dev/guide/api-plugin.html#config
     * @param config vite配置信息
     * @param env 描述配置环境的变量
     */
    config: (config, env) => ({}),

    /**
     * configResolved 解析 Vite 配置后调用,使用这个钩子读取和存储最终解析的配置
     * Vite独有钩子
     * https://cn.vitejs.dev/guide/api-plugin.html#configresolved
     * @param config vite配置信息
     */
    configResolved: config => ({}),

    /**
     * options 替换或操作传递给rollup.rollup()的选项
     * 通用钩子
     * https://rollupjs.org/guide/en/#options
     * @param options rollup配置信息
     */
    options: options => ({}),

    /**
     * configureServer 用于配置开发服务器
     * Vite独有钩子
     * https://cn.vitejs.dev/guide/api-plugin.html#configureserver
     * @param server ViteDevServer配置信息
     * https://cn.vitejs.dev/guide/api-javascript.html#vitedevserver
     */
    configureServer: server => ({}),

    /**
     * buildStart 在每个rollup.rollup()构建时被调用
     * 通用钩子
     * https://rollupjs.org/guide/en/#buildstart
     * @param options rollup配置信息
     */
    buildStart: options => ({}),

    /**
     * 此时 Vite dev server is running
     */

    /**
     * transformIndexHtml 转换 index.html 的专用钩子
     * Vite独有钩子
     * https://cn.vitejs.dev/guide/api-plugin.html#transformindexhtml
     * @param html html字符串
     * @param ctx 转换上下文; 在开发期间会额外暴露ViteDevServer实例; 在构建期间会额外暴露Rollup输出的包
     */
    transformIndexHtml: (html, ctx) => ({}),

    /**
     * resolveId 用户自定义解析器
     * 通用钩子 会在每个传入模块请求时被调用
     * https://rollupjs.org/guide/en/#resolveid
     * @param source 源导入者 例子: import { foo } from '../bar.js', '../bar.js' 为source
     * @param importer 导入者所在文件绝对路径
     */
    resolveId: (source, importer) => ({}),

    /**
     * load 用户自定义加载器
     * 通用钩子 会在每个传入模块请求时被调用
     * https://rollupjs.org/guide/en/#load
     * @param id 同resolveId source
     */
    load: id => ({}),

    /**
     * transform 可以用来转换单个模块
     * 通用钩子 会在每个传入模块请求时被调用
     * https://rollupjs.org/guide/en/#transform
     * @param code 模块代码
     * @param id 同resolveId source
     */
    transform: (code, id) => ({})

  }
}

举例子 🌰 vite-plugin-env-command

typescript
/**
 * 
 * @param options?: {defaultEnv = 'dev', key = 'APP_ENV'}} 
 * @returns 
 */
export default function CommandSetEnv(options: Options) {
    const { defaultEnv = 'dev', key = 'APP_ENV' } = options;
    const commandArgs = getCommandArgv() || defaultEnv;

    return {
        name: 'vite-plugin-env-command',
        config: () => ({
            define: {
                'process.env': JSON.stringify({
                    [key]: commandArgs
                }),
            }
        })
    }
}

vite-plugin-vconsole

js
export function viteVConsole(opt: viteVConsoleOptions): Plugin {
  let viteConfig: ResolvedConfig;
  return {
    name: 'vite:vconsole',
    
    /**
    	一个 Vite 插件可以额外指定一个 enforce 属性(类似于 webpack 加载器)来调整它的应用顺序。enforce 的值可以是pre 或 post。解析后的插件将按照以下顺序排列:
      1. Alias
      2. 带有 enforce: 'pre' 的用户插件
      3. Vite 核心插件
      4. 没有 enforce 值的用户插件
      5. Vite 构建用的插件
    	6.  带有 enforce: 'post' 的用户插件
      7. Vite 后置构建插件(最小化,manifest,报告)
    */
    
    enforce: 'pre',
    configResolved(resolvedConfig) {
      viteConfig = resolvedConfig;
      isDev = viteConfig.command === 'serve';
    },
    transform(_source: string, id: string) {
      if (entryPath.includes(id) && localEnabled && isDev) {
        // serve dev
        return `/* eslint-disable */;import VConsole from 'vconsole';new VConsole(${JSON.stringify(
          config
        )});/* eslint-enable */${_source}`;
      }
      if (entryPath.includes(id) && enabled && !isDev) {
        // build prod
        return `/* eslint-disable */;import VConsole from 'vconsole';new VConsole(${JSON.stringify(
          config
        )});/* eslint-enable */${_source}`;
      }
      return _source;
    }
  };
}