• home > tools > Bundler > vite >

    新一代构建工具(3):Vite站在巨人的肩膀——vite快的理由

    Author:zhoulujun Date:

    Vite 的最强点之一便是它周围的生态系统。Vite 承担起了许多职责(通用的 Web 模式、全局导入、热更新 API、底层的服务端渲染、构建优化)

    Vite简介

    Vite是一个由原生 ES Module 驱动的 Web 开发构建工具。

    • 在开发环境下基于浏览器原生 ES imports 开发

    • 在生产环境下基于 Rollup 打包。

    Vite 的特点

    • Lightning fast cold server start  - 闪电般的冷启动速度

    • Instant hot module replacement (HMR) - 即时热模块更换(热更新)

    • True on-demand compilation - 真正的按需编译

    为了实现上述特点,Vite 要求项目完全由 ES Module 模块组成,common.js 模块不能直接在 Vite 上使用。因此不能直接在生产环境使用。在打包上依旧还是使用 rollup 等传统打包工具。因此 Vite 目前更像是一个类似于 webpack-dev-server 的开发工具.

    Vite 采用了 ES Module 来实现 模块的加载。目前基于 web 标准的ES Module 已经覆盖了超过90%的浏览器。


    Vite为什么这么快?


    Vite 实现

    请求拦截原理

    Vite 的基本实现原理,就是启动一个 koa 服务器拦截浏览器请求ES Module的请求。通过 path 找到目录下对应的文件做一定的处理最终以 ES Modules 格式返回给客户端

    Vite 的基本实现原理

    这里稍微提一下Vite 对 js/ts 的处理没有使用如 gulp, rollup 等传统打包工具,而是使用了 esbuild。esbuild 是一个全新的js打包工具,支持如babel, 压缩等的功能,他的特点是快(比 rollup 等工具会快上几十倍)!

    而快的主要原因是他使用了 go 作为底层语言(go 这样的静态语言会比 动态语言 快很多)。

    这里推荐阅读《is《新一代构建工具(1):对比rollup/parcel/esbuild—esbuild脱颖而出》里面内容:

    为什么 esbuild 这么快 ?

    为什么 esbuild 这么快 ?

    Vue 的官方团队成员Patak发布了一篇叫做《The Vite Ecosystem》的文章

    Vite周围的生态系统

    Vite 的最强点之一便是它周围的生态系统。Vite 承担起了许多职责(通用的 Web 模式全局导入热更新 API底层的服务端渲染构建优化),我们提供了一个共同的协作基础,这样其他维护者就不必每次都重复造轮子了。甚至还有几个流行框架的维护者选择了 Vite 来作为他们推荐的构建工具,并且他们现在还参与了 Vite 的开发,并直接修复了不少 bug 还新增了许多 feature。Vite 公开了一个灵活的JavaScript API,允许与RailsLaravel等后端框架或其他开发工具集成,如CypressStorybook。Vite 的插件 API 与 Rollup 兼容,使 Vite 能够有效的利用 Rollup 现有的插件生态系统。

    Vite 确实超出了我的预期:它现在不仅用于 Vue,还用于 React、Svelte、Solid、Marko、Astro、Shopify Hydrogen,以及与 Storybook、Laravel、Rails 等的集成… -尤雨溪


    Rollup

    Vite 可以被认为是一个开发服务器 + Rollup。Rollup 的核心维护者之一@lukastaegert推荐使用 Vite 来作为 Rollup 的 Web 开发服务器。Vite 兼容 Rollup 的插件系统为 Vite 提供了一个良好的开端。Rollup 的维护者在扩展他们的插件 API 时会与 Vite 及 WMR(译者注:WMR 是 Preact 团队开发出来的一款类似于 Vite 的项目) 的维护者保持联系以确保生态系统能够保持兼容。

    esbuild

    esbuild是一个用 Go 语言编写的打包构建工具,它突破了构建工具性能的极限。Vite 用 esbuild 来转译单个文件(去除 TS 类型并编译 JSX)并将其作为默认压缩工具(对于 JS 和 CSS 文件)。在开发期间预打包依赖项时,它还会被用作打包工具。@evanwallace(译者注:他是esbuild的作者)一直在做非常出色的工作。esbuild 每天都在改进,它为 Vite 提供了 tsc、babel 和 Rollup 的快速替代方案。

    Typescript

    Typescript 的出现席卷了整个 JS 世界。Vite支持导入.ts文件。在内部,我们会用 esbuild 来去除掉TS类型,这样可以避免在编译成js文件时做很复杂的类型校验。这对于获得最佳的热更新体验而言非常重要。如果您使用的是VS Code之类的现代 IDE,您将在编写代码时通过智能提示来获得大部分信息。您也可以在打包期间运行tsc命令来进行类型校验,或者使用像rollup-plugin-typescript2这样的插件。@fi3eworkvite-plugin-checker是另一个有趣的项目,允许您在 worker 线程中运行 TypeScript。

    babel

    在大多数情况下,Vite 并不需要babel,这样可以避免其繁重的抽象语法树转换。但是 React 生态系统严重依赖 babel 来实现热更新以及其他基于编译的解决方案,比如 CSS-in-JS 库。目前在@vitejs/plugin-react@vitejs/plugin-legacy 中使用它来提供对旧版本浏览器的支持。ParcelNext.js团队正在对 Rust 工具链最常用插件SWC进行移植。一旦时机成熟,Vite 可以从 babel 直接转移到 SWC(早期探索:基于 SWC 的@vitejs/plugin-legacyunplugin-swcvite-plugin-swc-react)。

    PostCSS

    Vite 鼓励使用PostCSS,并支持它开箱即用。其他 CSS 预处理器也可以通过手动将它们添加到项目依赖项中来支持。但是 PostCSS 更符合 Vite 的愿景,现在允许使用像postcss-nesting这样的CSS草案插件,让你的 CSS 标准在未来保持兼容。




    node_modules 模块的处理

    首先说一下 基于 ES Module 模块的局限性,在我们平时写代码时。如何不是相对路径的引用,而是直接引用一个 node_modules模块时,我们都是以如下的格式进行引用。

    import vue from 'vue'

    如 Webpack & gulp 等打包工具会帮我们找到模块的路径。但浏览器只能通过相对路径去寻找。为了解决这个问题,Vite对其做了一些特殊处理。以 Vite 官方 demo 为例,当我们请求 localhost:3000

    Vite打包工具会帮我们找到模块的路径

    Vite 先返回 index.html 代码, 渲染 index.html 后 发送请求src/main.js。 main.js 代码如下。

    import { createApp } from 'vue'
    import App from './App.vue'
    import './index.css'
    createApp(App).mount('#app')

    可以观察到浏览器请求 vue.js 时, 请求路径是 @modules/vue.js。 在 Vite 中约定若 path 的请求路径满足 /^/@modules// 格式时,被认为是一个 node_modules 模块。

    如何将代码中的  /:id 转化为 /@modules/:id

    Vite 对 ES module 形式的js文件模块的处理使用了 ES Module Lexer 处理。Lexer 会返回js文件中导入的模块并以数组形式返回。Vite 通过该数组判断是否为一个 node_modules 模块。若是则进行对应重写。

    // Plugin for rewriting served js.
    // - Rewrites named module imports to `/@modules/:id` requests, e.g.
    //   "vue" => "/@modules/vue"
    export const moduleRewritePlugin: ServerPlugin = ({
      root,
      app,
      watcher,
      resolver
    }) => {
      app.use(async (ctx, next) => {
        await initLexer
        const importer = removeUnRelatedHmrQuery(
          resolver.normalizePublicPath(ctx.url)
        )
        ctx.body = rewriteImports(
          root,
          content!,
          importer,
          resolver,
          ctx.query.t
        )
      }
    })

    我们还能有另一个形式进行一个ES module 形式的导入,那就是直接使用script标签,对于 script标签导入的模块也会有对应的处理。

      const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
      const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/  
      async function rewriteHtml(importer: string, html: string) {
        await initLexer
        html = html!.replace(scriptRE, (matched, openTag, script) => {
          if (script) {
          } else {
            const srcAttr = openTag.match(srcRE)
            if (srcAttr) {
              // register script as a import dep for hmr
              const importee = resolver.normalizePublicPath(
                cleanUrl(slash(path.resolve('/', srcAttr[1] || srcAttr[2])))
              )
              ensureMapEntry(importerMap, importee).add(importer)
            }
            return matched
          }
        })
        return injectScriptToHtml(html, devInjectionCode)
      }

    通过 /@modules/:id 在 node_modules 文件下找到对应模块

    浏览器发送 path 为 /@modules/:id 的对应请求后。会被 Vite 客户端做一层拦截,最终找到对应的模块代码进行返回。

    export const moduleRE = /^\/@modules\//
    // plugin for resolving /@modules/:id requests.
    app.use(async (ctx, next) => {
        if (!moduleRE.test(ctx.path)) {
          return next()
        }
        // path maybe contain encode chars
        const id = decodeURIComponent(ctx.path.replace(moduleRE, ''))
        ctx.type = 'js'
        const serve = async (id: string, file: string, type: string) => {
          moduleIdToFileMap.set(id, file)
          moduleFileToIdMap.set(file, ctx.path)
          debug(`(${type}) ${id} -> ${getDebugPath(root, file)}`)
          await ctx.read(file)
          return next()
        }   }
        // alias 
        const importerFilePath = importer ? resolver.requestToFile(importer) : root
        const nodeModulePath = resolveNodeModuleFile(importerFilePath, id)
        if (nodeModulePath) {
          return serve(id, nodeModulePath, 'node_modules')
        }
    })

    .vue 文件的处理

    当 Vite 遇到一个 .vue 后缀的文件时。由于 .vue 模板文件的特殊性,它被拆分成 template , css ,script 模块三个模块进行分别处理。最后会对 script ,template, css 发送多个请求获取。

    如上图 App.vue 获取script , App.vue?type=template 获取 template , App.vue?type=style。这些代码都被插入在app.vue 返回的代码中。

      if (descriptor.customBlocks) {
        descriptor.customBlocks.forEach((c, i) => {
          const attrsQuery = attrsToQuery(c.attrs, c.lang)
          const blockTypeQuery = `&blockType=${qs.escape(c.type)}`
          let customRequest =
            publicPath + `?type=custom&index=${i}${blockTypeQuery}${attrsQuery}`
          const customVar = `block${i}`
          code += `\nimport ${customVar} from ${JSON.stringify(customRequest)}\n`
          code += `if (typeof ${customVar} === 'function') ${customVar}(__script)\n`
        })
      }
      if (descriptor.template) {
        const templateRequest = publicPath + `?type=template`
        code += `\nimport { render as __render } from ${JSON.stringify(
          templateRequest
        )}`
        code += `\n__script.render = __render`
      }
      code += `\n__script.__hmrId = ${JSON.stringify(publicPath)}`
      code += `\n__script.__file = ${JSON.stringify(filePath)}`
      code += `\nexport default __script`


    静态资源(statics & asset & JSON )的加载

    当请求的路径符合 imageRE, mediaRE , fontsRE 或 JSON 格式,会被认为是一个静态资源。静态资源将处理成 ES Module 模块返回。

    // src/node/utils/pathUtils.ts
    const imageRE = /\.(png|jpe?g|gif|svg|ico|webp)(\?.*)?$/
    const mediaRE = /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/
    const fontsRE = /\.(woff2?|eot|ttf|otf)(\?.*)?$/i
    export const isStaticAsset = (file: string) => {
      return imageRE.test(file) || mediaRE.test(file) || fontsRE.test(file)
    }
    
    // src/node/server/serverPluginAssets.ts
    app.use(async (ctx, next) => {
        if (isStaticAsset(ctx.path) && isImportRequest(ctx)) {
          ctx.type = 'js'
          ctx.body = `export default ${JSON.stringify(ctx.path)}`
          return
        }
        return next()
    })
    
    export const jsonPlugin: ServerPlugin = ({ app }) => {
      app.use(async (ctx, next) => {
        await next()
        // handle .json imports
        // note ctx.body could be null if upstream set status to 304
        if (ctx.path.endsWith('.json') && isImportRequest(ctx) && ctx.body) {
          ctx.type = 'js'
          ctx.body = dataToEsm(JSON.parse((await readBody(ctx.body))!), {
            namedExports: true,
            preferConst: true
          })
        }
      })
    }


    热更新(Hot Module Reload)原理

    Vite 的热加载原理,其实就是在客户端与服务端建立了一个 websocket 链接,当代码被修改时,服务端发送消息通知客户端去请求修改模块的代码,完成热更新。

    服务端原理

    服务端做的就是监听代码文件的改变,在合适的时机向客户端发送 websocket 信息通知客户端去请求新的模块代码。

    客户端原理

    Vite的 websocket 相关代码在 处理 html 中时被写入代码中。

    export const clientPublicPath = `/vite/client`
    
    const devInjectionCode = `\n<script type="module">import "${clientPublicPath}"</script>\n`
      async function rewriteHtml(importer: string, html: string) {
        return injectScriptToHtml(html, devInjectionCode)
    }


    当request.path 路径是 /vite/client 时,请求得到对应的客户端代码,因此在客户端中我们创建了一个 websocket 服务并与服务端建立了连接。

    Vite 会接受到来自客户端的消息。通过不同的消息触发一些事件。做到浏览器端的即时热模块更换(热更新)。

    // Listen for messages
    socket.addEventListener('message', async ({ data }) => {
      const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
      if (payload.type === 'multi') {
        payload.updates.forEach(handleMessage)
      } else {
        handleMessage(payload)
      }
    })
    async function handleMessage(payload: HMRPayload) {
      const { path, changeSrcPath, timestamp } = payload as UpdatePayload
      console.log(path)
      switch (payload.type) {
        case 'connected':
          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':
          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')) {
            // if html file is edited, only reload the page if the browser is
            // currently on that page.
            const pagePath = location.pathname
            if (
              pagePath === path ||
              (pagePath.endsWith('/') && pagePath + 'index.html' === path)
            ) {
              location.reload()
            }
            return
          } else {
            location.reload()
          }
      }
    }


    参考文章:

    Vite 原理分析 https://juejin.cn/post/6881078539756503047

    字节前端是如何基于 ESBuild 的做现代化打包设计? https://mp.weixin.qq.com/s/bS_qwiOIMqFN1sfuPKTUbA



    转载本站文章《新一代构建工具(3):Vite站在巨人的肩膀——vite快的理由》,
    请注明出处:https://www.zhoulujun.cn/html/tools/Bundler/vite/8772.html