vlambda博客
学习文章列表

来了来了,最新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: {
      stabilityThreshold100,
      pollInterval10
    }
    // 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({ noServertrue })
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, null2)
    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)
}

compileSFCTemplateparseSFCcompileSFCTemplatecompileSFCStyle编译方法都会讲当前读取的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()会进入下一个中间件moduleRewritePluginmoduleRewritePlugin方法

省略部分代码,提取重要代码

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,每周一篇小技巧,一起快速入坑,希望能在前端道路上,和你努力,一起共勉!