kangyana / daily-question

When your heart is set on something, you get closer to your goal with each passing day.
https://www.webpack.top
MIT License
3 stars 0 forks source link

【Q101】vite 原理 #101

Open kangyana opened 1 year ago

kangyana commented 1 year ago

为什么这么快?生产环境能用吗?

kangyana commented 1 year ago

1. Vite

Vite 是什么?

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

Vite 有如下特点:

Vite 和传统打包方式的对比

Alt

Vite VS Webapck

Webpack 是近年来使用量最大,同时社区最完善的前端打包构建工具,新出的5.x版本对构建细节进行了优化,在部分场景下打包速度提升明显。 Webpack 在启动时,会先构建项目模块的依赖图,如果在项目中的某个地方改动了代码,Webpack 则会对相关的依赖重新打包,随着项目的增大,其打包速度也会下降。

Vite 相比于 Webpack 而言,没有打包的过程,而是直接启动了一个开发服务器 devServer。 Vite 劫持浏览器的 HTTP 请求,在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再返回给浏览器(整个过程没有对文件进行打包编译)。 所以编译速度很快。

Vite VS SnowPack

Snowpack 首次提出利用浏览器原生 ESM 能力的打包工具,其理念就是减少或避免整个 bundle 的打包。 默认在 dev 和 production 环境都使用 unbundle 的方式来部署应用。 但是它的构建时却是交给用户自己选择,整体的打包体验显得有点支离破碎。

而 Vite 直接整合了 Rollup,为用户提供了完善、开箱即用的解决方案,并且由于这些集成,也方便扩展更多的高级功能。 两者较大的区别是在需要 bundle 打包的时候 Vite 使用 Rollup 内置配置,而 Snowpack 通过其他插件将其委托给 webpack。

2. 前置知识

ESM

在了解 Vite 之前,需要先了解下 ESM。

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

目前 ESM 模块化已经支持92%以上的浏览器,而且且作为 ECMA 标准,未来会有更多浏览器支持 ECMA 规范。

Alt

当我们在使用模块开发时,其实就是在构建一张模块依赖关系图,当模块加载时,就会从入口文件开始,最终生成完整的模块实例图。

ESM 的执行可以分为三个步骤:

从上面实例化的过程可以看出,ESM 使用实时绑定的模式,导出和导入的模块都指向相同的内存地址,也就是值引用。 而 CJS 采用的是值拷贝,即所有导出值都是拷贝值。

Esbuild

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

Esbuild是一个JavaScript`` Bundler 打包和压缩工具,它提供了与 Webpack、Rollup 等工具相似的资源打包能力。 可以将J avaScript 和 TypeScript 代码打包分发在网页上运行。 但其打包速度却是其他工具的10~100倍。

目前他支持以下的功能:

esbuild 总共提供了四个函数:transformbuildbuildSyncService

Rollup

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

Rollup 是基于 ESM 的 JavaScript 打包工具。 相比于其他打包工具如 Webpack,他总是能打出更小、更快的包。 因为 Rollup 基于 ESM 模块,比 Webpack 和 Browserify 使用的 CommonJS 模块机制更高效。 Rollup 的亮点在于同一个地方,一次性加载。 能针对源码进行 Tree Shaking (去除那些已被定义但没被使用的代码),以及 Scope Hoisting 以减小输出文件大小提升运行性能。

Rollup 分为 build(构建)阶段和 output generate(输出生成)阶段。 主要过程如下:

如果你的项目只有JavaScript,而没有其他的静态资源文件,使用 Webpack 就有点大才小用了。 因为 Webpack 打包的文件的体积略大,运行略慢,可读性略低。 这时候建议使用 Rollup。

kangyana commented 1 year ago

3. 核心原理

详细阐述下:

  1. 当声明一个 script 标签类型为 module 时,如:
    <script type="module" src="/src/main.js"></script>
  2. 当浏览器解析资源时,会往当前域名发起一个 GET 请求 main.js 文件:
    // main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    createApp(App).mount('#app')
  3. 请求到了 main.js 文件,会检测到内部含有 import 引入的包,又会 import 引用发起 HTTP 请求获取模块的内容文件,如App.vuevue文件。

Vite 其核心原理是利用浏览器现在已经支持 ES6 的 import,碰见 import 就会发送一个 HTTP 请求去加载文件。 Vite 启动一个 koa 服务器拦截这些请求,并在后端进行相应的处理将项目中使用的文件通过简单的分解与整合,然后再以 ESM 格式返回返回给浏览器。 Vite 整个过程中没有对文件进行打包编译,做到了真正的按需加载,所以其运行速度比原始的 webpack 开发编译速度快出许多!

基于 ESM 的 Dev server

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

Alt

而 Vite 利用浏览器对 ESM 的支持,当 import 模块时,浏览器就会下载被导入的模块。 先启动开发服务器,当代码执行到模块加载时再请求对应模块的文件,本质上实现了动态加载。 灰色部分是暂时没有用到的路由,所有这部分不会参与构建过程。 随着项目里的应用越来越多,增加 route,也不会影响其构建速度。

Alt

基于ESM 的 HMR 热更新

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

Vite VS Webpack

核心流程

Vite 整个热更新过程可以分成四步:

Alt

启动热更新:createWebSocketServer

在 Vite dev server 启动之前,Vite 会为 HMR 做一些准备工作: 比如创建 websocket 服务,利用 chokidar 创建一个监听对象 watcher 用于对文件修改进行监听等等,具体核心代码:

packages/vite/src/node/server/index.ts

export async function createServer(
  inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  ....
  const ws = createWebSocketServer(httpServer, config, httpsOptions)
  const { ignored = [], ...watchOptions } = serverConfig.watch || {}
  const watcher = chokidar.watch(path.resolve(root), {
    ignored: [
      '**/node_modules/**',
      '**/.git/**',
      ...(Array.isArray(ignored) ? ignored : [ignored])
    ],
    ignoreInitial: true,
    ignorePermissionErrors: true,
    disableGlobbing: true,
    ...watchOptions
  }) as FSWatcher
  ....
  watcher.on('change', async (file) => {

  })
  watcher.on('add', (file) => {
  })
  watcher.on('unlink', (file) => {
  })
  ...
  return server
}

createWebSocketServer这个方法主是创建 WebSocket 服务并对错误进行一些处理,最后返回封装好的 onoffsendclose 方法,用于后续服务端推送消息和关闭服务。

packages/vite/src/node/server/ws.ts

export function createWebSocketServer(
  server: Server | null,
  config: ResolvedConfig,
  httpsOptions?: HttpsServerOptions
): WebSocketServer {
  let wss: WebSocket
  let httpsServer: Server | undefined = undefined
  // 热更新配置
  const hmr = isObject(config.server.hmr) && config.server.hmr
  const wsServer = (hmr && hmr.server) || server
  // 普通模式
  if (wsServer) {
    wss = new WebSocket({ noServer: true })
    wsServer.on('upgrade', (req, socket, head) => {
      // 监听通过vite客户端发送的websocket消息,通过HMR_HEADER区分
      if (req.headers['sec-websocket-protocol'] === HMR_HEADER) {
        wss.handleUpgrade(req, socket as Socket, head, (ws) => {
          wss.emit('connection', ws, req)
        })
      }
    })
  } else { // 中间件模式
    // vite dev server in middleware mode
    wss = new WebSocket(websocketServerOptions)
  }
  wss.on('connection', (socket) => {
    ...
  })
  // 错误处理
  wss.on('error', (e: Error & { code: string }) => {
    ...
  })
  // 返回
  return {
    on: wss.on.bind(wss),
    off: wss.off.bind(wss),
    send(payload: HMRPayload) {
      ...
    },
    close() {
      ...
    }
  }
}

执行热更新:moduleGraph + handleHMRUpdate 模块

接收到文件改动执行的回调,这里主要两个操作:moduleGraph.onFileChange 修改文件的缓存和 handleHMRUpdate 执行热更新:

packages/vite/src/node/server/index.ts

 watcher.on('change', async (file) => {
    file = normalizePath(file)
    if (file.endsWith('/package.json')) {
      return invalidatePackageData(packageCache, file)
    }
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)
    if (serverConfig.hmr !== false) {
      try {
        await handleHMRUpdate(file, server)
      } catch (err) {
        ws.send({
          type: 'error',
          err: prepareError(err)
        })
      }
    }
  })

moduleGraph

moduleGraph 是 Vite 定义的用来记录整个应用的模块依赖图的类,除此之外还有 moduleNode

packages/vite/src/node/server/moduleGraph.ts

 watcher.on('change', async (file) => {
    file = normalizePath(file)
    if (file.endsWith('/package.json')) {
      return invalidatePackageData(packageCache, file)
    }
    // invalidate module graph cache on file change
    moduleGraph.onFileChange(file)
    if (serverConfig.hmr !== false) {
      try {
        await handleHMRUpdate(file, server)
      } catch (err) {
        ws.send({
          type: 'error',
          err: prepareError(err)
        })
      }
    }
  })

Alt

Alt

moduleGraph 是由一系列 map 组成,而这些 map 分别是 urlidfile 等与 ModuleNode 的映射, 而 ModuleNode 是 Vite 中定义的 最小模块单位。 通过这两个类可以构建下面的模块依赖图:

Alt

可以看看 moduleGraph.onFileChange 这个函数:主要是用来清空被修改文件对应的 ModuleNode 对象的 transformResult 属性,使之前的模块已有的转换缓存失效。 这块也就是 Vite 在热更新里的缓存机制。

packages/vite/src/node/server/moduleGraph.ts

onFileChange(file: string): void {
    const mods = this.getModulesByFile(file)
    if (mods) {
      const seen = new Set<ModuleNode>()
      mods.forEach((mod) => {
        this.invalidateModule(mod, seen)
      })
    }
  }

  invalidateModule(mod: ModuleNode, seen: Set<ModuleNode> = new Set()): void {
    mod.info = undefined
    mod.transformResult = null
    mod.ssrTransformResult = null
    invalidateSSRModule(mod, seen)
  }

handleHMRUpdate

handleHMRUpdate 模块主要是监听文件的更改,进行处理和判断通过 WebSocket 给客户端发送消息通知客户端去请求新的模块代码。

packages/vite/packages/vite/src/node/server/hmr.ts

Alt

客户端:websocket通信和更新处理

客户端:当我们配置了热更新且不是 ssr 的时候,Vite 底层在处理 html 的时候会把 HMR 相关的客户端代码写入在我们的代码中,如下:

Alt

当接收到服务端推送的消息,通过不同的消息类型做相应的处理,如(connectedupdatecustom...), 在实际开发热更新中使用最频繁的是 update (动态加载热更新模块)和 full-reload (刷新整个页面)事件。

packages/vite/packages/vite/src/client/client.ts

Alt

优化:浏览器的缓存策略提高响应速度

同时,Vite 还利用 HTTP 加速整个页面的重新加载。 设置响应头使得依赖模块(dependency module)进行强缓存,而源码文件通过设置 304 Not Modified 而变成可依据条件而进行更新。 若需要对依赖代码模块做改动可手动操作使缓存失效:

vite --force

或者手动删除 node_modules/.vite 中的缓存文件。

基于esbuild的依赖预编译优化

为什么需要预构建?

除此之外,我们常用的 lodash 工具库,里面有很多包通过单独的文件相互导入, 而 lodash-es 这种包会有几百个子模块,当代码中出现 import { debounce } from 'lodash-es' 会发出几百个 HTTP 请求, 这些请求会造成网络堵塞,影响页面的加载。

Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能。 通过预构建 lodash-es 成为一个模块,也就只需要一个 HTTP 请求了!

为什么使用 Esbuild?

Alt

引用尤大的一句话:“快”就一个字。

这是 Esbuild 首页的图。 新一代的打包工具,提供了与 Webpack、Rollup、Parcel 等工具相似的资源打包能力,但在时速上达到10~100倍的差距,耗时是 Webpack 2%~3%。

实现原理?

Vite 预编译之后,将文件缓存在 node_modules/.vite/ 文件夹下。根据以下地方来决定是否需要重新执行预构建。

如果想强制让 Vite 重新预构建依赖,可以使用 --force 启动开发服务器,或者直接删掉 node_modules/.vite/ 文件夹。

核心代码实现

const runOptimize = async () => {
    if (config.cacheDir) {
      server._isRunningOptimizer = true
      try {
        server._optimizeDepsMetadata = await optimizeDeps(
          config,
          config.server.force || server._forceOptimizeOnRestart
        )
      } finally {
        server._isRunningOptimizer = false
      }
      server._registerMissingImport = createMissingImporterRegisterFn(server)
    }
  }
if (!middlewareMode && httpServer) {
  let isOptimized = false
  // overwrite listen to run optimizer before server start
  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()
}

整体的流程图

核心代码都在 packages/vite/src/node/optimizer/index.ts 里面。

Alt

基于 Rollup 的 Plugins

Vite 从 preact 的 WMR 中得到了启发,将 Vite Plugins 继承 Rollup Plugins API, 在其基础上进行了一些扩展(如 Vite 特有的钩子等),同时 Vite 也基于 Rollup plugins 机制提供了强大的插件 API。 目前和 Vite 兼容或者内置的插件,可以查看 vite-rollup-plugins

Vite 插件是什么

使用 Vite 插件可以扩展 Vite 能力,通过暴露一些构建打包过程的一些时机配合工具函数,让用户可以自定义地写一些配置代码,执行在打包过程中。 比如解析用户自定义的文件输入,在打包代码前转译代码,或者查找。

在实际的实现中,Vite 仅仅需要基于 Rollup 设计的接口进行扩展,在保证兼容 Rollup 插件的同时再加入一些 Vite 特有的钩子和属性来进行扩展。

Vite 独有钩子

具体使用请移步这里

通用钩子

其它钩子使用请移步这里

钩子的调用顺序

Alt

Vite 的官网可以看出: Vite 插件可以用一个 enforce 属性(类似于 Webpack 加载器)来调整它的应用顺序。 enforce 的值可以是 prepost。 解析后的插件将按照以下顺序排列:

自定义插件

import myVitePlugin from '...'

export default defineConfig{ plugins:[vue(),myVitePlugin()] }

kangyana commented 1 year ago

4. 总结

最后总结下 Vite 相关的优缺点:

Vite.js 虽然才在构建打包场景兴起,但在很多场景下基本都会优于现有的解决方案。 如果有生态、想要丰富的 loaderplugins 的要求可以考虑成熟的 Webpack。 在其余情况下,Vite.js 不失为一个打包构建工具的好选择。