包阅导读总结
1. `vite`、`构建工具`、`脚手架`、`webpack`、`前端开发`
2. 本文主要介绍了 `vite`,包括其与 `vue` 的关系、作为构建工具的特点、与 `webpack` 的区别、原理及源码解读。强调了 `vite` 不仅是 `vue` 脚手架,也是多种框架的脚手架,重点阐述了它在开发环境的优势,解释了其原理是将各种文件编译为模块化 `js` 执行,还对其源码中的服务器建立、编译处理等进行了解读。
3.
– vite 概述
– 不仅是 `vue` 脚手架,也是 `react` 、`svelte` 等的脚手架
– 是构建工具,用于打包项目
– 构建工具和脚手架的区别
– 脚手架用于创建前端项目,集成常用功能和配置
– 构建工具将开发环境代码转化为生产环境可用代码
– 脚手架包含构建工具,二者存在交叉关系
– vite 与其他构建工具
– 介绍了 `vite` 出现之前的构建工具
– 指出 `vite` 对比 `webpack` 的优点:生态落后、技术领先、开发体验好
– vite 原理
– 利用浏览器支持的模块化语法能力,动态按需加载代码
– 对源文件处理,即时编译,将编译后的文件变成模块化 `js` 执行
– 对 `css` 内容处理,通过执行 `js` 动态挂载到 `dom` 中
– vite 源码解读
– 基于 `connect` 建立 `web` 服务器,通过命令行工具启动
– 处理编译利用优秀的插件设计
– 处理 `hmr` 热更新
– 具备速度快和按需处理的特点
思维导图:
文章地址:https://juejin.cn/post/7402915897906249743
文章来源:juejin.cn
作者:老骥farmer
发布时间:2024/8/15 7:28
语言:中文
总字数:6469字
预计阅读时间:26分钟
评分:85分
标签:Vue,Vite,构建工具,开发体验,热更新
以下为原文内容
本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com
声明:本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
vite 到底是什么
说起vite
很多jym
可能非常熟悉,并且脱口而出,不就是vue脚手架吗?
我说,说的对,但是不全对,他除了可以是vue
的脚手架,还可以是react
脚手架,还可以是svelte
的脚手架
目前支持的模板预设如下:
其实这种东西,他有一个特殊的名字,叫构建工具
接下来,我们开始第二个问题,构建工具和脚手架有什么区别呢?
脚手架
所谓脚手架是创建前端项目的命令行工具,集成了常用的功能和配置,方便我们快速搭建项目
说人话
脚手架建立的是前端的工程
他有以下特点
- 可以帮助我们快速生成项目的基础代码
- 脚手架工具的项目模板经过了开发者的提炼和检验,一定程度上代表了某类项目的最佳实践
- 脚手架工具支持使用自定义模板,我们可以根据不同的项目进行“定制”
构建工具
所谓构建工具,我的理解其实就是解放我们双手的自动化的兼容不同的代码的转换工具
es语法现在已经更新到了ECMAScript2024,我们在写代码的时候就可以用最新的语法
可浏览器做不到啊,因为他不认识,于是这时候就需要有一种工具能自动化的将代码打包成浏览器认识的代码
这时候就需要构建工具
所谓前端构建工具 简单的说就是将我们开发环境的代码,转化成生产环境可用来部署的代码。
那么脚手架跟构建工具有什么区别呢?
区别
我们知道脚手架是为了创建项目的,而构建工具是为了打包项目的
也就是说,脚手架中包含构建工具,并且传统意义上的构建工具(注意:这里不一定是前端构建工具)可构建脚手架
所以他们是可能一个交叉关系,
很绕是吧,总而言之,言而总之,我们只需要理解,他们一个是打包的,一个是制作打包的就可以了,这也不是咱们本次的重点
当然,只拿前端构建工具来说应该是个包含关系(脚手架包含构建工具)
在vite 出现之前,我们还是要礼貌性的介绍一下他的前辈们
其他的构建工具们
在vite 出现之前,
有远古时代的browserify
、grunt
,glup
,有传统的Webpack
、Rollup
、Parcel
,也有现代的Esbuild
、Vite
他们的出现分别是为了解决不同的痛点承载不同的使命。有的已经退出历史舞台,有的即将退出历史舞台
有人深入群众,地位不可撼动,总之,感谢他们,让我们看到到了这辉煌而又繁荣的前端江湖
在这个江湖中,如果说地位最牢固的,当属webpack
那么他到底和vite 有什么区别呢?
vite 和 webpack 的区别
开始之前我们来说一下 vite
的优点
看了以上这三个优点,我们就可以用一句话概括,vite 和 webpack 在生产环境没有区别
vite
主要解决的问题是开发环境的体验问题
他对比webpack
用一句很中肯的话可以评价生态落后,技术领先,开发体验好
我们一个个来解释
生态落后
这个就不需要多说了,老牌打包工具webpack
插件生态琳琅满目
,只要你业务能用到的,基本上都能找到插件使用,这一点,我对后来的后起之秀们说,革命尚未成功,同志仍需努力
而 vite
虽然继承了 rollup
的一些插件机制,可与老大哥还是有一定差距
技术领先
这个就更不用多说了, 使用esbuild
插件机制
常用业务中常用功能都做到了顶尖,而反观 webpack
就是,单拎出来哪一项都不行,总体来看条件还行
开发体验好
这个但凡用过 webpack
和 vite
的 对比下就知道,高下立判
所以,大家在选用构建工具的时候,请根据自身项目的需求,酌情选择,就好像择偶
一样
你是想要个好看的,还是想要个条件好的
请想清楚!!!!
vite原理
说起原理,其实这是一个老生常谈的问题,因为前面有无说的人讲过了——vite
在开发环境就是利用 <script type="module">
现代浏览器支持模块化语法的能力,动态按需加载代码
但我认为其实不是这么简单
所以这一次,结合一个项目来简单研究下原理
<div id="app"></div><script type="module"> import { createApp } from 'vue' import Main from './Main.vue' createApp(Main).mount('#app')</script>
上方就是我们的 html
文件,当我们去访问这个 html 文件的时候他 虽然他支持module
但也是执行不了的,因为vue
路径找不到
所以虽然 vite
号称是利用module
,但其实还是要对源文件做处理,他支持的 esm
模块,要是浏览器认识的模块才行,于是他也要对代码进行编译,只不过是即时编译
以上代码就是编译之后
的 html
文件
接下来执行执行其中代码 请求 vue.js
文件 请求.vue
文件
然后问题来了vue.js
浏览器好歹认识 可这个.vue
怎么办呢?
这时候就是 vite
的服务就起作用了,继续处理,本质上来讲, 当我访问到 .vue
文件在浏览器层面就要发请求了 要不就是文件系统请求,要不就是 http
请求
于是 vite
就可以上手段
了,他对这个请求做处理不就好了嘛! 返回浏览器能看得懂的
源代码
<template> <h1>Vue version {{ version }}</h1> <div class="comments"></div> <pre>{{ time as string }}</pre> <div class="hmr-block"> <Hmr /> </div></template><script setup lang="ts">import { version, defineAsyncComponent } from 'vue'import Hmr from './Hmr.vue'import { ref } from 'vue'const TsGeneric = defineAsyncComponent(() => import('./TsGeneric.vue'))const time = ref('loading...')window.addEventListener('load', () => { setTimeout(() => { const [entry] = performance.getEntriesByType('navigation') time.value = `loaded in ${entry.duration.toFixed(2)}ms.` }, 0)})</script><style scoped>.comments { color: red;}</style>
摇身一变就会成为这样
import { createHotContext as __vite__createHotContext } from "/@vite/client";import.meta.hot = __vite__createHotContext("/Main.vue");import { defineComponent as _defineComponent } from "/node_modules/.vite/deps/vue.js?v=e88b16ae";import { version, defineAsyncComponent } from "/node_modules/.vite/deps/vue.js?v=e88b16ae";import Hmr from "/Hmr.vue";import { ref } from "/node_modules/.vite/deps/vue.js?v=e88b16ae";const _sfc_main = _defineComponent({ __name: "Main", setup(__props, { expose: __expose }) { __expose(); const TsGeneric = defineAsyncComponent(() => import("/TsGeneric.vue")); const time = ref("loading..."); window.addEventListener("load", () => { setTimeout(() => { const [entry] = performance.getEntriesByType("navigation"); time.value = `loaded in ${entry.duration.toFixed(2)}ms.`; }, 0); }); const __returned__ = { TsGeneric, time, version, Hmr }; Object.defineProperty(__returned__, "__isScriptSetup", { enumerable: false, value: true }); return __returned__; }});import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/deps/vue.js?v=e88b16ae";const _hoisted_1 = { class: "hmr-block" };function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { return _openBlock(), _createElementBlock( _Fragment, null, [ _createElementVNode("h1", null, "Vue version " + _toDisplayString($setup.version)), _cache[0] || (_cache[0] = _createElementVNode( "div", { class: "comments" }, [ _createCommentVNode("hello") ], -1 )), _createElementVNode( "pre", null, _toDisplayString($setup.time), 1 ), _createElementVNode("div", _hoisted_1, [ _createVNode($setup["Hmr"]) ]) ], 64 );}import "/Main.vue?t=1723640466717&vue&type=style&index=0&scoped=4902c357&lang.css";_sfc_main.__hmrId = "4902c357";typeof __VUE_HMR_RUNTIME__ !== "undefined" && __VUE_HMR_RUNTIME__.createRecord(_sfc_main.__hmrId, _sfc_main);import.meta.hot.accept((mod) => { if (!mod) return; const { default: updated, _rerender_only } = mod; if (_rerender_only) { __VUE_HMR_RUNTIME__.rerender(updated.__hmrId, updated.render); } else { __VUE_HMR_RUNTIME__.reload(updated.__hmrId, updated); }});import _export_sfc from "/@id/__x00__plugin-vue:export-helper";export default _export_sfc(_sfc_main, [["render", _sfc_render], ["__scopeId", "data-v-4902c357"], ["__file", "/Users/a58/open_source_project/vite-plugin-vue/playground/vue/Main.vue"]]);
然后我们有惊奇的发现css没有处理,别急,我们发现,有这样一行代码
import "/Main.vue?t=1723640466717&vue&type=style&index=0&scoped=4902c357&lang.css";
他其实本质上将 当做一个请求重新处理了,结果如下
import {createHotContext as __vite__createHotContext} from "/@vite/client";import.meta.hot = __vite__createHotContext("/Main.vue?vue&type=style&index=0&scoped=4902c357&lang.css");import {updateStyle as __vite__updateStyle, removeStyle as __vite__removeStyle} from "/@vite/client"const __vite__id = "/Users/a58/open_source_project/vite-plugin-vue/playground/vue/Main.vue?vue&type=style&index=0&scoped=4902c357&lang.css"const __vite__css = "\n.comments[data-v-4902c357] {\n color: red;\n}\n"__vite__updateStyle(__vite__id, __vite__css)import.meta.hot.accept()import.meta.hot.prune(()=>__vite__removeStyle(__vite__id))
他将 css
内容变成 js,通过执行js
动态的将css 挂在dom
中
所以 vite 的原理就是将编译好的各种文件变成模块化的 js
执行,js
的执行过程中,引用了vue
、react
等框架 从而绘制出页面
如此一来,相信大家对vite
原理应该有了初步的理解,其实在 vite
的背后并没有大家想的只是利用type="module
的能力去做资源的读取,是一个资源服务器
本质上,他就是一个强大的web服务
对于所有的请求做拦截,读取本地资源做对应的处理,变成浏览器能看懂的 esm
代码,他背后承载着,打包
、收发请求
、编译
、热更新
等各种操作
vite 的本质: 利用浏览器
type="module
能力和http
能力的web打包服务器
源码解读
说完了原理,我们知道他是讲源码中的文件,处理成 esm
的js 文件顺序执行,最终绘制出页面
那么他是怎么实现的呢?
这里我实现了一个 mini-vite
供大家参考 mini-vite
接下来我们就简单的解读一下源码
少说废话,先看图
上图中,我们简单介绍了,vite
初始化以及运行图
流程图
这里由于篇幅关系,我们就用简写的方式研究一下他的内部构造
我们主要需要研究一下几个方向
- 1、怎样建立 web 服务器的
- 2、怎样处理编译的
- 3、hmr 热更新怎么处理的
- 4、为什么这么快
- 5、为什么能按需处理
1、怎样建立 web 服务器的
建立web
服务器,其实很简单了,其实我们常用的 web
服务框架都可koa
、express
、connect
这种小而美的都可以,源码中选择的就是connect
服务器选好了,我们就要开始启动了,但是我们发现,在日常的开发中,是这一堆命令
"scripts": { "dev": "vite", "build": "vite build", "debug": "node --inspect-brk vite", "preview": "vite preview" },
于是我们就又需要有命令行工具,这样当我们启动 vite
命令的时候 才能启动命令行工具来启动服务
那么怎么启动命令行工具呢?
我们只需要在 node_modules
的包中,对应下载的项目的package.json
中加入以下命令
"bin": { "mini-vite": "bin/mini-vite" },
如果有不明白 bin
是个什么东西请,去温习下npm
相关内容
这样就可以正式开始了
const cli = cac()cli .command('[root]', 'Run the development server') .alias('serve') .alias('dev') .action(async (option) => { await startDevServer(option) })
以上代码中就可以拿到命令行的参数,执行不同的逻辑,我们本次就是 dev
接下来就是启动服务的时刻了,我们简化了源码
中的流程,只保留核心逻辑
export async function startDevServer(type) { const app = connect() const root = process.cwd() const startTime = Date.now() const plugins = resolvePlugins() const pluginContainer = createPluginContainer(plugins) const moduleGraph = new ModuleGraph((url) => pluginContainer.resolveId(url)) const watcher = chokidar.watch(root, { ignored: ['**/node_modules/**', '**/.git/**'], ignoreInitial: true, }) const ws = createWebSocketServer(app) const serverContext: ServerContext = { root: normalizePath(process.cwd()), app, pluginContainer, plugins, moduleGraph, ws, type: type == 'react' ? 'react' : 'vue', watcher, } bindingHMREvents(serverContext) for (const plugin of plugins) { if (plugin.configureServer) { await plugin.configureServer(serverContext) } } app.use(transformMiddleware(serverContext)) app.use(indexHtmlMiddware(serverContext)) app.use(staticMiddleware(serverContext.root)) app.listen(3000, async () => { await optimize(root, type == 'react' ? 'src/main.tsx' : 'src/main.ts') console.log( green('🚀 No-Bundle 服务已经成功启动!'), `耗时: ${Date.now() - startTime}ms`, ) console.log(`> 本地访问路径: ${blue('http://localhost:3000')}`) })}
以上代码中,就是服务启动的流程,启动过程中,通过中间件将编译
热更新
等流程全部初始化完毕,接下来,当浏览器请求的时候服务就开始加载对应资源返回给客户端
2、怎样处理编译
处理编译其实就是利用vite
最优秀的插件设计,其实写过 rollup插件 的jym
就会发现vite
跟他插件设计几乎相同
我们先来简单的找个 rollup
插件举例
export default function myExample () { return { name: 'my-example', resolveId ( source ) { if (source === 'virtual-module') { return source; } return null; }, load ( id ) { if (id === 'virtual-module') { return 'export default "This is virtual!"'; } return null; } };}import myExample from './rollup-plugin-my-example.js';export default ({ input: 'virtual-module', plugins: [myExample()], output: [{ file: 'bundle.js', format: 'es' }]});
接下来我们用 vite
如法炮制一个简单的插件,就用源码中最简单的json
插件举例,
import { dataToEsm } from '@rollup/pluginutils'import { SPECIAL_QUERY_RE } from '../constants'import type { Plugin } from '../plugin'import { stripBomTag } from '../utils'export interface JsonOptions { namedExports?: boolean stringify?: boolean}const jsonExtRE = /\.json(?:$|\?)(?!commonjs-(?:proxy|external))/const jsonLangs = `\\.(?:json|json5)(?:$|\\?)`const jsonLangRE = new RegExp(jsonLangs)export const isJSONRequest = (request: string): boolean => jsonLangRE.test(request)export function jsonPlugin( options: JsonOptions = {}, isBuild: boolean,): Plugin { return { name: 'vite:json', transform(json, id) { if (!jsonExtRE.test(id)) return null if (SPECIAL_QUERY_RE.test(id)) return null json = stripBomTag(json) try { if (options.stringify) { if (isBuild) { return { code: `export default JSON.parse(${JSON.stringify( JSON.stringify(JSON.parse(json)), )})`, map: { mappings: '' }, } } else { return `export default JSON.parse(${JSON.stringify(json)})` } } const parsed = JSON.parse(json) return { code: dataToEsm(parsed, { preferConst: true, namedExports: options.namedExports, }), map: { mappings: '' }, } } catch (e) { const position = extractJsonErrorPosition(e.message, json.length) const msg = position ? `, invalid JSON syntax found at position ${position}` : `.` this.error(`Failed to parse JSON file` + msg, position) } }, }}export function extractJsonErrorPosition( errorMessage: string, inputLength: number,): number | undefined { if (errorMessage.startsWith('Unexpected end of JSON input')) { return inputLength - 1 } const errorMessageList = /at position (\d+)/.exec(errorMessage) return errorMessageList ? Math.max(parseInt(errorMessageList[1], 10) - 1, 0) : undefined}
上述代码中,就是一个插件的简单的实现过程 他的返回值主要包含resolveId
、load
、transform
三个方法,当然,都不是必须的
那么接下来问题来了,插件有了,他是怎么执行实现编译的呢?
很简单,还记得服务的中间件机制吗?
插件系统
和中间件机制类似,当我们注册插件的中间件以后,请求过来以后,注册的插件就会遍历启动相关插件 顺序的对代码进行处理, 最终返回目标结果
上图就是 json文件的请求结果
那么接下来问题又来了,他怎么实现流水线顺序执行的呢?
很简单,利用 for 循环,当请求来了以后,我们利用注册的中间件
处理得到请求 url
然后对 url 进行处理,for 循环所有插件,插件流水线处理内容,这里我们简单的用简版代码举个例子
app.use(transformMiddleware(serverContext)) export function transformMiddleware( serverContext: ServerContext,): NextHandleFunction { return async (req, res, next) => { if (req.method !== 'GET' || !req.url) { return next() } const url = req.url debug('transformMiddleware: %s', url) if ( isJSRequest(url) || isVue(url) || isCSSRequest(url) || isImportRequest(url) ) { let result = await transformRequest(url, serverContext) if (!result) { return next() } if (result && typeof result !== 'string') { result = result.code } res.statusCode = 200 res.setHeader('Content-Type', 'application/javascript') return res.end(result) } return next() }}
上述代码中,就是请求来了以后的处理逻辑,这里我们且不看其他内人,我们关注pluginContainer.transform
方法,就是核心的编译逻辑,他是怎么做的呢?
async transform(code, id) { const ctx = new Context() as any for (const plugin of plugins) { if (plugin.transform) { const result = await plugin.transform.call(ctx, code, id) if (!result) continue if (typeof result === 'string') { code = result } else if (result.code) { code = result.code } } } return { code } },
上述代码中你就会发现,他是利用 for
循环遍历插件,处理code
代码,最后得到我们想要的内容,至于插件的形式,就不再赘述,开文中已经展示过!
如此一来,vite
就通过插件的形式,高明的可拓展的实现我们的需求
3、hmr 热更新怎么处理的
hmr
是目前前端开发最重要的东西,因为谁都不想改动一个代码就得重启一下服务
,用户体验很重要那么他到底是怎么实现的呢?
其实本质很简单:利用 ws 的能力,主动推送变动内容通知客户端更新代码
当然,别看这么简单的事情,也是要严格的执行三步走战略
- 1、 监听文件变化
- 2、建立 ws通信
- 3、通知客户端更改代码
1、 监听文件变化
第一步就很简单了,我们只需要一个工具chokidar
const watcher = chokidar.watch(root, { ignored: ['**/node_modules/**', '**/.git/**'], ignoreInitial: true, })
2、建立 ws通信
这一步 我们同样的也是用 node 的ws
这个库
import connect from 'connect'import { red } from 'picocolors'import { WebSocketServer, WebSocket } from 'ws'import { HMR_PORT } from './constants'export function createWebSocketServer(server: connect.Server): { send: (msg: string) => void close: () => void} { let wss: WebSocketServer wss = new WebSocketServer({ port: HMR_PORT }) wss.on('connection', (socket) => { socket.send(JSON.stringify({ type: 'connected' })) }) wss.on('error', (e: Error & { code: string }) => { if (e.code !== 'EADDRINUSE') { console.error(red(`WebSocket server error:\n${e.stack || e.message}`)) } }) return { send(payload: Object) { const stringified = JSON.stringify(payload) wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(stringified) } }) }, close() { wss.close() }, }}const socket = new WebSocket(`ws://localhost:__HMR_PORT__`, 'vite-hmr')socket.addEventListener('message', async ({ data }) => { handleMessage(JSON.parse(data)).catch(console.error)})
3、通知客户端更改代码
这是最重要的一步,当服务端监听到文件变化以后,发送通知
代码如下
export function bindingHMREvents(serverContext: ServerContext) { const { watcher, ws, root } = serverContext watcher.on('change', async (file) => { console.log(`✨${blue('[hmr]')} ${green(file)} changed`) const { moduleGraph } = serverContext await moduleGraph.invalidateModule(file) ws.send({ type: 'update', updates: [ { type: 'js-update', timestamp: Date.now(), path: '/' + getShortName(file, root), acceptedPath: '/' + getShortName(file, root), }, ], }) })}
这里我们为了能让大家看明白,模拟一下简单的 js 变动,这里发送的内容其实很简单,只是变动的文件名,时间戳等信息
接下来就是客户端的更新问题了,也很简单重新请求一下这个js
文件即可
async function fetchUpdate({ path, timestamp }: Update) { const mod = hotModulesMap.get(path) if (!mod) return const moduleMap = new Map() const modulesToUpdate = new Set<string>() modulesToUpdate.add(path) await Promise.all( Array.from(modulesToUpdate).map(async (dep) => { const [path, query] = dep.split(`?`) try { const newMod = await import( path + `?t=${timestamp}${query ? `&${query}` : ''}` ) moduleMap.set(dep, newMod) } catch (e) {} }), )}
当然,其实有些热更新细节,比如 css请求
、如何保存原始数据
等等,可能要费劲一点,但主要流程也是大致相同,我们理解主要原理即可
4、为什么这么快以及为什么能按需处理
这个其实在文章开头已经埋过伏笔,这两个问题,其实就是一个相辅相成问题,之所以快就是因为能按需处理,而按需处理就导致他非常快
好像还是很绕,我们来详细解释一下
在传统的 webpack
时代,我们的 serve
服务在启动之前是需要编译全部文件的,所以在启动之初是非常耗时的
而到了 vite
时代,我们由于利用 浏览器对于 esm
,我们只需要请求的时候服务处理成 esm
模块即可,所以,理论上来说启动服务只需要一瞬间,因为他省略了打包的逻辑,但当然也是有代价的,你请求页面的时候,就会即时编译
,会有些许的损耗
但这也比启动的全量编译消耗小很多
这时候我们就解释了第一个问题 之所以快是因为启动的时候不用编译
我们在来解释第二个问题,为什么能按需处理
同样的在 webpack
时代,我们启动的时候要全量打包,之所以这么做是因为,我不知道你最开始要打开什么页面,进入哪个路由,因为是编译是前置的,所以要一股脑全给你
而由于 vite
是即时编译
,于是我就能知道你要什么,你要什么就给什么, 当然就做到了按需加载
最后
终于写完了希望各位 jym
给个三连,老骥下台鞠躬!!!!