Vite自动对代码执行检验、转换、压缩等功能的构建工具

  一、Vite简介

  1.1、什么是ViteVite是一种新型的前端构建工具,它能显著改善前端开发体验。

  Vite由两个主要部分组成:

  dev server:利用浏览器的ESM能力来提供源文件,具有丰富的内置功能并具有高效的HMR

  生产构建:生产环境利用Rollup来构建代码,提供指令用来优化构建过程

  Vite作为一个基于浏览器原生ESM的构建工具,它省略了开发环境的打包过程,利用浏览器去解析imports,在服务端按需编译返回。同时,在开发环境拥有速度快到惊人的模块热更新,且热更新的速度不会随着模块增多而变慢。因此,使用Vite进行开发css 加载层,至少会比Webpack快10倍左右。

  1.2 、Vite的主要特性1.3、 主流构建工具对比

  构建工具指能自动对代码执行检验、转换、压缩等功能的工具。常见功能包括:代码转换、代码打包、代码压缩、HMR、代码检验。构建工具也随着前端技术的发展,从Browserify、Gulp到Parcel,从Webpack到Rollup,一直到最近比较火的面向非打包的Snowpack和Vite。

  Browserify

  Gulp

  Parcel

  Webpack

  Rollup

  Snowpack

  Vite

  1.4、 为什么要使用Vite

  开发环境⚡️速度的提升

  经过1.3节,我们简单对比了各打包工具之间的差异。可以看到使用JS开发的工具通常需要很长的时间才能启动开发服务器,且这个启动时间与代码量、代码复杂度正相关。即使使用HMR,文件修改后的效果也要几秒钟才能在浏览器中反应出来,代表如Webpack。那么Vite是如何解决如Webpack这样的构建工具一样,在复杂、多模块项目开发中启动慢、HMR慢的问题呢?

  我们详细对比了开发环境中的Vite和Webpack,发现主要有如下不同:

  WebpackVite

  先打包生成bundle,再启动开发服务器

  先启动开发服务器,利用新一代浏览器的ESM能力,无需打包,直接请求所需模块并实时编译

  HMR时需要把改动模块及相关依赖全部编译

  HMR时只需让浏览器重新请求该模块,同时利用浏览器的缓存(源码模块协商缓存,依赖模块强缓存)来优化请求

  内存高效利用

  -

  因此,针对开发环境中的启动慢问题,Vite开发环境冷启动无需打包,无需分析模块之间的依赖,同时也无需在启动开发服务器前进行编译,启动时还会使用esbuild来进行预构建。而Webpack 启动后会做一堆事情,经历一条很长的编译打包链条,从入口开始需要逐步经历语法解析、依赖收集、代码转译、打包合并、代码优化,最终将高版本的、离散的源码编译打包成低版本、高兼容性的产物代码,这可满满都是 CPU、IO 操作啊,在 Node 运行时下性能必然是有问题。

  针对HMR慢,即使只有很小的改动,Webpack依然需要构建完整的模块依赖图,并根据依赖图来进行转换。而Vite利用了ESM和浏览器缓存技术css 加载层,更新速度与项目复杂度无关。可以看到,如Snowpack、Vite这类面相非打包的构建工具,在开发环境启动时只需要启动两个Server,一个用于页面加载,一个用于HMR的Websocket。当浏览器发出原生的ESM请求,Server收到请求只需要编译当前文件后返回给浏览器,不需要管理依赖。

  css加载微软雅黑字体_css 加载层_css 加载层

  css 加载层_css加载微软雅黑字体_css 加载层

  使用简单,开箱即用

  相比Webpack需要对entry、loader、plugin等进行诸多配置,Vite的使用可谓是相当简单了。只需执行初始化命令,就可以得到一个预设好的开发环境,开箱即获得一堆功能,包括:CSS预处理、html预处理、异步加载、分包、压缩、HMR等。他使用复杂度介于Parcel和Webpack的中间,只是暴露了极少数的配置项和plugin接口,既不会像Parcel一样配置不灵活,又不会像Webpack一样需要了解庞大的loader、plugin生态,灵活适中、复杂度适中。适合前端新手。

  1.5、 Vite 开发环境 VS 生产环境

  在1.3节主流工具对比时我们可以看到Vite的开发环境和生产环境具有较大的差异性。

  开发环境不需要对所有资源打包,只是使用esbuild对依赖进行预构建,将CommonJS和UMD发布的依赖转换为浏览器支持的ESM,同时提高了后续页面的加载性能(lodash的请求)。Vite会将于构建的依赖缓存到node_modules/.vite目录下,它会根据几个源来决定是否需要重新运行预构建,包括 packages.json中的dependencies列表、包管理器的lockfile、可能在vite.config.js相关字段中配置过的。只要三者之一发生改变,才会重新预构建。

  同时,开发环境使用了浏览器缓存技术,解析后的依赖请求以http头的max-age=31536000,immutable强缓存,以提高页面性能。

  在生产环境,由于嵌套导入会导致发送大量的网络请求,即使使用HTTP2.x(多路复用、首部压缩),在生产环境中发布未打包的ESM仍然性能低下。因此,对比在开发环境Vite使用esbuild来构建依赖,生产环境Vite则使用了更加成熟的Rollup来完成整个打包过程。因为esbuild虽然快,但针对应用级别的代码分割、CSS处理仍然不够稳定,同时也未能兼容一些未提供ESM的SDK。

  为了在生产环境中获得最佳的加载性能,仍然需要对代码进行tree-shaking、懒加载以及chunk分割(以获得更好的缓存)。

  二、Vite原理2.1 、ESM&esbuild

  ESM

  在ES6没有出现之前,随着js代码日益膨胀,往往会对资源模块化来提效,这也就出现了多个模块化方案。如CommonJS常用于服务端,AMD、CMD规范常用在客户端。ES6出现后,紧接着出现了ESM。ESM是浏览器支持的一种模块化方案,允许在浏览器实现模块化。

  与CommonJS、AMD不同,ESM的对外接口只是一种静态定义,为编译时加载,遇到模块加载命令import,就会生成一个只读引用。等脚本真正执行时,再根据这个只读引用,到被加载的那个模块内取值。由于ESM编译时就能确定模块的依赖关系,因此能够只包含要运行的代码,可以显著减少文件体积,降低浏览器压力。

  由于ESM是一个比较新的模块化方案,目前其浏览器能力支持如下:

  css加载微软雅黑字体_css 加载层_css 加载层

  可以看到,除了IE、Opera等,新一代浏览器中绝大部分都已支持。

  接下来以Vite创建的模板为例,看一下ESM的解析过程:

  <pre style="overflow-wrap: initial;background: rgb(246, 246, 246);border-radius: 4px;font-size: 0.9em;overflow: auto;padding: calc(0.888889em);word-break: initial;"> import HelloWorld from './components/HelloWorld.vue' export default { name: 'App', components: { HelloWorld } } </pre>

  当浏览器解析 import HelloWorld from './components/HelloWorld.vue' 时,会向当前域名发送一个请求获取对应的资源(ESM支持解析相对路径)。

  css 加载层_css 加载层_css加载微软雅黑字体

  浏览器下载对应的文件,然后解析成模块记录。接下来会进行实例化,为模块分配内存,然后按照导入、导出语句建立模块和内存的映射关系。最后,运行上述代码,把内存空间填充为真实的值。

  esbuild

  Vite 对 js/ts 的处理没有使用如 glup, rollup 等传统打包工具,而是使用了 esbuild。esbuild 是一个全新的js打包工具,底层使用了go,大量使用了并行操作,可以充分利用CPU资源。esbuild支持如babel, 压缩等的功能。

  对比各打包工具性能,可以看到esbuild比rollup等工具快十几倍。

  css 加载层_css 加载层_css加载微软雅黑字体

  2.2、请求拦截

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

  css加载微软雅黑字体_css 加载层_css 加载层

  2.2.1、依赖处理

  Vite 通过在一开始将应用中的模块区分为依赖和源码两类,改进了开发服务器启动时间。依赖大多为在开发时不会变动的纯 JavaScript。一些较大的依赖(例如有上百个模块的组件库)处理的代价也很高。

  以 Vite 官方 demo 为例,当我们请求localhost:3000时,Vite 默认返回localhost:3000/index.html的代码。而后发送请求src/main.js。

  main.js 代码如下:

  <pre style="overflow-wrap: initial;background: rgb(246, 246, 246);border-radius: 4px;font-size: 0.9em;overflow: auto;padding: calc(0.888889em);word-break: initial;">import { createApp } from 'vue' import App from './App.vue' import './index.css' createApp(App).mount('#app')</pre>

  css 加载层_css 加载层_css加载微软雅黑字体

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

  平时开发中,webpack & rollup(rollup有对应插件) 等打包工具会帮我们找到模块的路径,但浏览器只能通过相对路径去寻找,而如果是直接使用模块名比如:import vue from 'vue',浏览器就会报错,这个时候就需要一个三方包进行处理。Vite 对ESM形式的 js 文件模块使用了 ES Module Lexer 处理。Lexer 会找到代码中以 import 语法导入的模块并以数组形式返回。Vite 通过该数组的值获取判断是否为一个 node_modules 模块。若是则进行对应改写成 @modules/:id 的写法。

  重写完路径后,浏览器会发送 path 为 /@modules/:id 的对应请求,接下来会被 Vite 客户端做一层拦截来解析模块的真实位置。

  首先正则匹配请求路径,如果是/@modules开头就进行后续处理,否则就跳过。若是,会设置响应类型为js,读取真实模块路径内容,返回给客户端。

  客户端注入本质上是创建一个script标签(type='module'),然后将其插入到head中,这样客户端在解析html是就可以执行代码了

  <pre style="overflow-wrap: initial;background: rgb(246, 246, 246);border-radius: 4px;font-size: 0.9em;overflow: auto;padding: calc(0.888889em);word-break: initial;">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) => { // 在代码中做一个缓存,下次访问相同路径直接从 map 中获取 304 返回 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) // 如果是个 node_modules 的模块,读取文件。 if (nodeModulePath) { return serve(id, nodeModulePath, 'node_modules') } }) </pre>

  依赖预构建主要有两个目的:

  Vite使用esbuild在初次启动开发服务器前把检测到的依赖进行预构建。Vite 基于ESM,在使用某些模块时,由于模块依赖了另一些模块,依赖的模块又基于另一些模块。会出现页面初始化时一次发送数百个模块请求的情况。

  以 lodash-es 为例,代码中以 import { debounce } from 'lodash' 导入一个命名函数时候,并不是只下载包含这个函数的文件,而是有一个依赖图。

  css加载微软雅黑字体_css 加载层_css 加载层

  可以看到一共发送了651个请求。一共花费1.53s。

  Vite 为了优化这个情况,利用esbuild在启动的时候预先把debounce用到的所有内部模块全部打包成一个bundle,这样就浏览器在请求debounce时,便只需要发送一次请求了

  可以看到预构建后,只发送了14个请求。

  css 加载层_css 加载层_css加载微软雅黑字体

  2.2.2、静态资源加载

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

  <pre style="overflow-wrap: initial;background: rgb(246, 246, 246);border-radius: 4px;font-size: 0.9em;overflow: auto;padding: calc(0.888889em);word-break: initial;">// 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)} // 输出是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 }) } }) } </pre>

  2.2.3、vue文件缓存

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

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

  css 加载层_css加载微软雅黑字体_css 加载层

  2.2.4、 js/ts处理

  Vite使用esbuild将ts转译到js,约是tsc速度的20~30倍,同时HMR更新反应到浏览器的时间会小于50ms。但是,由于esbuild转换ts到js对于类型操作仅仅是擦除,所以完全保证不了类型正确,因此需要额外校验类型,比如使用tsc --noEmit。

  将ts转换成js后,浏览器便可以利用ESM直接拿到js资源。

  2.3、 热更新原理

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

  <pre style="overflow-wrap: initial;background: rgb(246, 246, 246);border-radius: 4px;font-size: 0.9em;overflow: auto;padding: calc(0.888889em);word-break: initial;">export const clientPublicPath = /vite/client const devInjectionCode = nimport "${clientPublicPath}"n async function rewriteHtml(importer: string, html: string) { return injectScriptToHtml(html, devInjectionCode) } </pre>

  当request.path 路径是 /vite/client 时,请求获取已经提前写好的关于 websocket 的代码。因此在客户端中我们创建了一个 websocket 服务并与服务端建立了连接。

  Vite 会接受到来自客户端的消息。通过不同的消息触发一些事件。做到浏览器端的即时热模块更换(热更新)。包括 connect、vue-reload、vue-rerender 等事件,分别触发组件vue 的重新加载,render等。

  <pre style="overflow-wrap: initial;background: rgb(246, 246, 246);border-radius: 4px;font-size: 0.9em;overflow: auto;padding: calc(0.888889em);word-break: initial;">// 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 const el = document.querySelector(link[href*='${path}']) if (el) { el.setAttribute( 'href', ${path}${path.includes('?') ? '&' : '?'}t=${timestamp} ) break } const importQuery = path.includes('?') ? '&import' : '?import' await import(${path}${importQuery}&t=${timestamp}) console.log([vite] ${path} updated.) 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() } } } </pre>

  三、问题

  1、构建工具和打包工具的区别?

  构建过程应该包括 预编译、语法检查、词法检查、依赖处理、文件合并、文件压缩、单元测试、版本管理等 。打包工具更注重打包这一过程,主要包括依赖管理和版本管理。

  2、Vite有什么缺点?

  3、Vite生产环境用了Rollup,那能在生产环境中直接使用 esm 吗?

  4、对于一些 没有产出 commonjs 的模块,如何去兼容呢?

  首先业界是有一些如 lebab 的方法可以将 commjs 代码快速转化为 esm 的,但是对于一些格式不规范的代码,可能还是需要单独处理。

  5、如果组件嵌套层级比较深,会影响速度吗?

文章由官网发布,如若转载,请注明出处:https://www.veimoz.com/1685
0 评论
686

发表评论

!