前言 自 vue3.0
的正式发布以来,关注度一直很高,同时被带火的还有一款尤大打造的工具 vite
,声称能让页面及时响应我们修改的代码效果,而不需要过长的等待时间,苦 webpack
久已的我们可能将要迎来开发环境 runtime
的时代了!
Vite 是什么? 在深入了解 vite
的运行机制之前,老生常谈先来聊聊 vite
到底是个什么东西,官方 对vite
的说明为
Vite is an opinionated web dev build tool that serves your code via native ES Module imports during dev and bundles it with Rollup for production.
大意是说 vite
是一个基于浏览器原生 esmodule
的特性来工作的前端开发构建工具,并且通过 rollup
来实现生产打包,也就是说,本身 vite
是不需要将 import/export 等 es 语法转成 AMD
规范让浏览器去读取,而靠浏览器原生的引模块的能力去引入,这和 webpack
先在内存编译再发送给浏览器读取是有本质区别的,带来的好处是显而易见的,少了编译这一环节,浏览器的响应时间会大大缩短!要知道项目一大,每次改完代码等待编译的时间,emmm,懂的都懂。
下图为 vite
的组成主体
可以看到 vite
本身并不提供 服务器能力 和 打包能力 ,而是借助第三方包 koa
和 rollup
来实现,而真正核心的关键点,也是vite
真正在做的是一整套中间件和插件系统,我想尤大肯定是看中了 koa
洋葱圈模型的灵活性和 rollup
的轻量。
Vite 是怎么处理不同文件的?
备注:本篇源码基于最新的 1.0.0-rc.9
版本
针对日常前端开发的文件类型可分为 静态资源文件
、html文件
、js文件
、css文件
,又因为不同的开发需求,js文件
和 css文件
都有各自的变体,那么 vite
是怎么处理这些引入的文件?别着急,接下来让我们从源码角度来分析具体实现。
中间件的执行顺序 在返回给浏览器之前 vite
会先对文件类型做不同的 处理
1 2 3 4 5 6 7 8 9 10 11 12 const resolvedPlugins = [ sourceMapPlugin, moduleRewritePlugin, htmlRewritePlugin, ...toArray(configureServer), envPlugin, ...省略其他中间件, ];
其中 moduleRewritePlugin
为重写 js
模块的 核心中间件 ,在所有中间件执行完毕之后,为所有 import
的模块打上标记和处理 模块路径 。vite
采用 es-module-lexer
来解析 es 模块 import 信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 import { init as initLexer, parse as parseImports, ImportSpecifier, } from "es-module-lexer" ; imports = parseImports(source)[0 ]; for (let i = 0 ; i < imports.length; i++) { const { s : start, e : end, d : dynamicIndex } = imports[i]; let id = source.substring(start, end); const resolved = resolveImport(root, importer, id, resolver, timestamp); export const resolveImport = ( root: string, importer : string, id : string, resolver : InternalResolver, timestamp?: string ): string => { id = resolver.alias(id) || id; if (bareImportRE.test(id)) { id = `/@modules/${resolveBareModuleRequest( root, id, importer, resolver )} ` ; } else { let { pathname, query } = resolver.resolveRelativeRequest(importer, id); pathname = resolver.normalizePublicPath(pathname); if (!query && path.extname(pathname) && !jsSrcRE.test(pathname)) { query += `?import` ; } id = pathname + query; } if (timestamp) { const dirtyFiles = hmrDirtyFilesMap.get(timestamp); const cleanId = cleanUrl(id); if (dirtyFiles && dirtyFiles.has(cleanId)) { id += `${id.includes(`?` ) ? `&` : `?` } t=${timestamp} ` ; } else if (latestVersionsMap.has(cleanId)) { id += `${id.includes(`?` ) ? `&` : `?` } t=${latestVersionsMap.get( cleanId )} ` ; } } return id; }; }
这样我们原来的 模块
,比如
1 2 3 4 5 6 7 import { createApp } from "vue" ;import App from "./App.vue" ;import "./index.css" ;import "./assets/logo.png" ;createApp(App).mount("#app" );
经过 moduleRewritePlugin
处理后返回给客户端就变成了
1 2 3 4 5 6 7 import { createApp } from "/@modules/vue.js" ;import App from "/src/App.vue" ;import "/src/index.css?import" ;import "/src/assets/logo.png?import" ;createApp(App).mount("#app" );
当代码执行到第一句时,发送一个请求去服务器拿 '/@modules/vue.js'
,返回给客户端之前,通过 moduleResolvePlugin
进行实际路径的解析,读取资源,返回给客户端,整个流程如下所示 那 vite
是怎么解析不同的文件为 js
模块 返回给客户端执行的呢?让我们继续往下看
静态资源 之前介绍过 vite
服务器端是通过 koa
实现的,所有文件的处理其实都是通过 koa
中间件来做的,源码中对应处理 静态资源 的中间件为 assetPathPlugin
如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export const assetPathPlugin: ServerPlugin = ({ app, resolver } ) => { app.use(async (ctx, next) => { if (resolver.isAssetRequest(ctx.path) && isImportRequest(ctx)) { ctx.type = "js" ; ctx.body = `export default ${JSON .stringify(ctx.path)} ` ; return ; } return next(); }); };
静态资源通过路径的解析,直接返回资源绝对路径
1 2 3 4 import './assets/logo.png' /src/assets/logo.png?import
实际返回
1 export default "/src/assets/logo.png" ;
html 文件 处理 html
文件用的中间件为 rewriteHtml
,逻辑很简单就做了三件事:
按 moduleRewritePlugin
的逻辑转换 script
标签里可能存在的 import
语句
1 2 3 4 5 6 7 8 9 10 11 12 html = html.replace(scriptRE, (matched, openTag, script ) => { if (script) { return `${openTag} ${rewriteImports( root, script, importer, resolver )} </script>` }
为 hmr
热更新注册脚本,将script
的 src
属性 注册进 importerMap
1 2 3 4 5 6 7 8 9 10 11 12 13 const srcAttr = openTag.match(srcRE) if (srcAttr) { const importee = resolver.normalizePublicPath( cleanUrl(path.posix.resolve('/' , srcAttr[1 ] || srcAttr[2 ])) ) debugHmr(` ${importer} imports ${importee} ` ) ensureMapEntry(importerMap, importee).add(importer) } return matched }
为 hmr
热更新添加客户端脚本,这个将在热更新章节详细讲解
1 2 3 4 5 6 7 8 9 10 const devInjectionCode = `\n<script type="module">import "${clientPublicPath} "</script>\n` ;const processedHtml = injectScriptToHtml(html, devInjectionCode);return await transformIndexHtml( processedHtml, config.indexHtmlTransforms, "post" , false );
js 文件 正常的js
文件本身并不需要特殊的处理,这里主要着重在 .vue
文件的解析,毕竟 vite
一开始就是为 vue3
保驾护航的,解析 .vue
文件主要用到了中间件 vuePlugin
。当 .vue
文件到达服务器,vite
首先会解析 SFC
的内容
1 2 const descriptor = await parseSFC(root, filePath, ctx.body);
然后通过 compileSFCMain
这个解析函数,将 .vue
文件中的 script
标签,解析成对应的 js
模块,当初次解析 .vue
文件时,会对 style
、template
进行 query
标记
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const { code, map } = await compileSFCMain( descriptor, filePath, publicPath, root ) if (descriptor.styles) { descriptor.styles.forEach((s, i ) => { const styleRequest = publicPath + `?type=style&index=${i} ` if (descriptor.template) { const templateRequest = publicPath + `?type=template`
客户端通过重写过的 .vue
文件,再去向服务端请求对应的 style
、template
模块
1 2 import "/src/App.vue?type=style&index=0" ;import { render as __render } from "/src/App.vue?type=template" ;
由于有 query
标记,通过 compileSFCStyle
解析 style
标签,compileSFCTemplate
解析 template
标签
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const { code, map } = compileSFCTemplate( root, templateBlock, filePath, publicPath, descriptor.styles.some((s ) => s.scoped), bindingMetadata, vueSpecifier, config ); const result = await compileSFCStyle( root, styleBlock, index, filePath, publicPath, config );
至此完成了对 vue
文件的解析。
style 文件 style
文件通过 cssPlugin
中间件来解析,通过 processCss
方法来解析 css
,通过 codegenCss
方法来重写 css
文件为 esmodule
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 if (isImportRequest(ctx)) { const { css, modules } = await processCss(root, ctx) ctx.type = 'js' ctx.body = codegenCss(id, css, modules) } } const cssPreprocessLangRE = /\.(less|sass|scss|styl|stylus|postcss)$/ const css = (await readBody(ctx.body))! const filePath = resolver.requestToFile(ctx.path) const preprocessLang = (ctx.path.match(cssPreprocessLangRE) || [])[1 ] const result = await compileCss(root, ctx.path, { id : '' , source : css, filename : filePath, scoped : false , modules : ctx.path.includes('.module' ), preprocessLang, preprocessOptions : ctx.config.cssPreprocessOptions, modulesOptions : ctx.config.cssModuleOptions }) if (typeof result === 'string' ) { const res = { css : await rewriteCssUrls(css, ctx.path) } processedCSS.set(ctx.path, res) return res } const res = { css : await rewriteCssUrls(result.code, ctx.path), modules : result.modules } 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
最终返回给浏览器如下结果
1 2 3 4 5 import { updateStyle } from "/vite/client" ;const css = "#app {\n font-family: Avenir, Helvetica, Arial, sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n text-align: center;\n color: #2c3e50;\n margin-top: 60px;\n}\n" ; updateStyle('"2418ba23"' , css); export default css;
至此 css
文件的解析也已完成
Vite 的热更新机制 vite
的热更新可以实现代码效果的 毫秒级 响应,告别痛苦的等编译时间,实现真正意义上的 热更新 ,接下来让我一起来探究其 热更新 的实现原理~
热更新是什么? 热更新(hot module replacement) ,简称 hmr
,是一种无需刷新浏览器即可更新代码效果的技术,实现该技术的关键点是要建立 浏览器 和 服务器 之间的联系,还好我们现成就有一种技术可以实现:websocket
协议,普通的 http
协议为短连接,一次会话结束就会关闭,这显然没法满足我们时刻都需要关联 浏览器 和 服务器 的需求,而websocket
为长连接,可以一直保持 浏览器 和 服务器 之间的会话不中断,通过事件来互相传送数据,vite
也用了 websocket
来实现 热更新 。
websocket 连接方式 之前说过 vite
通过 websocket
来实现 浏览器 和 服务器 之间的链接,具体到源码里,服务端在 hmrPlugin
中间件里
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const wss = new WebSocket.Server({ noServer : true });wss.on("connection" , (socket ) => { debugHmr("ws client connected" ); socket.send(JSON .stringify({ type : "connected" })); }); wss.on("error" , (e: Error & { code: string } ) => { if (e.code !== "EADDRINUSE" ) { console .error(chalk.red(`[vite] WebSocket server error:` )); console .error(e); } });
客户端在 client.ts
文件中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 const socketProtocol = __HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws' ) const socketHost = `${__HMR_HOSTNAME__ || location.hostname} :${__HMR_PORT__} ` const socket = new WebSocket(`${socketProtocol} ://${socketHost} ` , 'vite-hmr' )socket.addEventListener('message' , async ({ data }) => { const payload = JSON .parse(data) as HMRPayload | MultiUpdatePayload if (payload.type === 'multi' ) { payload.updates.forEach(handleMessage) } else { handleMessage(payload) } }) socket.addEventListener('close' , () => { console .log(`[vite] server connection lost. polling for restart...` ) setInterval (() => { fetch('/' ) .then(() => { location.reload() }) .catch((e ) => { }) }, 1000 ) })
值得一提的是,我们写的代码里并没有写 WebSocket
的初始化代码,vite
是怎么注入到客户端的呢?秘密在之前我们提到的 rewriteHtml
中间件中,它在 html
文件里注入了请求脚本,请求 /vite/client
地址
1 2 3 <script type ="module" > import "/vite/client" ; </script >
地址经过 clientPlugin
中间件解析之后,返回 client.ts
的内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 const clientPublicPath = `/vite/client` const clientFilePath = path.resolve(__dirname, '../../client/client.js' )const clientCode = fs .readFileSync(clientFilePath, 'utf-8' ) .replace(`__MODE__` , JSON .stringify(config.mode || 'development' )) .replace( `__DEFINES__` , JSON .stringify({ ...defaultDefines, ...config.define }) ) app.use(async (ctx, next) => { if (ctx.path === clientPublicPath) { let socketPort: number | string = ctx.port let socketProtocol = null let socketHostname = null if (config.hmr && typeof config.hmr === 'object' ) { socketProtocol = config.hmr.protocol || null socketHostname = config.hmr.hostname || null socketPort = config.hmr.port || ctx.port if (config.hmr.path) { socketPort = `${socketPort} /${config.hmr.path} ` } } ctx.type = 'js' ctx.status = 200 ctx.body = clientCode .replace(`__HMR_PROTOCOL__` , JSON .stringify(socketProtocol)) .replace(`__HMR_HOSTNAME__` , JSON .stringify(socketHostname)) .replace(`__HMR_PORT__` , JSON .stringify(socketPort))
至此完成了 客户端 于 服务端 之间的链接。
Vite 怎么监听文件变化? 既然要 热更新 ,光有 websocket
是不够的,因为我们需要一个 action
,也就是触发时机去触发事件,才能达到更新的目的。vite
选用了和 webpack
一样的解决方案 chokidar ,为啥用这个呢?我们知道 nodejs
本身有 fs
模块的 api
来实现文件监听的功能,但是因为缺少一系列的优化,会带来一系列的问题,chokidar
的出现就为了解决这个问题,保证原来功能的基础上做了一系列的优化,可以更快的响应,兼容性也刚好。
在 vite
源码里,通过 chokidar
初始化了一个监听对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 const watcher = chokidar.watch(root, { ignored : [/node_modules/ , /\.git/ ], awaitWriteFinish : { stabilityThreshold : 100 , pollInterval : 10 } }) as HMRWatcher const context: ServerPluginContext = { root, app, server, watcher, resolver, config, port : config.port || 3000 } app.use((ctx, next ) => { Object .assign(ctx, context) ctx.read = cachedRead.bind(null , ctx) return next() })
然后在处理不同的中间件里,注入 watcher
的监控代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 watcher.on("change" , (file ) => { const path = resolver.fileToRequest(file); if (path.endsWith(".html" )) { debug(`${path} : cache busted` ); watcher.send({ type : "full-reload" , path, }); console .log(chalk.green(`[vite] ` ) + ` ${path} page reloaded.` ); } }); watcher.on("change" , (file ) => { if (file.endsWith(".vue" )) { handleVueReload(file); } });
看到这里有小伙伴肯定会疑惑,和 客户端 进行链接是 websocket
,为啥发送事件变成 watcher
了?其实这边的 send
方法就是 websocket
的 send
方法,在 hmrPlugin
中做了一层封装
1 2 3 4 5 6 7 8 9 10 11 const send = (watcher.send = (payload: HMRPayload ) => { const stringified = JSON .stringify(payload, null , 2 ); debugHmr(`update: ${stringified} ` ); wss.clients.forEach((client ) => { if (client.readyState === WebSocket.OPEN) { client.send(stringified); } }); });
然后 客户端 接收事件之后进行处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 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 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' : 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 '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' )) { const pagePath = location.pathname if ( pagePath === path || (pagePath.endsWith('/' ) && pagePath + 'index.html' === path) ) { location.reload() } return } else { location.reload() } } }
至此就完成了整一套 热更新 的流程
总结 vite
作为面向未来的前端构建工具,对前端开发体验是一次质的飞越!虽然现在还在起步阶段,也有不少 bug ,但相信社区的力量可以让它越来越好,加油!ヾ(◍°∇°◍)ノ゙