来了来了,最新vite源码分析,vite到底为什么比webpack快
vite源码
vite执行流程
初始化
当我们执行vite serve
命令的时候,vite
会在项目启动的时候访问/bin/vite.js
文件
vite.js文件代码如下:
require('../dist/node/cli')
他实际上访问的是node
下的cli
文件,我们可以直接进入相对应的cli
文件,该文件位于 /src/node/cli.ts
下,我们可以直接打开这个文件,内部用了一个立即执行函数,具体代码看下面
// /src/node/cli.ts
;(async () => {
// 主要代码如下
const envMode = mode || m || defaultMode
// resolveOptions函数 主要拿到vite的配置
// 这里可以理解成webpack的一些初始化配置
const options = await resolveOptions(envMode)
process.env.NODE_ENV = process.env.NODE_ENV || envMode
// 接下来根据我们输入的键或者默认走serve
if (!options.command || options.command === 'serve') {
runServe(options)
} else if (options.command === 'build') {
runBuild(options)
} else if (options.command === 'optimize') {
runOptimize(options)
} else {
console.error(chalk.red(`unknown command: ${options.command}`))
process.exit(1)
}
})
从上面的代码,我们可以看到,我们在日常的开发过程中,主要走的是runServe()
函数,接下来,我们来看runServe
具体做了什么内容
runServe函数的处理
function runServe() {
// 创建一个server服务
const server = require('./server').createServer(options)
// 启动一个node进程
let port = options.port || 3000
let hostname = options.hostname || 'localhost'
const protocol = options.https ? 'https' : 'http'
// 启动错误处理
server.on('error', (e: Error & { code?: string }) => {})
// 启动成功处理
server.listen(port, () => {})
}
runServe函数最重要的就是创建一个server服务,这里也是npm run serve
的核心在server.js
内部的方法里
server.js文件
该文件位于 /src/node/server/index.ts
主要代码如下显示处理,这里的主要逻辑处理
-
创建 koa
服务 -
创建 websocket
链接 -
监听根目录下的文件
export function createServer(config) {
// 创建一个server服务
const app = new Koa<State, Context>()
const server = resolveServer(config, app.callback())
// 调用chokidar对文件进行递归监听,并排除node模块和.git目录
// chokidar链接: https://www.npmjs.com/package/chokidar
const watcher = chokidar.watch(root, {
ignored: [/node_modules/, /\.git/],
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 10
}
// createResolver
// 处理alias别名的应用
// 对已请求的文件或者请求进行缓存
const resolver = createResolver(root, resolvers, alias, assetsInclude)
// 将服务器信息绑定到koa的context内
app.use((ctx, next) => {
Object.assign(ctx, context)
ctx.read = cachedRead.bind(null, ctx)
return next()
})
}
加载koa中间件
将所有的路由中间件放到resolvedPlugins
数组,再依次遍历执行内部的中间件操作
const resolvedPlugins = [
// somecode
]
resolvedPlugins.forEach((m) => m && m(context))
serverPluginHmr中间件
在执行npm run serve
的时候,vite
会在内部启动一个websocket
服务并构建一个send
方法,用来与客户端的hmr
通信,内部对非.vue
和样式
文件做了文件变动更新,部分核心代码如下
const wss = new WebSocket.Server({ noServer: true })
server.on('upgrade', (req, socket, head) => {
if (req.headers['sec-websocket-protocol'] === 'vite-hmr') {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req)
})
}
})
// 通知客户端文件,什么文件已改动,需要重新请求文件
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)
}
})
})
const handleJSReload = (watcher.handleJSReload = (
filePath: string,
timestamp: number = Date.now()
) => {
//...somecode
}
)
// somecode
watcher.on('change', (file) => {
if (!(file.endsWith('.vue') || isCSSRequest(file))) {
handleJSReload(file)
}
})
serverPluginHtml中间件
serverPluginHtml
中间件,内部方法rewriteHtml
在浏览器进行访问的时候进行index.htm
文件的重写。访问public/index.html
文件的时候,会进入到koa
拦截,并在head
的第一个子标签加入<script type="module">import "/vite/client"</script>
文件,实现热刷新HMR
,并在内部开启一个.html
文件的监听,如果.html
文件被改动,则会调用send
方法,并发送类型为full-reload
数据与给客户端
app.use(async (ctx, next) => {
await next()
if (ctx.status === 304) {
return
}
// 拿到.html文件,并重写当前文件,重写html脚本,加入client-hmr文件,并将当前读取到的脚本进行缓存
// 缓存的原因,每次文件请求都会经过koa多次,这样可以减少操作
if (ctx.response.is('html') && ctx.body) {
const importer = ctx.path
// 读取文件
const html = await readBody(ctx.body)
if (rewriteHtmlPluginCache.has(html)) {
debug(`${ctx.path}: serving from cache`)
ctx.body = rewriteHtmlPluginCache.get(html)
} else {
if (!html) return
ctx.body = await rewriteHtml(importer, html)
rewriteHtmlPluginCache.set(html, ctx.body)
}
return
}
})
// 监听.html文件
watcher.on('change', (file) => {
const path = resolver.fileToRequest(file)
if (path.endsWith('.html')) {
watcher.send({
type: 'full-reload',
path
})
}
})
serverPluginVue中间件
serverPluginVue
这个中间件应该是重点中的重点了,遇到.vue
的文件且没有带type
方法
文件位于:/src/server/serverPluginVue
,当浏览器打开,请求到以.vue
结尾的文件,会先进入这个中间针对.vue
会先做一层处理,将每一个.vue
文件重写成.js
文件写入html
中,会遵循以下格式处理:
<template></template>
语法转换成import { render as __render } from "/App.vue?type=template"
<style></style>
语法转换成import "/App.vue?type=style&index=0"
代码如下:
// ...code
// 处理单个vue文件,拿到转义后可执行的对象数据
const descriptor = await parseSFC(root, filePath, ctx.body)
// 如果不存在query.type则默认为第一次加载vue文件
// 执行 if (!query.type) 逻辑
// 提取编译后的vue文件数据,函数内部做了一系列的
// import 处理,例如我们上面所说的
const { code, map } = await compileSFCMain(
descriptor,
filePath,
publicPath,
root
)
ctx.body = code
ctx.map = map
// etagCacheCheck函数一次浏览url缓存,并修改status状态
return etagCacheCheck(ctx)
compileSFCMain
主要用来将我们单页面组件,编译成浏览器识别的文件,
以App.vue
为栗子:
<template>
<h1>Vite Playground</h1>
</template>
<script>
const App = {}
export { App as default }
</script>
<style scoped lang='scss'>
body {
background: #f5f5f5;
}
</style>
转换成
const App = {}
export { }
const __script = App
import "/App.vue?type=style&index=0"
__script.__scopeId = "data-v-c44b8200"
import { render as __render } from "/App.vue?type=template"
__script.render = __render
__script.__hmrId = "/App.vue"
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)
__script.__file = "E:\\work\\vite\\playground\\App.vue"
export default __script
以上被解析后.vue
文件,遇到import
会再一次发起请求,比如加载/App.vue?type=style
文件,会再一次进入到serverPluginVue.js
文件,根据ctx.query
进入styles
解析条件
if (query.type === 'style') {
// 拿到相对应的style对象
const index = Number(query.index)
const styleBlock = descriptor.styles[index]
// 解决style路径引用,让浏览器能识别
if (styleBlock.src) {
filePath = await resolveSrcImport(root, styleBlock, ctx, resolver)
}
// 第一个style-hash-id 唯一识别
const id = hash_sum(publicPath)
// 拿到style文件编译后的结果
const result = await compileSFCStyle(
root,
styleBlock,
index,
filePath,
publicPath,
config
)
ctx.type = 'js'
// 调用客户端client, updateStyle方法,追加style样式
ctx.body = codegenCss(`${id}-${index}`, result.code, result.modules)
// 标记将读取过的文件进行缓存
return etagCacheCheck(ctx)
}
相对应的type=template
也会进入template
解析
if (query.type === 'template') {
// 拿到template块
const templateBlock = descriptor.template!
if (templateBlock.src) {
filePath = await resolveSrcImport(root, templateBlock, ctx, resolver)
}
// 拿到vue所对应的node_module地址路径
const cached = vueCache.get(filePath)
const bindingMetadata = cached && cached.script && cached.script.bindings
const vueSpecifier = resolveBareModuleRequest(
root,
'vue',
publicPath,
resolver
)
// 将template语法转换成jsx语法丢回浏览器
// 让浏览器可以识别并加载出相对应的Dom节点
const { code, map } = compileSFCTemplate
// 返回浏览器
ctx.body = code
ctx.map = map
// 标记将读取过的文件进行缓存
return etagCacheCheck(ctx)
}
compileSFCTemplate
、parseSFC
、compileSFCTemplate
、compileSFCStyle
编译方法都会讲当前读取的path
路径,写入缓存,例如:compileSFCTemplate方法
function compileSFCTemplate() {
// ...somecode
// 将当前路径做一次map映射,缓存文件
vueCache.set(filePath, cached)
}
handleVueReload
方法用来处理.vue
文件变动后的回调,然后根据缓存的数据和当前内容做一次数据匹配,判断文件哪块内容变动,在调用send
方法通知客户端,客户端根据不同的类型重新发起请求,读取改动后的文件内容
const handleVueReload = (watcher.handleVueReload = async ()=>{
// 获取请求路径
const publicPath = resolver.fileToRequest(filePath)
// 读取缓存文件
const cacheEntry = vueCache.get(filePath)
// 调用send方法
const { send } = watcher
// somecode
// script部分更新,重新渲染vue
if (
!isEqualBlock(descriptor.script, prevDescriptor.script) ||
!isEqualBlock(descriptor.scriptSetup, prevDescriptor.scriptSetup)
) {
return sendReload()
}
// template部分更新
if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
// 如果script部分没变,直接需要缓存的script就行
// 解决数据绑定解析的问题
if (prevDescriptor.scriptSetup && descriptor.scriptSetup) {
vueCache.get(filePath)!.script = cacheEntry!.script
}
// 标记需要重新渲染vue
needRerender = true
}
// 针对于css-module的变化,则需要全量刷新
// 因为内部$style已变更
if (
prevStyles.some((s) => s.module != null) ||
nextStyles.some((s) => s.module != null)
) {
return sendReload()
}
// styls的全局变量改动也需要重新渲染vue
if (
prevStyles.some((s, i) => {
const next = nextStyles[i]
if (s.attrs.vars && (!next || next.attrs.vars !== s.attrs.vars)) {
return true
}
})
) {
return sendReload()
}
// scoped变动也需要重新渲染
if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
return sendReload()
}
// 遍历新的vue文件内部的style
// 匹配每一个数据,如果开动则重新请求style
nextStyles.forEach((_, i) => {
if (!prevStyles[i] || !isEqualBlock(prevStyles[i], nextStyles[i])) {
didUpdateStyle = true
const path = `${publicPath}?type=style&index=${i}`
send({
type: 'style-update',
path,
changeSrcPath: path,
timestamp
})
}
})
// 如果当前style长度与缓存不匹配,则删除style
prevStyles.slice(nextStyles.length).forEach((_, i) => {
didUpdateStyle = true
send({
type: 'style-remove',
path: publicPath,
id: `${styleId}-${i + nextStyles.length}`
})
})
const prevCustoms = prevDescriptor.customBlocks || []
const nextCustoms = descriptor.customBlocks || []
//自定义块内容变化,也需要重新渲染vue
if (
nextCustoms.some(
(_, i) =>
!prevCustoms[i] || !isEqualBlock(prevCustoms[i], nextCustoms[i])
)
) {
return sendReload()
}
// template语法变动,渲染template组件
if (needRerender) {
send({
type: 'vue-rerender',
path: publicPath,
changeSrcPath: publicPath,
timestamp
})
}
})
serverPluginClient中间件
文件位于:/src/server/serverPluginClient
中,该中间件主要处理客户端与服务端链接websocket
通信,直接读取了/src/client/client.js
文件并以text的形式返回给客户端,客户端直接加载websocket
// 提取其中重要代码
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')
// ...code
socket.addEventListener('message', async ({ data }) => {
// 监听服务器的message信息,根据不同类型就行数据热更新
const payload = JSON.parse(data)
if (payload.type === 'multi') {
payload.updates.forEach(handleMessage)
} else {
// 处理文件热更新方法
handleMessage(payload)
}
})
handleMessage方法作用是,通过nodejs
模块chokidar
对文件进行相对应的监听变动,该函数也是响应客户端文件变动的重要方法,也会重点分析
async function handleMessage(payload) {
const { path, changeSrcPath, timestamp } = payload
switch (payload.type) {
// 链接
case 'connected': ''; break;
// vue文件script部分更新
case 'vue-reload': '';
// 重新请求vue文件
import(`${path}?t=${timestamp}`)
break;
// vue文件template部分更新
case 'vue-rerender':
// 请求vue.template部分
import(`${templatePath}&t=${timestamp}`)
break;
// style样式更新
case 'style-update':
// 请求vue.style部分
// 这里做了一次如果当前路径存在则直接修改
// 不需要添加dom标签
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}`)
break;
// style移除更新
case 'style-remove':
// 直接移除style
removeStyle(payload.id)
break;
// js文件更新
case 'js-update': ''; break;
case 'custom': ''; break;
// 修改html则重新刷新页面
case 'full-reload': ''; break;
}
}
serverPluginModuleRewrite 与 serverPluginModuleResolve
内部有个rewriteImports
方法,主要作用就是我们在项目中引入的模块做替换, 如:
import test from './test.js'
import vue from 'vue'
代码会转换如下
import test from './test.js'
import vue from '/@modules/vue/dist/vue.runtime.esm-bundler.js'
相比较下来,我们能看到,如果引入的是属于node_modules
包,vite
会自动转为/@modules/xxx
来识别我们的npm
包
然后会进入serverPluginModuleResolve
下的moduleResolvePlugin
方法,该方法用来解决/@modules
开头的依赖,内部serve
方法是最后得到的本路径,然后读取并返回
首次会进入
// resolveNodeModuleFile方法会标记换粗,并返回当前模块被解析到的本地路径
// 例如: '/@modules/vue/dist/vue.runtime.esm-bundler.js'
// 以下为我个人项目盘符
// 转为换: 'E:\\work\\vite\\node_modules\\vue\\dist\\vue.runtime.esm-bundler.js'
const nodeModuleFilePath = resolveNodeModuleFile(importerFilePath, id)
if (nodeModuleFilePath) {
return serve(id, nodeModuleFilePath, 'node_modules')
}
// serve 做一次文件缓存,并读取文件,进入下一个中间件
const serve = async (id: string, file: string, type: string) => {
moduleIdToFileMap.set(id, file)
moduleFileToIdMap.set(file, ctx.path)
debug(`(${type}) ${id} -> ${getDebugPath(root, file)}`)
// ctx.read方法就是我们在一开始初始化的地方
// ctx.read = cachedRead.bind(null, ctx)
await ctx.read(file)
return next()
}
cachedRead
方法位于/src/node/utils/fsUtils.js
用于缓存已经读取过的文件,设置读取文件类型,给ctx.body
追加信息
export async function cachedRead() {
// 读取文件修改时间
const lastModified = fs.statSync(file).mtimeMs
// 读取缓存
const cached = fsReadCache.get(file)
// 设置请求信息
if (ctx) {
ctx.set('Cache-Control', 'no-cache')
ctx.type = mime.lookup(path.extname(file)) || 'application/octet-stream'
}
// 如果有缓存,且未修改
if (cached && cached.lastModified === lastModified) {
// 执行代码逻辑
}
// 读取文件
let content = await fs.readFile(file)
// map处理
if (file.endsWith('.map')) {
// ...code
}
// 设置请求信息以及返回体信息
if (ctx) {
ctx.etag = etag
ctx.lastModified = new Date(lastModified)
ctx.body = content
ctx.status = 200
const { root, watcher } = ctx
// 如果文件不在root下,则需要单独执行监听
watchFileIfOutOfRoot(watcher, root, file)
}
}
当我们完成了ctx.read
方法后,代码中的await next()
会进入下一个中间件moduleRewritePlugin
的moduleRewritePlugin方法
省略部分代码,提取重要代码
if(...) {
// 读取moduleResolvePlugin方法,read读取文本后的content
const content = await readBody(ctx.body)
// somecode
// 解决import的路径
// 这里才是实际返回浏览器内容的数据
ctx.body = rewriteImports(
root,
content!,
importer,
resolver,
ctx.query.t
)
}
vite
源码大概到这里就分析结束了,基本上它就是一个冷启动,根据需要用到的文件进行按需加载,大大加快了构建速度,通过监听文件改动,对比修改块内容,实现按需构建
,对比webpack
这里,确实加快了热刷新编译,不得不说尤大的很多想法和设计确实值得我学习。。哈哈哈!!!
我是godlike,每周一篇小技巧,一起快速入坑,希望能在前端道路上,和你努力,一起共勉!