速度与激情之 Vite 初体验(最佳入门)
共 22112字,需浏览 45分钟
·
2021-03-03 02:56
梁晓莹,一只喜欢游泳&&读书的猪猪女孩。
大家最近学习 Vue3 学废了吗?尤雨溪尤大大马不停蹄地又给大家送来了专门为 Vue3 打造的开发利器 — Vite。你是否在开发过程中使用 Webpack 觉得不那么丝滑,是否等待启动编译可以喝好几口热水?本文将带领大家简单了解 Vite 的基本知识和作用,让我们更好的开启 Vue3 开发之旅~ 首先,学习 Vite 之前得至少有 2 部分的知识储备:1)掌握 ES Modules 特性 2)了解 Http2 标准,限于篇幅,这里就不过多赘述啦~
一、问题来源
1.1 Webpack 槽点
如果应用比较复杂,那么使用 Webpack 的开发过程就相对没有那么舒适。
- Webpack Dev Server 冷启动时间会比较长
- Webpack HMR 热更新的反应速度比较慢
1.2 回顾 Webpack 初衷
【之前技术环境】我们使用 Webpack 打包应用代码,最后生成一个 bundle.js,主要有两个原因:
- 浏览器环境并不很好的来支持模块化
- 零散的模块文件会产生大量的 HTTP 请求
1.3 思考现在
bundle 太大,要采用各种 Code Splitting,压缩代码,去除的插件,提取的第三方库,so tired~ 【当前技术环境】是否能解决 Webpack 当时的难点?thinking~~
二、解决思路
2.1 ES Module
随着浏览器的对 ES 标准支持的逐渐完善,第一个问题已经慢慢不存在了。现阶段绝大多数浏览器都是支持 ES Modules 的。
其最大的特点是在浏览器端使用 export import 的方式导入和导出模块,在 script 标签里写 type="module"
,然后使用 ES Module。
// 当 html 里嵌入 ES module 的 script 标签时候,浏览器会发起 http 请求,请求 http server 托管的 main.js ;
// index.html
<script type="module" src="/src/main.js"></script>
// 使用 export 导出模块, import 导入模块:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
createApp(App).mount('#app')
直接访问 index.html,报错:在浏览器里使用 ES module 是使用 http 请求拿到模块的,所以 file 协议的请求不允许。
2.2 模块解析
那我们就在本地起一个静态服务,再来打开一下 index.html 来看下报错:找不到模块 vue;原因:"/", "./", or "../"开头的 import 相对/绝对路径,才是合法的。
import vue from 'vue'
也就是说浏览器中的 ESM 是获取不到导入的模块内容的。平时我们写代码,如果不是引用相对路径的模块,而是引用 node_modules
的模块,都是直接 import xxx from 'xxx'
,由 Webpack
等工具来帮我们处理 js 间的相互依赖关系,找这个模块的具体路径进行打包,但是浏览器不知道你项目里有 node_modules
,它只能通过相对路径或者绝对路径去寻找模块。
那咋办???所以 Vite 的一个任务就是启动一个 web server 去代理这些模块,Vite 这里是借用了 koa 来启动了一个服务
export function createServer(config: ServerConfig): Server {
// ...
const app = new Koa<State, Context>()
const server = resolveServer(config, app.callback())
// ...
const listen = server.listen.bind(server)
server.listen = (async (port: number, ...args: any[]) => {
if (optimizeDeps.auto !== false) {
await require('../optimizer').optimizeDeps(config)
}
return listen(port, ...args)
}) as any
server.once('listening', () => {
context.port = (server.address() as AddressInfo).port
})
return server
}
那这就引出了 Vite 的一个实现核心 - 拦截浏览器对模块的请求并返回处理后的结果我们来看下 Vite 是怎么处理的?
2.3 /@module/
前缀
通过工程下的 main.js 和开发环境下的实际加载的 main.js 对比,发现 main.js 内容发生了改变,由
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
createApp(App).mount('#app')
变成了
import { createApp } from '/@modules/vue.js'
import App from '/src/App.vue'
import '/src/index.css?import'
createApp(App).mount('#app')
为了解决 import xxx from 'xxx'
报错的问题,Vite 对这种资源路径做了一个统一的处理,加一个/@module/
前缀。我们在 src/node/server/serverPluginModuleRewrite.ts
源码这个 koa 中间件里可以看到 Vite 对 import 都做了一层处理,其过程如下:
在 koa 中间件里获取请求 ctx.body 通过 es-module-lexer 解析资源 ast 拿到 import 的内容 判断 import 的资源是否是绝对路径,绝对视为 npm 模块 rewriteImports 返回处理后的资源路径:"vue" => "/@modules/vue"
如何支持 /@module/?
在 /src/node/server/serverPluginModuleResolve.ts
里可以看到大概的处理逻辑是
在 koa 中间件里获取请求 ctx.body 判断路径是否以 /@module/ 开头,如果是取出包名 去 node_module 里找到这个库,基于 package.json 返回对应的内容
2.4 文件编译
上面我们提到的是对普通 js module 的处理,那对于其他文件,比如 vue
、css
、ts
等是如何处理的呢?我们以 vue 文件为例来看一下,在 Webpack 里我们是使用的 vue-loader 对单文件组件进行编译,实际上 Vite 同样的是拦截了对模块的请求并执行了一个实时编译。通过工程下的 App.vue 和开发环境下的实际加载的 App.vue 对比,发现内容发生了改变
原本的 App.vue
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<HelloWorld msg="Hello Vue 3.0 + Vite" />
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
}
}
</script>
<style>
body {
background: #fff;
}
</style>
变成了
import HelloWorld from '/src/components/HelloWorld.vue'
const __script = {
name: 'App',
components: {
HelloWorld
}
}
import "/src/App.vue?type=style&index=0"
import {render as __render} from "/src/App.vue?type=template"
__script.render = __render
__script.__hmrId = "/src/App.vue"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)
__script.__file = "/Users/liangxiaoying/myfile/wy-project/vite-demo/src/App.vue"
export default __script
这样就把原本一个 .vue
的文件拆成了三个请求(分别对应 script、style 和 template) ,浏览器会先收到包含 script 逻辑的 App.vue 的响应,然后解析到 template 和 style 的路径后,会再次发起 HTTP 请求来请求对应的资源,此时 Vite 对其拦截并再次处理后返回相应的内容。
实际上在看到这个思路之后,对于其他的类型文件的处理几乎都是类似的逻辑,根据请求的不同文件类型,做出不同的编译处理。实际上 Vite 就是在按需加载的基础上通过拦截请求实现了实时按需编译
2.5 HTTP 2
零散模块文件在HTTP 1.x 确实会产生大量的 HTTP 请求,而大量的 HTTP 请求在浏览器端就会并发请求资源的问题;但是这些问题随着HTTP 2的出现,也就不复存在了。 why?
HTTP 1.x 中,如果想并发多个请求,必须使用多个 TCP 链接,且浏览器为了控制资源,还会对单个域名有 6-8 个的 TCP 链接请求限制;HTTP 2 则可以使用多路复用,代替原来的序列和阻塞机制。所有请求都是通过一个 TCP 连接并发完成。
三、三大作用
即 Vite 的 3 大核心功能:Static Server + HMR + Compile
3.1 快速的冷启动
社区:比如可以借助各种 cli :vue-cli、create-react-app 等等
当我们对比使用 vue-cli-service serve 的时候,会有明显感觉。因为 Webpack Dev Server 在启动时,需要先 build—遍,而 build 的过程是需要耗费很多时间的。而 Vite 则完全不同,当我们执行 Vite serve 时(npm run dev),内部直接启动了 Web Server,并不会先编译所有的代码文件。那仅仅是启动 Web Server,速度上自然就蹭蹭蹭的 up↑。那么及时请求的编译呢?关于支持 JSX, TSX,Typescript 编译到原生 JS —— Vite 引入了EsBuild,是使用 Go 写的,直接编译为 Native 代码,性能要比 TSC 好二三十倍,所以就不用担心啦~ 当然也会用上缓存,具体这里暂时不扩展。
3.2 即时的热模块更新
社区:Webpack HMR 等
热更新的时候,Vite 只需要立即编译当前所修改的文件即可,所以 响应速度非常快。而 Webpack 修改某个文件过后,会自动以这个文件为入口重写 build—次,所有的涉及到的依赖也都会被加载一遍,所以反应速度会慢很多。
3.3 真正的按需编译
社区:需要开发者自行在代码中引入其他插件 impor('xx.js')
实现 dynamic-import;如@babel/plugin-syntax-dynamic-import
但是像 Webpack 这类工具的做法是将所有模块提前编译、打包进 bundle 里,换句话说,不管模块是否会被执行,都要被编译和打包到 bundle 里。随着项目越来越大打包后的 bundle 也越来越大,打包的速度自然也就越来越慢。
Vite 利用现代浏览器原生支持 ESM 特性,省略了对模块的打包。
对于需要编译的文件,Vite 采用的是另外一种模式:即时编译。也就是说,只有具体去请求某个文件时才会编译这个文件。所以,这种「即时编译」的好处主要体现在:按需编译。
四、核心思路
4.1 初始启动静态服务
初始执行命令 npm run dev --> 实际就是启动了 /src/node/server/index.ts 如上文提到启动了一个 koa server, 该文件还使用了 chokidar 库创建一个 watcher,来监听文件变动:
export function createServer(config: ServerConfig): Server {
// 启动静态 server:
const app = new Koa<State, Context>()
const server = resolveServer(config, app.callback())
......
const listen = server.listen.bind(server)
server.listen = (async (port: number, ...args: any[]) => {
...
}) as any
// 其中关键 1:使用 chokidar 对文件进行递归监听:监听到文件变动可对不同模块进行相应处理
const watcher = chokidar.watch(root, {
ignored: ['**/node_modules/**', '**/.git/**'],
...
}) as HMRWatcher
// 其中关键 2:执行各类插件
const resolvedPlugins = [
// rewrite and source map plugins take highest priority and should be run
// after all other middlewares have finished
sourceMapPlugin,
moduleRewritePlugin,
htmlRewritePlugin, // 处理 html 文件
// user plugins
...toArray(configureServer),
envPlugin,
moduleResolvePlugin,
proxyPlugin,
clientPlugin, // 输出客户端执行代码
hmrPlugin, // 处理热模块更新
...(transforms.length || Object.keys(vueCustomBlockTransforms).length
? [
createServerTransformPlugin(
transforms,
vueCustomBlockTransforms,
resolver
)
]
: []),
vuePlugin, // 处理单文件组件
cssPlugin, // 处理样式文件
enableEsbuild ? esbuildPlugin : null,
jsonPlugin,
assetPathPlugin,
webWorkerPlugin,
wasmPlugin,
serveStaticPlugin
]
resolvedPlugins.forEach((m) => m && m(context))
}
4.2 监听消息,拦截部分请求
我们可以看到初始第一个请求如下:那么这个文件哪里来的?这就是经过 clientPlugin 【/src/node/server/serverPluginClient.ts】处理输出的:
export const clientPublicPath = `/vite/client` // 当前的文件名称
const legacyPublicPath = '/vite/hmr' // 历史版本的名称
...
export const clientPlugin: ServerPlugin = ({ app, config }) => {
// clientCode 替换配置的信息,用于最后 body 输出:
const clientCode = fs
.readFileSync(clientFilePath, 'utf-8')
.replace(`__MODE__`, JSON.stringify(config.mode || 'development'))
...
app.use(async (ctx, next) => {
// 请求路径是/vite/client,返回响应:200,响应文本是处理好的 clientCode
if (ctx.path === clientPublicPath) {
// 设置 socket 配置信息
let socketPort: number | string = ctx.port
...
if (config.hmr && typeof config.hmr === 'object') {
// hmr option 有最高优先级
...
}
ctx.type = 'js'
ctx.status = 200
// 返回整合好的 body
ctx.body = clientCode.replace(`__HMR_PORT__`, JSON.stringify(socketPort))
} else {
if (ctx.path === legacyPublicPath) { // 历史版本 /vite/hmr
console.error('xxxx')
}
return next()
}
})
}
请求/vite/client 实际就是 /src/client/client.ts 文件,即返回 body = clientCode = client.ts 文件内容;那么它做啥了呢???使用 websoket 处理消息,快速编译,达到实时热更新:
const socketProtocol =
__HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws')
const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}`
// 启动 websocket 通信,可实时处理消息,实现 HMR
const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr')
...
监听消息:
socket.addEventListener('message', async ({ data }) => {
const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
handleMessage(payload)
})
处理消息:
async function handleMessage(payload) {
const { path, changeSrcPath, timestamp } = payload;
switch (payload.type) {
case 'connected': // socket 连接成功
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': // js 更新
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')) {
...
} else {
location.reload();
}
}
}
咦?那设立了 message 监听,那 message 又是谁发出来的呢?
4.3 不同插件,监听文件变化,返回消息
例如:cssPlugin 【/src/node/server/serverPluginCss.ts】
// 处理 css 文件,监听 css 文件变动
export const cssPlugin: ServerPlugin = ({ root, app, watcher, resolver }) => {
// 输出 css 请求的响应模板
export function codegenCss(
id: string,
css: string,
modules?: Record<string, string>
): string {
let code =
`import { updateStyle } from "${clientPublicPath}"\n` +
`const css = ${JSON.stringify(css)}\n` +
`updateStyle(${JSON.stringify(id)}, css)\n`
if (modules) {
code += dataToEsm(modules, { namedExports: true })
} else {
code += `export default css`
}
return code
}
app.use(async (ctx, next) => {
await next()
// 处理 .css 的 imports
...
const id = JSON.stringify(hash_sum(ctx.path))
if (isImportRequest(ctx)) {
const { css, modules } = await processCss(root, ctx)
ctx.type = 'js'
// 用`?import`去重写 css 文件为一个 js 模块,插入 style 标记,链接到实际原始 url
ctx.body = codegenCss(id, css, modules)
}
})
watcher.on('change', (filePath) => {
// 筛出 css 文件,更新 css 请求文件
if (文件更新) {
watcher.send({ // 发送消息
type: 'style-update',
path: publicPath,
changeSrcPath: publicPath,
timestamp: Date.now()
})
}
})
}
4.4 逻辑小结
将当前项目目录作为静态文件服务器的根目录 拦截部分文件请求 处理代码中 import node_modules 中的模块 b 处理 Vue 单文件组件(SFC)的编译 通过 WebSocket 实现 HMR
五、Snowpack VS Vite
同:
底层原理:Snowpack v2 和 Vite 均提供基于浏览器原生 ES 模块导入的开发服务器; 冷启动快速:在开发反馈速度方面,两个项目都具有相似的性能特征; 开箱即用:避免各种 Loader 和 Plugin 的配置。
Vite 默认情况下支持更多的选择加入功能-例如 TypeScript transpilation、CSS import、CSS Modules 和 postcss 支持(需要单独安装所对应的编译器) 都是现成的,无需配置;snowpack 也是支持 JSX、TypeScript、React、Preact、CSS Modules 等构建,非默认;
插件:支持很多自定义插件;Vite 关于这部分的官方文档还没有。
异:
演变:Snowpack 最初不提供 HMR 支持,但在 v2 添加了它,从而使两个项目的范围更加接近。Vite 最初就是参考了 snowpack v1; 双方在基于 ESM 的 HMR 上合作过,尝试建立统一的 api ESM-HMR API 规范, 但因为底层不同还是会略微不同; 使用:Vite 当前暂时只能给 Vue 3.x.使用+react 等部分模板, 对 vue 支持更棒👍;snowpack 没限制; 生产打包:Vite 用 rollup,打包体积更小(rollupInputOptions:定义 rollup 的插件功能);snowpack 用 parcel/webpack;- 决定了开发者生产个性化配置的方案不一样; 偏向:Vue 支持是 Vite 中的一级功能。例如,Vite 提供了一个更细粒度的 HMR 与 Vue 的集成,并且对构建配置进行了微调,以生成最高效的 bundle; 文档完善性:
Vitejs 优点是尤雨溪出品,可能和 Vue3 生态更好的融合。缺点是文档不完善。目前 star 13.7k; Snowpack 优点是更加成熟,有成熟的 v1 和已经发布正式版的 v2, 支持 react, Vue, preact, svelte 等各类应用,文档也更加完善。目前 star 14.4k。
so。。。如何选择?:=> 选 Vite:
喜欢用 Vue,那么 Vite 提供更好支持; 诉求是打包 bundle 体积小 ,那么 Vue 使用 rollup👌。
=> 选 Snowpack:
不喜欢用 Vue,不用 vue-cli,喜欢 react 等; 大的 team 想要使用各类插件 plugin,想要清晰的文档📚等; 对 Webpack 比较用的惯,想要开发模式不要 bundle 打包,更快速👀。
本篇文章主要是带领 Vue 开发爱好者学习尤大对于按需编译等方面的想法和新思路,助力童鞋们的高效开发,减少学习路径。期待 Vite 不仅能成为 vue 的配套工具,还能在未来形成更成熟的社区方案,推进技术进步!!!
最后
如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:
点个「在看」,让更多的人也能看到这篇内容(喜欢不点在看,都是耍流氓 -_-)
欢迎加我微信「qianyu443033099」拉你进技术群,长期交流学习...
关注公众号「前端下午茶」,持续为你推送精选好文,也可以加我为好友,随时聊骚。