vite介绍 | 与其他构建工具做比较,分析vite预构建和热更新的原理

avatar
前端 @CVTE_希沃

什么是vite

Vite (法语意为 "快速的",发音 /vit/),新型前端构建工具。

为什么用vite

一句话总结:使用vite构建项目,启动的速度要比使用webpack构建更快。

  • 之前浏览器是不支持ES Modules的,为了在让浏览器能够运行我们写的代码(es6语法、.jsx/.vue文件),我们需要使用打包工具,例如webpack,来实现代码的转换和优化的过程;
  • 在浏览器支持ES Modules后,import、export、<script type='modules'>等成为vite出现的条件;
  • vite 主要对应的场景是开发模式,它只启动一台静态页面的服务器,对文件代码不打包,服务器会根据客户端的请求加载不同的模块处理;
  • 底层实现上,Vite 是基于 esbuild 预构建依赖的。当声明一个script标签类型为module时,浏览器将对其内部的import引用发起HTTP请求获取模块内容;
  • Vite 劫持了这些请求,并在后端进行相应的处理(如处理.ts文件为.js文件),然后再返回给浏览器;
  • 由于浏览器只会对用到的模块发起 HTTP 请求,所以 Vite 没必要对项目里所有的文件先打包后返回,而是只编译浏览器发起 HTTP 请求的模块即可。也就实现了所谓的按需加载。

与常见构建工具比较

与打包工具比较 —— webpack

这篇文章主要介绍vite,所以关于webpack相关的内容会写的比较简单。

冷启动时webpack做了什么

打包过程如下:

  • 通过配置文件,找到入口entry;
  • 从入口文件开始递归识别模块依赖,也就是遇到类似于require、import时,webpack都会对其分析,从而拿到对应的代码依赖;
  • 对拿到的代码进行分析、转换、编译,最后输出浏览器可以识别的代码。整个过程大概如下图(来自vite官网):

image.png

冷启动时vite做了什么

在项目根目录下有一个index.html文件,在其script标签设置type='module':

<script type="module" src="/src/index.tsx"></script>

启动项目,vite会启动一个dev server,来实现对前端访问请求的监听。
浏览器会向服务器发起一个GET请求,拿到index.tsx文件:
image.png

// http://localhost:3000/src/index.tsx请求index.tsx文件:
// /src/index.tsx文件

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(
    <App />,
  document.getElementById('root')
)

浏览器看到index.tsx文件内部含有import,就会对其import引用发起HTTP请求获取该模块的内容文件:
image.png
image.png
image.png
当你访问一个页面,他就会对当前页面的引用模块进行一个递归的请求,而和当前页面无关的模块则不会去请求,这样来实现按需加载
整个过程大概如下:
image.png

对于引用的模块,vite将其分为了 依赖源码 两类,对其进行不同的处理。

先来看一下对于源码的处理。我们打开请求的App.tsx文件,可以看到里面的内容已经不是源码了,而是经过处理后的代码。

// App.tsx
import React from 'react'

function App() {
  return (
    <div className="App">11111
    </div>
  )
}

export default App;

image.png
我们点开Headers,可以看到返回到文件类型已经是javascript:
image.png

从这我们也可以看出,vite在返回源码时,已经对源码做了一层处理,编译为浏览器可以运行的代码。实际上,对于不同的文件后缀,比如.ts、.vue、.tsx、.jsx,vite都会对其做不同的处理。下面是来自官网的内容:

vue文件

.ts文件

对于.ts文件,vite天然支持,其内部使用esbuild将TypeScript 转译到 JavaScript。

.jsx文件、.tsx文件

JSX 的转译同样是通过 esbuild,默认为 React 16 风格。

css文件

导入 .css 文件将会把内容插入到 标签中。

裸模块重写

从下面这张图中,我们可以看到,react.js、react-dom.js这种依赖模块文件名后面是有后缀的,而index.tsx、App.tsx等这种源代码文件是没有的。为什么会有这样的不同呢?这是因为react.js、react-dom.js是裸模块,而浏览器在加载文件时,只能加载相对地址,类似于import React from 'react';这种裸模块的加载,浏览器是不支持的,所以vite会对其做一层裸模块重写的处理,例如将引入react的url改写为/node_modules/.vite/react.js?v=a941774d
image.png

你会发现,引入的react是在.vite文件夹里头,而不是在node_modules的react文件夹中,这是因为vite做了一个优化 —— 依赖预构建

简单来说,vite会对package.json中的dependencies部分先进行构建,然后把构建后的文件换存在node_modules/.vite文件夹中,当启动项目时,直接请求该缓存内容。

预构建的作用

我们对App.tsx文件做一个小修改,引入lodash-es:

// App.tsx
import React, { useState } from 'react';
import _ from 'lodash-es';

function App() {
  const [num, setNum] = useState(0);

  const add = () => {
    const changeNum = () => {
      setNum(num+1);
    };
    const addNum = _.debounce(changeNum, 500);    
    addNum();
  };
  return (
    <div className="App">
      <button onClick={add}>add</button>
      {num}
    </div>
  );
}

export default App;

然后我们启动项目,会在终端看到这么个东西:
image.png
这告诉我们,react、react-dom、lodash这三个包会被预构建。
那预构建有啥作用呢?我们先来看看没有使用预构建的效果。在vite配置文件中设置:

// vite.config.ts 
optimizeDeps: {
    exclude: ['lodash-es']
}

然后打开页面康康network:
image.png
可以看到页面发了652个请求,大部分是和lodash-es相关的请求,一共花了3.68s,这可不是一个小数字,蛮长一时间。为什么会有这么多请求呢?因为通常我们引入的一些依赖,它自己又会引用一些其他依赖,如果没有使用预构建的话,那么就会因为模块间依赖引用过多导致过多的请求次数。

接着我们再把移除预构建的相关配置删掉,再次试试:image.png
可以看到这次和lodash-es相关的请求只有一个,并且所有请求所花时间只有543ms。
这就是预构建给冷启动带来的作用。

预构建实现原理

  1. **在开启dev server之前进行依赖预构建。**首先,vite会执行一个createServer函数来创建一个dev server:
// vite/src/node/server/index.ts
export async function createServer(
  inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  ...
	if (!middlewareMode && httpServer) {
    let isOptimized = false
    // 重写listen,确保server启动之前执行。
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (port: number, ...args: any[]) => {
      if (!isOptimized) {
        try {
          await container.buildStart({})
          // 执行预构建
          await runOptimize()
          isOptimized = true
        } catch (e) {
          httpServer.emit('error', e)
          return
        }
      }
      return listen(port, ...args)
    }) as any
  } else {
    await container.buildStart({})
    // 执行预构建
    await runOptimize()
  }
  ...
}

从这个函数我们可以看到,在dev server启动之前,vite会先调用runOptimize函数,来执行预构建。我们再看一下runOptimize函数:

// vite/src/node/server/index.ts  
const runOptimize = async () => {
    if (config.cacheDir) {
      // 设置构建状态的标志位为true
      server._isRunningOptimizer = true
      try {
        server._optimizeDepsMetadata = await optimizeDeps(config)
      } finally {
        // 设置构建状态的标志位为flase
        server._isRunningOptimizer = false
      }
      // 返回一个预构建函数可以随时进行预构建
      server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
  }

这个函数的主要作用是调用optimizeDeps函数。

  1. **读取项目的package.json和配置文件vite.config.js中optimizeDeps的参数,生成depHash,准备进行第一次预构建。**在node_modules/.vite中,有一个_metadata.json文件,这个文件就是用来提供预构建的相关信息:
{
  "hash": "101e2043",
  "browserHash": "16c8b19d",
  "optimized": {
    "react": {
      "file": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/.vite/react.js",
      "src": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/react/index.js",
      "needsInterop": true
    },
    "react-dom": {
      "file": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/.vite/react-dom.js",
      "src": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/react-dom/index.js",
      "needsInterop": true
    },
    "lodash-es": {
      "file": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/.vite/lodash-es.js",
      "src": "/Users/bokfang/Desktop/repositories/vite-demo/vite-project/node_modules/lodash-es/lodash.js",
      "needsInterop": false
    }
  }
}
  • hash是根据需要预构建的文件内容生成的,实现一个缓存的效果,在启动dev server时可以避免重复构建相同的依赖;
  • browserHash是由hash和在运行时发现额外依赖时生成的,用于使预构建的依赖的浏览器请求无效;
  • optimized是一个包含所有需要预构建的依赖的对象,src表示依赖的源文件,file表示构建后所在的路径;
  • needsInterop表示是否对CommoJS的依赖引入代码进行重写。比如,当我们在vite项目中使用react时:import React, { useState } from 'react',react的needsInterop为true,所以importAnalysisPlugin 插件的会对导入 react 的代码进行重写:
import $viteCjsImport1_react from "/@modules/react.js";
const React = $viteCjsImport1_react;
const useState = $viteCjsImport1_react["useState"];
const createContext = $viteCjsImport1_react["createContext"];

因为 CommonJS 的模块并不支持命名方式的导出,所以需要对其进行重写。如果不重写的话,则会看到以下报错:

Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'

接下来就是预构建的核心环节。

  1. **调用optimizeDeps函数实现预构建。**这个过程主要分为以下几步:
    • 调用getDepHash函数获取此时依赖的hash值,并与上次构建信息的hash值做对比,判定是否需要重新于构建:
  const dataPath = path.join(cacheDir, '_metadata.json')
  //	生成此次构建的hash值
  const mainHash = getDepHash(root, config)
  const data: DepOptimizationMetadata = {
    hash: mainHash,
    browserHash: mainHash,
    optimized: {}
  }
  // vite配置中的force参数决定是否每次都重新构建  
  if (!force) {
    let prevData
    try {
	  //	加载上次构建信息
      prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
    } catch (e) {}
     //		前后比对hash,如果相同则直接返回上一次的构建内容
    if (prevData && prevData.hash === data.hash) {
      return prevData
    }
  }
  • 如果前后hash值不相同,则表示没有命中缓存,vite会删除有缓存作用的cacheDir文件夹,如果是第一次依赖预构建,则创建一个新的cacheDir文件夹:
  if (fs.existsSync(cacheDir)) {
    emptyDir(cacheDir)
  } else {
    fs.mkdirSync(cacheDir, { recursive: true })
  }
  • 在服务启动后,如果有新的依赖加入时,会被放在newDeps中。接着vite使用esbuild的scanImports函数来扫描源码,找出与预构建相关的依赖deps对象:
  // newDeps参数是在服务启动后加入依赖时传入的依赖信息。
  let deps
  if (!newDeps) {
     //	使用esbuild扫描源码,获取依赖
    ;({ deps, missing } = await scanImports(config))
  } else {
    deps = newDeps
    missing = {}
  }
  • 加入vite.config.js配置中optimizeDeps.include 相关依赖:
  const include = config.optimizeDeps?.include
  if (include) {
    // 	...加入用户指定的include
    const resolve = config.createResolver({ asSrc: false })
    for (const id of include) {
      if (!deps[id]) {
        const entry = await resolve(id)
        if (entry) {
          deps[id] = entry
        } else {
          throw new Error(
            `Failed to resolve force included dependency: ${chalk.cyan(id)}`
          )
        }
      }
    }
  }
  • 使用esbuild构建依赖
  //	调用esbuild.build打包文件
  const result = await build({
     //	...
    entryPoints: Object.keys(flatIdDeps),// 入口
    format: 'esm',// 打包成esm模式
    external: config.optimizeDeps?.exclude,// 剔除exclude文件
    outdir: cacheDir,// 输出地址
     // ...
  })
  • 预构建完成后,重新写入_metadata.json
  //  重新写入_metadata.json
  for (const id in deps) {
    const entry = deps[id]
    data.optimized[id] = {
      file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
      src: entry,
    }
  }

  writeFile(dataPath, JSON.stringify(data, null, 2))


整个函数代码大致如下:

function optimizeDeps(config, force=config.server.force,asCommand=false,newDeps?) {
  // 	...
    
  const dataPath = path.join(cacheDir, '_metadata.json')
  //	生成此次构建hash
  const mainHash = getDepHash(root, config)
  const data: DepOptimizationMetadata = {
    hash: mainHash,
    browserHash: mainHash,
    optimized: {}
  }
  //	用户的force参数决定是否每次都重新构建  
  if (!force) {
    let prevData
    try {
	  //	加载上次构建信息
      prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
    } catch (e) {}
     //		前后比对hash,相同则直接返回
    if (prevData && prevData.hash === data.hash) {
      return prevData
    }
  }
  
  if (fs.existsSync(cacheDir)) {
    emptyDir(cacheDir)
  } else {
    fs.mkdirSync(cacheDir, { recursive: true })
  }
  
   // 	...

   // newDeps参数是在服务启动后加入依赖时传入的依赖信息。
  let deps
  if (!newDeps) {
     //	借助esbuild扫描源码,获取依赖
    ;({ deps, missing } = await scanImports(config))
  } else {
    deps = newDeps
    missing = {}
  }

  // 	...

  // 	...加入用户指定的include
  const resolve = config.createResolver({ asSrc: false })
  for (const id of include) {
    if (!deps[id]) {
      const entry = await resolve(id)
      if (entry) {
        deps[id] = entry
      } else {
        throw new Error(
          `Failed to resolve force included dependency: ${chalk.cyan(id)}`
        )
      }
    }
  }
    
   // 扁平化依赖
  await init
  for (const id in deps) {
      flatIdDeps[id]=//...
    // 	...
  }
    
  // 	...

  // 	...加入用户指定的esbuildOptions
  const { plugins = [], ...esbuildOptions } =
    config.optimizeDeps?.esbuildOptions ?? {}

  //	调用esbuild.build打包文件
  const result = await build({
     //	...
    entryPoints: Object.keys(flatIdDeps),// 入口
    format: 'esm',// 打包成esm模式
    external: config.optimizeDeps?.exclude,// 剔除exclude文件
    outdir: cacheDir,// 输出地址
     // ...
  })
  
  //  重新写入_metadata.json
  for (const id in deps) {
    const entry = deps[id]
    data.optimized[id] = {
      file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
      src: entry,
    }
  }

  writeFile(dataPath, JSON.stringify(data, null, 2))

  return data
}

热更新时webpack做了什么

总的来说,webpack的热更新就是,当我们对代码做修改并保存后,webpack会对修改的代码块进行重新打包,并将新的模块发送至浏览器端,浏览器用新的模块代替旧的模块,从而实现了在不刷新浏览器的前提下更新页面。相比起直接刷新页面的方案,HMR的优点是可以保存应用的状态。当然,随着项目体积的增长,热更新的速度也会随之下降。
以下是极客时间《玩转webpack》19 | webpack中的热更新及原理分析的截图:
image.png
其中,使用webpack冷启动项目的流程是1 -> 2 -> A -> B,热更新的流程是1 -> 2 -> 3 -> 4 -> 5。热更新的大致流程如下:

  • 编辑文件并保存后,webpack就会调用Webpack-complier对文件进行编译;
  • 编译完后传输给HMR Server,HMR得知某个模块发生变化后,就会通知HMR Runtime;
  • HMR Runtime就会加载要更新的模块,从而让浏览器实现更新并不刷新的效果。

热更新时vite做了什么

热更新主要与项目编写的源码有关。前面提到,对于源码,vite使用原生esm方式去处理,在浏览器请求源码文件时,对文件进行处理后返回转换后的源码。vite对于热更新的实现,大致可以分为三步:

  • 监听文件变动
  • 读取文件内容
  • 通知浏览器做相应的更新

热更新的实现原理

  1. 创建一个websocket服务端。vite执行createWebSocketServer函数,调用ws库创建ws服务端。


  1. 创建一个ws客户端来接收ws服务端的信息。vite首先会创建一个ws client文件,然后在处理入口文件index.html时,把对ws client文件的引入注入到index.html文件中。当浏览器访问index.html时,就会加载ws client文件并执行,创建一个客户端ws,从而接收ws服务端的信息。

  2. 服务端监听文件变化,发送websocket消息,通知客户端。


  1. 服务端调用handleHMRUpdate函数,该函数会根据此次修改文件的类型,通知客户端是要刷新还是重新加载文件。
  • 一个小细节:vite对于node_modules的文件做了强缓存,而对我们编写的源码做了协商缓存。

总结:vite为什么比webpack快

  • 构建速度快:Webpack 会先将代码打包,然后启动开发服务器,请求服务器时返回打包后的结果;而 Vite 是直接启动开发服务器,请求哪个模块再对该模块进行实时编译,省去了打包的过程。
  • 热更新快:相比起webpack,vite会让浏览器帮忙做更多的事情。vite 采用立即编译当前修改文件的办法,当改动了一个模块后,仅需让浏览器重新请求该模块即可。同时 vite 还会使用缓存机制( http 缓存、 vite 内置缓存 ),加载更新后的文件内容。

与非打包解决方案比较 —— snowpack

vite官网中也列举了vite和snowpack的异同。

相同

snowpack和vite都是非构建式原生 ESM 开发服务器。

不同

snowpack和vite的不同,更多的是在构建生产版本中。

vitesnowpack
多页面应用支持支持不支持
库模式支持不支持
自动分割 CSS 代码支持不支持
预优化的异步 chunk 加载支持不支持
对动态导入自动 polyfill支持不支持
依赖预构建速度相比vite较慢

vite的依赖预构建速度更快,是因为vite使用esbuild来实现预构建,而snowpack使用rollup来实现。

总结 —— 如何考虑是否接入vite

本文主要分析了vite的预构建和热更新,都是属于vite在开发环境下的特点,从分析的结果来看,vite确实能给项目开发时带来不小的提速,但实际上,在生产环境模式下,我们需要更注重“稳”。
所以,从我个人的角度来讲,我认为项目是否接入vite,要看该项目的启动时间、项目可承受的风险性等,而在生产环境中,更推荐使用稳定的打包工具,如webpack。所以整体思路可以是,vite用在开发环境,webpack用在生产环境。


参考内容