前端三大构建工具横评,谁是性能之王!,前端程序员秋招三面蚂蚁金服

65 阅读10分钟

} catch (err) {

logger.error(err.message);

logger.debug(err.stack);

process.exit(1);

}

if (commandOptions.config.buildOptions.watch) {

// We intentionally never want to exit in watch mode!

returnnewPromise(() => {});

}

}

所有的模块会经过install进行安装,此处的安装是将模块转换成ESM放在pkg目录下,并不是npm包安装的概念。

在Snowpack3中增加了一些老版本不支持的能力,如:内部默认集成Node服务、支持CSS Modules、支持HMR等。

Vite


什么是Vite?

Vite(法语单词“ fast”,发音为/vit/)是一种构建工具,旨在为现代Web项目提供更快,更精简的开发体验。它包括两个主要部分:

  1. 开发服务器,它在本机ESM上提供了丰富的功能增强,例如,极快的Hot Module Replacement(HMR)。

  2. 构建命令,它将代码使用Rollup进行构建。

随着vue3的推出,Vite也随之成名,起初是一个针对Vue3的打包编译工具,目前2.x版本发布面向了任何前端框架,不局限于Vue,在Vite的README中也提到了在某些想法上参考了Snowpack。

在已有方案上Vue本可以抛弃Webpack选择Snowpack,但选择开发Vite来造一个新的轮子也有Vue团队自己的考量。

在Vite官方文档列举了Vite与Snowpack的异同,其实本质是说明Vite相较于Snowpack的优势。

相同点,引用Vite官方的话:

Snowpack is also a no-bundle native ESM dev server that is very similar in scope to Vite。

不同点:

  1. Snowpack的build默认是不打包的,好处是可以灵活选择Rollup、Webpack等打包工具,坏处就是不同打包工具带来了不同的体验,当前ESbuild作为生产环境打包尚不稳定,Rollup也没有官方支持Snowpack,不同的工具会产生不同的配置文件;

  2. Vite支持多page打包;

  3. Vite支持Library Mode;

  4. Vite支持CSS代码拆分,Snowpack默认是CSS in JS;

  5. Vite优化了异步代码加载;

  6. Vite支持动态引入 polyfill;

  7. Vite官方的 legacy mode plugin,可以同时生成 ESM 和 NO ESM;

  8. First Class Vue Support。

第5点Vite官网有详细介绍,在非优化方案中,当A导入异步块时,浏览器必须先请求并解析,A然后才能确定它也需要公共块C。这会导致额外的网络往返:

Entry ---> A ---> C

Vite通过预加载步骤自动重写代码拆分的动态导入调用,以便在A请求时并行C获取:

Entry ---> (A + C)

可能C会多次导入,这将导致在未优化的情况下发出多次请求。Vite的优化将跟踪所有import,以完全消除重复请求,示意图如下:

第8点的First Class Vue Support,虽然在列表的最后一项,实则是点睛之笔。

源码分析

Vite在启动时,如果不是中间件模式,内部默认会启一个http server。

exportasyncfunction createServer(

inlineConfig: InlineConfig = {}

): Promise {

// 获取 config

const config = await resolveConfig(inlineConfig, 'serve', 'development')

const root = config.root

const serverConfig = config.server || {}

// 判断是否是中间件模式

const middlewareMode = !!serverConfig.middlewareMode

const middlewares = connect() as Connect.Server

// 中间件模式不创建 http 服务,允许外部以中间件形式调用:Vitejs.dev/guide/api-j…

const httpServer = middlewareMode

? null

: await resolveHttpServer(serverConfig, middlewares)

// 创建 websocket 服务

const ws = createWebSocketServer(httpServer, config)

// 创建文件监听器

const { ignored = [], ...watchOptions } = serverConfig.watch || {}

const watcher = chokidar.watch(path.resolve(root), {

ignored: ['/node_modules/', '/.git/', ...ignored],

ignoreInitial: true,

ignorePermissionErrors: true,

...watchOptions

}) as FSWatcher

const plugins = config.plugins

const container = await createPluginContainer(config, watcher)

const moduleGraph = new ModuleGraph(container)

const closeHttpServer = createSeverCloseFn(httpServer)

const server: ViteDevServer = {

// 前面定义的常量,包含:config、中间件、websocket、文件监听器、ESbuild 等

}

// 监听进程关闭

process.once('SIGTERM', async () => {

try {

await server.close()

} finally {

process.exit(0)

}

})

watcher.on('change', async (file) => {

file = normalizePath(file)

// 文件更改时使模块图缓存无效

moduleGraph.onFileChange(file)

if (serverConfig.hmr !== false) {

try {

// 大致逻辑是修改 env 文件时直接重启 server,根据 moduleGraph 精准刷新,必要时全部刷新

await handleHMRUpdate(file, server)

} catch (err) {

ws.send({

type: 'error',

err: prepareError(err)

})

}

}

})

// 监听文件创建

watcher.on('add', (file) => {

handleFileAddUnlink(normalizePath(file), server)

})

// 监听文件删除

watcher.on('unlink', (file) => {

handleFileAddUnlink(normalizePath(file), server, true)

})

// 挂载插件的服务配置钩子

const postHooks: ((() => void) | void)[] = []

for (const plugin of plugins) {

if (plugin.configureServer) {

postHooks.push(await plugin.configureServer(server))

}

}

// 加载多个中间件,包含 cors、proxy、open-in-editor、静态文件服务等

// 运行post钩子,在html中间件之前应用的,这样外部中间件就可以提供自定义内容取代 index.html

postHooks.forEach((fn) => fn && fn())

if (!middlewareMode) {

// 转换 html

middlewares.use(indexHtmlMiddleware(server, plugins))

// 处理 404

middlewares.use((_, res) => {

res.statusCode = 404

res.end()

})

}

// errorHandler 中间件

middlewares.use(errorMiddleware(server, middlewareMode))

// 执行优化逻辑

const runOptimize = async () => {

if (config.optimizeCacheDir) {

// 将使用 ESbuild 将依赖打包并写入 node_modules/.Vite/xxx

await optimizeDeps(config)

// 更新 metadata 文件

const dataPath = path.resolve(config.optimizeCacheDir, 'metadata.json')

if (fs.existsSync(dataPath)) {

server._optimizeDepsMetadata = JSON.parse(

fs.readFileSync(dataPath, 'utf-8')

)

}

}

}

if (!middlewareMode && httpServer) {

// 在服务器启动前覆盖listen方法并运行优化器

const listen = httpServer.listen.bind(httpServer)

httpServer.listen = (async (port: number, ...args: any[]) => {

await container.buildStart({})

await runOptimize()

return listen(port, ...args)

}) as any

httpServer.once('listening', () => {

// 更新实际端口,因为这可能与初始端口不同

serverConfig.port = (httpServer.address() as AddressInfo).port

})

} else {

await runOptimize()

}

// 最后返回服务

return server

}

访问Vite服务的时候,默认会返回index.html:

Vite App

处理 import 文件逻辑,在 node/plugins/importAnalysis.ts 文件内:

exportfunction importAnalysisPlugin(config: ResolvedConfig): Plugin {

const clientPublicPath = path.posix.join(config.base, CLIENT_PUBLIC_PATH)

let server: ViteDevServer

return {

name: 'Vite:import-analysis',

configureServer(_server) {

server = _server

},

async transform(source, importer, ssr) {

const rewriteStart = Date.now()

// 使用 es-module-lexer 进行语法解析

await init

let imports: ImportSpecifier[] = []

try {

imports = parseImports(source)[0]

} catch (e) {

const isVue = importer.endsWith('.vue')

const maybeJSX = !isVue && isJSRequest(importer)

// 判断文件后缀给不同的提示信息

const msg = isVue

? Install @Vitejs/plugin-vue to handle .vue files.

: maybeJSX

? If you are using JSX, make sure to name the file with the .jsx or .tsx extension.

: `You may need to install appropriate plugins to handle the ${path.extname(

importer

)} file format.`

this.error(

Failed to parse source for import analysis because the content +

contains invalid JS syntax. +

msg,

e.idx

)

}

// 将代码字符串取出

let s: MagicString | undefined

const str = () => s || (s = new MagicString(source))

// 解析 env、glob 等并处理

// 转换 cjs 成 esm

}

}

}

拿Vue的NPM包举例经优化器处理后的路径如下:

// before

  • import { createApp } from'vue'
  • import { createApp } from'/node_modules/.Vite/vue.runtime.esm-bundler.js?v=d17c1aa4'

import App from'/src/App.vue'

createApp(App).mount('#app')

截图中的/src/App.vue路径经过Vite处理发生了什么?

首先需要引用 @Vitejs/plugin-vue 来处理,内部使用Vue官方的编译器@vue/compiler-sfc,plugin处理逻辑同rollup的plugin,Vite在Rollup的插件机制上进行了扩展。

详细可参考:Vitejs.dev/guide/api-p…

编译后的App.vue文件如下:

import { createHotContext as __Vite__createHotContext } from"/@Vite/client";

import.meta.hot = __Vite__createHotContext("/src/App.vue");

import HelloWorld from'/src/components/HelloWorld.vue'

const _sfc_main = {

expose: [],

setup(__props) {

return { HelloWorld }

}

}

import {

createVNode as _createVNode,

Fragment as _Fragment,

openBlock as _openBlock,

createBlock as _createBlock

} from"/node_modules/.Vite/vue.runtime.esm-bundler.js?v=d17c1aa4"

const _hoisted_1 = /#PURE/_createVNode("img", {

alt: "Vue logo",

src: "/src/assets/logo.png"

}, null, -1/* HOISTED */)

function _sfc_render(_ctx, _cache, props,props, setup, data,data, options) {

return (_openBlock(), _createBlock(_Fragment, null, [

_hoisted_1,

_createVNode($setup["HelloWorld"], { msg: "Hello Vue 3 + Vite" })

], 64/* STABLE_FRAGMENT */))

}

import"/src/App.vue?vue&type=style&index=0&lang.css"

_sfc_main.render = _sfc_render

_sfc_main.__file = "/Users/orange/build/Vite-vue3/src/App.vue"

exportdefault _sfc_main

_sfc_main.__hmrId = "7ba5bd90"

typeof VUE_HMR_RUNTIME !== 'undefined' && VUE_HMR_RUNTIME.createRecord(_sfc_main.__hmrId, _sfc_main)

import.meta.hot.accept(({ default: updated, _rerender_only }) => {

if (_rerender_only) {

VUE_HMR_RUNTIME.rerender(updated.__hmrId, updated.render)

} else {

VUE_HMR_RUNTIME.reload(updated.__hmrId, updated)

}

})

可以发现,Vite本身并不会递归编译,这个过程交给了浏览器,当浏览器运行到import HelloWorld from '/src/components/HelloWorld.vue' 时,又会发起新的请求,通过中间件来编译 vue,以此类推,为了证明我们的结论,可以看到 HelloWorld.vue 的请求信息:

经过分析源码后,能断定的是,Snowpack与Vite在启动服务的时间会远超Webpack,但复杂工程的首次编译到完全可运行的时间需要进一步测试,不同场景下可能产生截然不同的结果。

功能对比


|

| Vite@2.0.3 | Webpack@5.24.2 | Snowpack@3.0.13 |

| --- | --- | --- | --- |

| 支持Vue2 | 非官方支持: github.com/underfin/vi… | 支持:vue-loader@^15.0.0 | 非官方支持:www.npmjs.com/package/@le… |

| 支持Vue3 | 支持 | 支持:vue-loader@^16.0.0(github.com/Jamie-Yang/…) | 支持:www.npmjs.com/package/@Sn… |

| 支持Typescript | 支持:ESbuild (默认无类型检查) | 支持:ts-loader | 支持:github.com/Snowpackjs/… |

| 支持CSS预处理器 | 支持:vitejs.dev/guide/featu… | 支持:vue-loader.vuejs.org/guide/pre-p… | 部分支持:官方仅提供了Sass和Postcss,且存在未解决BUG |

| 支持CSS Modules | 支持:vitejs.dev/guide/featu… | 支持:vue-loader.vuejs.org/guide/css-m… | 支持 |

| 支持静态文件 | 支持 | 支持 | 支持 |

| 开发环境 | no-bundle native ESM(CJS → ESM) | bundle(CJS/UMD/ESM) | no-bundle native ESM(CJS → ESM) |

| HMR | 支持 | 支持 | 支持 |

| 生产环境 | Rollup | Webpack | Webpack, Rollup, or even ESbuild |

| Node API 调用能力 | 支持 | 支持 | 支持 |

启动时编译速度对比


下面一组测试的代码完全相同,都是 Hello World 工程,没有任何复杂逻辑,Webpack 与 Snowpack 分别引入了对应的 Vue plugin,Vite 无需任何插件。

Webpack5 + vue3(1.62s)

工程目录:

控制台输出:

Snowpack3 + vue3(2.51s)

工程目录:

控制台输出:

Vite2 + vue3(0.99s)

工程目录:

控制台输出:

真实项目迁移


测试案例:已存在的复杂逻辑vue工程

经过简单的测试及调研结果,首先从生态和性能上排除了Snowpack,下面将测试Webpack5与Vite2。

迁移Vite2遇到的问题:

1.不支持省略.vue后缀,因为此路由机制与编译处理强关联;

2.不支持.vue后缀文件内写jsx,若写jsx,需要改文件后缀为.jsx;

3.不建议import { ... } from "dayjs"与import duration from 'dayjs/plugin/duration'同时使用,从源码会发现在optimizeDeps阶段已经把ESM编译到了缓存文件夹,若同时使用会报错:

4.当optimizeDeps忽略后文件路径错误,node_modules/dayjs/dayjs.main.js?version=xxxxx,此处不应该在query中添加version;

5.组件库中window.$方法找不到,不能强依赖关联顺序,跟请求返回顺序有关;

6.当dependencies首次未被写入缓存时,补充写入会报错,需要二次重启;

7.在依赖关系复杂场景,Vue被多次cache,会出现ESM二次封装的情况,也就是ESM里面嵌套ESM的情况;

种种原因,调试到这里终结了,结论就是Vite2目前处于初期,尚不稳定,处理深层次依赖就目前的moduleGraph机制还有所欠缺,有待完善。

Webpack5

效果和我们之前测试相同代码在Webpack4下50+秒相比提升明显,实际场景可能存在误差,但WebpackConfig配置细节基本一致。

编译压缩提速


不知大家是否有遇到这个问题:

<--- Last few GCs --->

[59757:0x103000000] 32063 ms: Mark-sweep 1393.5 (1477.7) -> 1393.5 (1477.7) MB, 109.0 / 0.0 ms allocation failure GC in old space requested

<--- JS stacktrace --->

==== JS stack trace =========================================

Security context: 0x24d9482a5ec1

...

或者在 92% 的进度里卡很久:

Webpack chunk asset optimization (92%) TerserPlugin

随着产物越来越大,编译上线和CI的时间都越来越长,而其中1/3及更多的时间则是在做压缩的部分。OOM的问题也通常来源于压缩。

如何解决压缩慢和占内存的问题,一直是逃避不开的话题,Vite采用了ESbuild,接下来分析一下ESbuild。

ESbuild

下面是官方的构建时间对比图,并没有说明场景,文件大小等,所以不具备实际参考价值。

之所以快,其中最主要的应该是用go写,然后编译为Native代码。然后npm安装时动态去下对应平台的二进制包,支持Mac、Linux和Windows,比如esbuild-darwin-64。

相同思路的还有es-module-lexer、swc等,都是用编译成Native代码的方式进行提速,用来弥补Node在密集CPU计算场景的短板。

ESbuild有两个功能,bundler和minifier。bundler的功能和babel以及Webpack相比差异很大,直接使用对现有业务的风险较大;而minifier可以尝试,在Webpack和babel产物的基础上做一次生产环境压缩,可以节省terser plugin的压缩时间。

同时针对Webpack提供了 esbuild-webpack-plugin,可以在 Webpack 内直接使用 ESbuild。

优缺点及总结


Snowpack

缺点:

最后

基础知识是前端一面必问的,如果你在基础知识这一块翻车了,就算你框架玩的再6,webpack、git、node学习的再好也无济于事,因为对方就不会再给你展示的机会,千万不要因为基础错过了自己心怡的公司。前端的基础知识杂且多,并不是理解就ok了,有些是真的要去记。当然了我们是牛x的前端工程师,每天像背英语单词一样去背知识点就没必要了,只要平时工作中多注意总结,面试前端刷下题目就可以了。

开源分享:docs.qq.com/doc/DSmRnRG…