花个几分钟,Vite原理简单看

446 阅读5分钟

懒了大半年,2021年底,有机会用上vite+vue3用在实战项目上,vite搭建项目上手很快,配置也好理解,快速搭建新的小型项目,极速开发业务需求,搞定!2k工资到账,打卡下班🐶。

不不,菜鸟就多学点吧,想着有没有可以简单了解vite原理的路径,人们一直说vite是基于浏览器原生 ES imports是咋实现的?本着这个目的,git clone了vite仓库,直接翻到最初的log历史记录,可以看到有个v0.1.1的最初版本,我们就基于refactor: use async fs + expose createServer API这条历史记录进行git checkout(因为后续就是加入ts,样式热替换等,我们就从最简单的入手就好)

git2.png

项目目录

可以看到,现在项目目录很简单,如下,后续再一一介绍每个文件。

.
├── bin
│   └── vds.js
├── lib
│   ├── hmrClient.js
│   ├── hmrWatcher.js
│   ├── moduleMiddleware.js
│   ├── moduleRewriter.js
│   ├── parseSFC.js
│   ├── server.js
│   ├── utils.js
│   └── vueMiddleware.js
├── package.json
└── yarn.lock

现在我们手动在项目根目录添加index.htmlmain.jsComp.vue三个文件,方便调试

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>vite</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="/main.js"></script>
</body>
</html>

main.js

import { createApp } from 'vue'
import Comp from './Comp.vue'

createApp(Comp).mount('#app')

Comp.vue

<template>
  <button @click="count++">{{ count }}</button>
</template>

<script>
  export default {
    setup() {
      return {
        count: 1
      }
    }
  }
</script>

然后我们在package.json添加下启动命令

"scripts": {
  "dev": "bin/vds.js"
}

这样我们就可以yarn dev启动项目,尝试更改下Comp.vuemain.js文件的代码,浏览器都会实时更新,木问题,说明前期准备搞好了。

如果我们使用的是vscode,我们可以debug来代替yarn dev启动,如下点击Debug Script

package.png

然后我们就可以在我们想要的代码行进行断点调试了,如下,我们可以代码最左侧点击选择我们想要断点的代码行。

breakpoint.png

进入正题

我们先看下server.js文件,启动项目的开始。先不用看那么细

const server = http.createServer(async (req, res) => {
  const pathname = url.parse(req.url).pathname
  if (pathname === '/__hmrClient') {
    return sendJS(res, await hmrClientCode)
  } else if (pathname.startsWith('/__modules/')) {
    return moduleMiddleware(pathname.replace('/__modules/', ''), res)
  } else if (pathname.endsWith('.vue')) {
    return vue(req, res)
  } else if (pathname.endsWith('.js')) {
    const filename = path.join(process.cwd(), pathname.slice(1))
    try {
      const content = await fs.readFile(filename, 'utf-8')
      return sendJS(res, rewrite(content))
    } catch (e) {
      if (e.code === 'ENOENT') {
        // fallthrough to serve-handler
      } else {
        console.error(e)
      }
    }
  }

  serve(req, res, {
    rewrites: [{ source: '**', destination: '/index.html' }]
  })
})

大概看if/else语句分成了好几种情况。可以看到目前,分成5种请求情况

请求路径

  • /__hmrClient
  • /moduels/
  • .vue
  • .js
  • 其他重置到/index.html

一般我们访问网站先请求index.html,然后开始加载js资源

请求js

// 相对于当前项目根目录下的文件路径
// 如请求路径/main.js --> /Users/your_name/your_project/main.js
const filename = path.join(process.cwd(), pathname.slice(1))
try {
  const content = await fs.readFile(filename, 'utf-8')
  return sendJS(res, rewrite(content))
} catch (e) {
  if (e.code === 'ENOENT') {
    // fallthrough to serve-handler
  } else {
    console.error(e)
  }
}

代码块中sendJS(res, rewrite(content))很简单,就是向客户端返回js响应

function send(res, source, mime) {
  res.setHeader('Content-Type', mime)
  res.end(source)
}

function sendJS(res, source) {
  send(res, source, 'application/javascript')
}

可以看到这里还有有rewrite(content)对js代码进行了重写,它引用于const { rewrite } = require('./moduleRewriter'),我们前往moduleRewriter文件看看

const { parse } = require('@babel/parser')
const ms = require('magic-string')

exports.rewrite = (source, asSFCScript = false) => {
  const ast = parse(source, {
    // ...
  }).program.body

  let s
  ast.forEach((node) => {
    if (node.type === 'ImportDeclaration') {
      if (/^[^\.\/]/.test(node.source.value)) {
        // module import
        // import { foo } from 'vue' --> import { foo } from '/__modules/vue'
        ;(s || (s = new ms(source))).overwrite(
          node.source.start,
          node.source.end,
          `"/__modules/${node.source.value}"`
        )
      }
    } else if (node.type === 'ExportDefaultDeclaration') {
      ;(s || (s = new ms(source))).overwrite(
        node.start,
        node.declaration.start,
        `let __script; export default (__script = `
      )
      s.appendRight(node.end, `)`)
    }
  })

  return s ? s.toString() : source
}

这里使用babel解析成ast抽象语法树,并且暂时处理两种情况。

一种是ImportDeclaration,可以看到注释是将import { foo } from 'vue' --> import { foo } from '/__modules/vue',这是为了后续引入vue等第三方包的时候提供标识,让请求服务发现含有__modules时前往node_modules获取资源。而例如我们自己写的代码如import utils fom './utils'等就正常查找项目路径。

另一种是ExportDefaultDeclaration,例子如下

export default {
  setup() { 
    return {
      count: 0
    }
  }
}
// 转换成
let __script; export default (__script = {
  setup() {
    return {
      count: 0
    }
  }
})

请求__modules

请求_modules,也即请求第三包的资源,我们看下是如何做的

// ...
else if (pathname.startsWith('/__modules/')) {
  return moduleMiddleware(pathname.replace('/__modules/', ''), res)
}
// ...

我们前往moduleMiddleware文件查看

// moduleMiddleware.js
const path = require('path')
const resolve = require('resolve-cwd')
const { sendJSStream } = require('./utils')

exports.moduleMiddleware = (id, res) => {
  let modulePath
  try {
    // 例如moduleMiddleware('vue', res) --> resolve('vue'),会自动查找node_modules中vue包路径
    // 将'vue'变成'/Users/your_name/your_project/node_modules/vue/index.js'
    modulePath = resolve(id)
    if (id === 'vue') {
      modulePath = path.join(
        path.dirname(modulePath),
        'dist/vue.runtime.esm-browser.js'
      )
    }
  } catch (e) {
    res.setStatus(404)
    res.end()
  }

  sendJSStream(res, modulePath)
}

可以看到moduleMiddleware.js文件代码还是很简单的,目的是找到node_modules里第三方资源并返回给客户端。

例如查找vue的包,首先使用resolve-cwd工具,modulePath = resolve(id),查找到

modulePath="/Users/your_name/your_project/node_modules/vue/index.js"

再对vue引用路径特殊更改

modulePath = path.join(
  path.dirname(modulePath),
  'dist/vue.runtime.esm-browser.js'
)
// 更改为:modulePath="/Users/your_name/your_project/node_modules/vue/dist/vue.runtime.esm-browser.js"

最后使用sendJSStream返回给客户端(这里没直接使用上面的sendJS,而是使用fs模块的流来读取。因为第三方包的源码文件都比较大,一次性读取会占用大量内存,效率很低,用流来读取更合适)

// utils.js
function sendJSStream(res, file) {
  res.setHeader('Content-Type', 'application/javascript')
  const stream = fs.createReadStream(file)
  stream.on('open', () => {
    stream.pipe(res)
  })
  stream.on('error', (err) => {
    res.end(err)
  })
}
exports.sendJSStream = sendJSStream

请求vue文件

接下来是请求vue文件

const vue = require('./vueMiddleware')
// ...
else if (pathname.endsWith('.vue')) {
  return vue(req, res)
}
// ...

同样我们前往vueMiddleware文件看看,代码有几十行,我们拆开来讲吧

const { parseSFC } = require('./parseSFC')

module.exports = async (req, res) => {
  const parsed = url.parse(req.url, true)
  const query = parsed.query
  const filename = path.join(process.cwd(), parsed.pathname.slice(1))
  const [descriptor] = await parseSFC(filename, true)
  //...
}

首先会对请求的vue文件进行parseSFC方法进行解析处理,我们再前往parseSFC文件看下,如下。对vue文件进行解析,顺便做一下缓存,方便后续监听文件变化的时候对比新旧数据。

const { parse } = require('@vue/compiler-sfc')

const cache = new Map()
exports.parseSFC = async (filename, saveCache = false) => {
  const content = await fs.readFile(filename, 'utf-8')
  const { descriptor, errors } = parse(content, { filename })

  // 获取上一次缓存数据
  const prev = cache.get(filename)
  if (saveCache) {
    cache.set(filename, descriptor)
  }
  return [descriptor, prev]
}

@vue/compiler-sfc是Vue的底层工具,方便库作者能够对 Vue 单文件实现重新挂载、渲染。

我们知道,Vue单文件组件(SFC)有template,script,style标签组成。当解析拿到的descriptor字段,我们会分成template,script,style三个情况进行处理。

// ...
const { rewrite } = require('./moduleRewriter')
if (!query.type) {
  // inject hmr client
  let code = `import "/__hmrClient"\n`
  if (descriptor.script) {
    code += rewrite(descriptor.script.content, true)
  } else {
    code += `const __script = {}; export default __script`
  }

  if (descriptor.template) {
    code += `\nimport { render as __render } from ${JSON.stringify(
      parsed.pathname + `?type=template${query.t ? `&t=${query.t}` : ``}`
    )}`
    code += `\n__script.render = __render`
  }

  if (descriptor.style) {
    // TODO
  }

  code += `\n__script.__hmrId = ${JSON.stringify(parsed.pathname)}`
  return sendJS(res, code)
}

首先对script标签的内容,进行上面讲过的用moduleRewriter进行部分重写。

export default {
  setup() { 
    return { count: 0 }
  }
}
// 转换成
let __script; export default (__script = {
  setup() {
    return { count: 0 }
  }
})

对于template部分先不急着进行编译,先给上面声明的变量__scritpt添加唯一表识__hmrtIdrender方法,这目的是热替换时,方便能够拿到对应vue组件的render进行重新渲染。

请求Comp.vue文件内容如下(style部分先忽略)

Comp.png

我们留意到,请求vue文件的时候还会import "/__hmrClient",这是用来浏览器端接受hmr通知的,我们下面再讲。

然后,还会再次import一遍Comp.vue,并且为请求路径添加参数type=template,这才会真正对template进行编译解析。另外t=query.t(query.t为时间轴)是当Comp.vue更改的时候才会添加上,这是我们常用的防止浏览器缓存的方法。看下vueMiddleware对请求Comp.vue?type=template处理

const { compileTemplate } = require('@vue/compiler-sfc')

if (query.type === 'template') {
  const { code, errors } = compileTemplate({
    source: descriptor.template.content,
    filename,
    compilerOptions: {
      // TODO infer proper Vue path
      runtimeModuleName: '/__modules/vue'
    }
  })
  return sendJS(res, code)
}

可以看到,这时才开始调用@vue/compiler-sfc工具的compileTemplate方法,进行编译。

例如

<template>
  <button @click="count++">{{ count }}</button>
</template>

将变成

CompTemplate.png

至此,启动项目到初次渲染的请求过程就算粗略地介绍完了,之后浏览器端拿到渲染函数,开始虚拟DOM树的构建,转换真实DOM......

监听文件变化

当启动项目,并在浏览器渲染完成之后,修改文件之后是怎么触发重新渲染更新的呢?我们再回到server.js文件看看

const ws = require('ws')

const wss = new ws.Server({ server })
const sockets = new Set()

wss.on('connection', (socket) => {
  sockets.add(socket)
  socket.send(JSON.stringify({ type: 'connected' }))
  socket.on('close', () => {
    sockets.delete(socket)
  })
})

可以看到,我们创建了webSocket,在本地服务监听文件变化,推送通知给浏览器端

const { createFileWatcher } = require('./hmrWatcher')

createFileWatcher((payload) =>
  sockets.forEach((s) => s.send(JSON.stringify(payload)))
)

我们前往看下createFileWatcher方法的监听规则

const fs = require('fs')
const path = require('path')
const chokidar = require('chokidar')

exports.createFileWatcher = (notify) => {
  const fileWatcher = chokidar.watch(process.cwd(), {
    ignored: [/node_modules/]
  })

  fileWatcher.on('change', async (file) => {
    const resourcePath = '/' + path.relative(process.cwd(), file)

    if (file.endsWith('.vue')) {
      // ...
    } else {
      console.log(`[hmr:full-reload] ${resourcePath}`)
      notify({
        type: 'full-reload'
      })
    }
  })
}

我们监听process.cwd()进程的当前工作目录,一般都是当前项目根目录。监听所有文件变化(不包括node_modules目录),除了vue文件单独处理之外,其他文件的更改都会通知浏览器刷新页面(触发full-reload),很明显目前的代码还是比较简单粗暴的。

我们再单独看下对vue的监听处理

if (file.endsWith('.vue')) {
  // check which part of the file changed
  const [descriptor, prevDescriptor] = await parseSFC(file)
  if (!prevDescriptor) {
    // the file has never been accessed yet
    return
  }

  // 监听script的代码更改
  if (
    (descriptor.script && descriptor.script.content) !==
    (prevDescriptor.script && prevDescriptor.script.content)
  ) {
    notify({ type: 'reload', path: resourcePath})
    return
  }

  // 监听template的代码更改
  if (
    (descriptor.template && descriptor.template.content) !==
    (prevDescriptor.template && prevDescriptor.template.content)
  ) {
    notify({ type: 'rerender', path: resourcePath})
    return
  }

  // TODO styles
} 

parseSFC解析方法我们在上面说过了,现在我们对比更新前后的descriptorprevDescriptor,发现是script部分的更改,那么触发reload;是template部分的更改,则触发rerenderstyle样式部分先不考虑。

至此,我们了解到目前,触发hmr热替换有三个规则:full-reloadreloadrerender

浏览器端是怎么通过webscoket接受这些消息规则的?上面我们暂时没讲的请求hmrClient,现在前往这个文件看下

const socket = new WebSocket(`ws://${location.host}`)

socket.addEventListener('message', ({ data }) => {
  const { type, path, index } = JSON.parse(data)
  switch (type) {
    case 'connected':
      break
    // 组件重新挂载
    case 'reload':
      import(`${path}?t=${Date.now()}`).then(m => {
        __VUE_HMR_RUNTIME__.reload(path, m.default)
      })
      break
    // 组件重新渲染
    case 'rerender':
      import(`${path}?type=template&t=${Date.now()}`).then(m => {
        __VUE_HMR_RUNTIME__.rerender(path, m.render)
      })
      break
    case 'update-style':
      import(`${path}?type=style&index=${index}&t=${Date.now()}`).then(m => {
        // TODO style hmr
      })
      break
    // 页面刷新
    case 'full-reload':
      location.reload()
  }
})

__VUE_HMR_RUNTIME__是Vue3在开发环境暴露出来提供给我们方便对组件重新渲染挂载的功能。

reload

例如我们更改了Comp.vue的script代码,那么浏览器端会重新请求Comp.vue

import(`/Comp.vue?t=${Date.now()}`)

接着调用__VUE_HMR_RUNTIME__.reload方法重新挂载Comp.vue组件

rerender

例如我们更改了Comp.vue的template代码,那么浏览器端只需拿到Comp.vue的新渲染函数

import(`/Comp.vue?type=template&t=${Date.now()}`)

接着调用__VUE_HMR_RUNTIME__.rerender方法重新渲染Comp.vue组件

full-reload

更改其他文件,直接location.reload()刷新页面(当然现在vite不可能直接这样做)

最后

vite最初版本的代码我们看完啦,那么我们对vite原理也算是了解了大概,如果需要更多地了解,我们可以切换到vite更高的版本分支,继续撸代码。我们对上面的内容进行简单回顾吧

文件说明
moduleRewriter.js改写js中import/export代码,例如import { createApp } from 'vue',让vite知道前往node_modules第三方包请求资源
moduleMiddleware.js寻找到第三包资源路径
parseSFC.js解析vue组件,并且缓存解析结果。方便组件更改前后比较
vueMiddleware.js编译vue组件,为组件添加渲染函数
hmrWatcher.js监听项目中文件变化,通过webSocket通知浏览器端
hmrClient.js接受更新通知,根据规则决定重新挂载组件、重新渲染组件或刷新页面

鹅是进击的小前端,如果文章有不对的地方,欢迎👏大家指出,最后希望对你有收获。