四百多行代码实现一个toy vite

70 阅读8分钟

vite的基本原理

vite 依靠浏览器对es6module的支持,当浏览器解析JavaScript文件的时候,遇到import语句的时候,会构造http请求向服务器文件,例如

import fs from 'fs'
import hello from './uitls.js'
import home from './home.vue'
import 'hello.css'

对于上面的三句import语句,浏览器会构造3个http请求,向服务器请求文件,因此vite的总体思路是,在本地启动一个server,通过解析浏览器的请求url,来解析不同的文件返回给浏览器。

我们现在来写一些基本代码来加深对vite的理解。

新建Vue项目

添加vue项目的的模版,具体情况如下所示。

WechatIMG19.png 具体代码可以点击链接复制即可 打开index.html我们可以看到这样子一行代码<script type="module" src="/src/main.js"></script>type属性启用es6 module的支持,src属性则用来请求项目的入口文件,然后我们在App.vue文件中我们看到了import HelloWorld from './components/HelloWorld.vue'这又请求HelloWorld.vue文件,因此我们必须在本地启动一个server拦截请求镜像处理。

使用koa处理浏览器的请求

对于本地server我们选择使用koa,通过npm安装koa之后,我们在项目的根目录下载新建一个index.js文件来写一些代码让koa跑起来,处理来自浏览器的请求。

import koa from 'koa'

const app = new koa()
app.use((ctx, next) => {
  console.info(ctx.path)
})

app.listen(3000)

运行koa之后,如果我们打开浏览器输入localhost:3000,我们会在控制台看到二个输出。

/
/favicon.icon

对于/favicon.icon我们不管,对于/的请求,我们把它重定向为index.html,然后使用koa-static读取本地文件返回给浏览器。

import koa from 'koa'
import staticSever from 'koa-static'

const root = process.cwd() // 项目运行的文件路径
const app = new koa()

app.use(async (ctx, next) => {
  if (ctx.path === '/') {
    ctx.redirect('/index.html')
    return
  }
  await next()
})

app.use(staticSever())

app.listen(3000)

在浏览器输入localhost:3000,得到以下结果。

WechatIMG21.png

可以看到我们已经成功的把'/'重定向为'/index.html',并且也成功的读取index.html,但是控制台却报了如下错误。

WechatIMG20.png

这是因为main.js文件中import {createApp} from 'vue'的导入路径不合法,导入路径应该为'/','./','..'开头的。但是在开发的过程中我们导入node_modules依赖的时候为了跟本地的文件区别都是会与字母或者@开头的,因此我们必须解决这个问题。

解决方案也不难,我们可以使用koa的ctx.path看是否为.js结尾的js文件请求,然后使@babel/parser这个包来编译js文件,然后是查看是否有导入/node_modules的情况,如果有的话把路径前面添加上/@modules/即可。根据以上思路我们很快就能写出以下代码。

app.use(async (ctx, next) => {
  if (ctx.path === '/') {
    ctx.redirect('/index.html')
    return
  }
  await next()
}).use(moduleRewrite)
async function moduleRewrite(ctx, next) {
  if (!ctx.path.endsWith('.js')) {
    return next()
  }

  const path = root + ctx.path
  let content
  try {
    content = (await fs.readFile(path, "utf-8")).toString()
  } catch (error) {
    ctx.status = 404
  } 
  ctx.type = 'js'
  ctx.body = rewrite(content)
}

function rewrite(source) {
  const ast = parse(source, {
    plugins: [
      'bigInt',
      'optionalChaining',
      'nullishCoalescingOperator'
    ],
    sourceType: 'module'
  }).program.body

  let s = new MagicString(source)
  ast.forEach((node) => {
    if (node.type === 'ImportDeclaration') {
      if (/^[^\.\/]/.test(node.source.value)) {
        s.overwrite(
          node.source.start,
          node.source.end,
          `"/@modules/${node.source.value}"`
        )
      }
    }
  })
  return s.toString()
}

可以看到我们成功解决了第一条import路径的问题之后,后面二条import语句也发出去请求了。但是此时浏览器还是浏览器仍然没有vue文件,因此我们被在本地node_modules正确读取vue文件返回给浏览器。

对于这种node_modules依赖的请求,因为前面同一添加了'/@modules/'因此很容易同其他请求区别开来,所以我们重点考虑如果拼接出库文件在本地正确的路径,我们想想库文件一定在(项目地址 + node_modules + 库文件名称)的目录下 项目地址我们可以使用process.cwd()来得到,而node_modules是固定不变的目录名称,现在我们不清楚的就是库名称了,其实我们在用babel/parser解析的时候我们已经知道了库的名称了,因此我们只好缓存一下即可。

我们拼接出正确的路径之后,我们还要考虑可能出现2种情况。

  1. 本地依赖还没有安装。
  2. 本地依赖安装但是却没有打包成es6 module形式。

对于第一种情况,我们使用resolve-from这个包来解决,这个包在给定的路径库文件不存在的情况下会报错,第二种情况解决方法是我们在确定库文件存在的情况下,加载package.json文件如果package.json有用module或者main字段指定入口文件的话,说明支持es6 modules反正就是不支持。以此我们可以写成以下代码。

const moduleIdCache = new Map()

app.use(async (ctx, next) => {
  if (ctx.path === '/') {
    ctx.redirect('/index.html')
    return
  }
  await next()
}).use(moduleRewrite)
  .use(nodeModuleResolve)

async function nodeModuleResolve(ctx, next) {
  if (!ctx.path.startsWith('/@modules')) {
    return next()
  }

  // vue是特殊情况
  if (ctx.path.endsWith('vue')) {
    let vuePath
    let vueSource
    try {
      const userVuePkg = resolve(root, 'vue/package.json')
      vuePath = path.join(
        path.dirname(userVuePkg),
        'dist/vue.runtime.esm-browser.js'
      )
      vueSource = (await fs.readFile(vuePath, 'utf-8')).toString()
    } catch (error) {
      ctx.status = 404
      return
    } 
    ctx.type = 'js'
    ctx.body = vueSource
    return
  }

  // 其他node_modules依赖
  let modulePath
  let source
  const id = moduleIdCache.get(ctx.path)
  try {
    modulePath = resolve(cwd, `${id}/package.json`)
    // module resolved, try to locate its "module" entry
    const pkg = JSON.parse((await fs.readFile(modulePath, 'utf-8')).toString())
    modulePath = path.join(path.dirname(modulePath), pkg.module || pkg.main)
    source = (await fs.readFile(modulePath, 'utf-8')).toString()
  } catch (error) {
    ctx.status = 404
    return
  }
  ctx.type = 'js'
  ctx.body = source
}

function rewrite(source) {
 // 其他代码如上
   ast.forEach((node) => {
    if (node.type === 'ImportDeclaration') {
      if (/^[^\.\/]/.test(node.source.value)) {
        moduleIdCache.set('/@modules' + node.source.value, node.source.value)
        // 省略
        }
}

处理以.vue结尾的单文件组件

对于.vue结尾的单文件组件,我们使用vue官方提供的@vue/complier-sfc来编译,我们主要使用它的三个函数parse, compileTemplate, compileStyle。

使用parse编译之后会得到一个object,里面包含很多用的字段,我们只关心三个字段,分别是script,template,和style。看名字也知道分别对应组件的js代码,template模版,和style代码。如下图所示。

WechatIMG23.png

如果你自己有用parse去编译sfc,会发现我们3个问题需要进一步处理。

  1. script内包含的代码有可能import node_module依赖,至少它一定会导入vue,此时路径需要重写。
  2. template代码没有编译成render函数,仍然是原始的代码。
  3. styles里面css代码,如何下载到浏览器。

我们先来解决1和2,对于1我们刚才已经做过了,重写调用rewrite即可,问题2也简单调用comilpeTempalte即可。因此我们很快就能写出下面的代码。

app.use(async (ctx, next) => {
    // 代码省略
}).use(moduleRewrite)
  .use(nodeModuleResolve)
  .use(sfcComplie)
  
async function sfcComplie(ctx, next) {
  if (!ctx.path.endsWith('.vue')) {
    return next()
  }
  let sourcePath = root + ctx.path
  let content
  try {
    content = (await fs.readFile(sourcePath, 'utf-8')).toString()
  } catch (error) {
    ctx.status = 404
    return
  }
  const desc = sfcParse(content, {
    filename: sourcePath
  })

  let code = ''
  code += rewrite(desc.descriptor.script.content)
  code += compileTemplate({
    source: desc.descriptor.template.content,
    filename: sourcePath
  }).code
  ctx.type = 'js'
  ctx.body = code
}

我们先把main.js中的import "style.css"先删除一下,然后刷新浏览器此时我们得到如下结果。

WechatIMG24.png

现在我们能够直接返回给浏览器了吗?实际上还是不行的,这是因为export default defineComponentdefineComponent返回的对象缺失render函数(render函数是负责创建组件的虚拟dom的,有兴趣的可以找一些vue源码解析文章来看)。

解决办法也容易想到我们把export default defineComponent 改写成为let __script; export default (__script = , 然后再最后添加一句_script.render = reder即可。

修改一下rewrite,使其支持改写export default

function rewrite(source, asSFC = false) {
  // 其他代码如上

  let s = new MagicString(source)
  ast.forEach((node) => {
    if (node.type === 'ImportDeclaration') {
      if (/^[^\.\/]/.test(node.source.value)) {
        s.overwrite(
          node.source.start,
          node.source.end,
          `"/_modules/${node.source.value}"`
        )
      }
    } else if (asSFC && node.type == 'ExportDefaultDeclaration') {
      s.overwrite(
        node.start,
        node.declaration.start,
        `let __script; export default (__script = `
      )
      s.appendRight(node.end, `)`)
    }
  })
  return s.toString()
}

然后把scfComplier代码修改如下。

async function sfcComplier(ctx, next) {
  // 其他代码省略
  if (desc.descriptor.script) {
    code += rewrite(desc.descriptor.script.content, true)
  } else {
    // 组件没有script代码,导出一个空对象,后续看有没有render添加即可
    code += `const __script = {}; export default __script`
  }
  if (desc.descriptor.template) {
    code += compileTemplate({
      source: desc.descriptor.template.content,
      filename: sourcePath
    }).code

    code += `_script.render = render`
  }
  ctx.type = 'js'
  ctx.body = rewrite(code) // 去除template编译引入的
}

此时刷新浏览器,可以看到文件都请求到,但是控制台却报错了。

WechatIMG25.png 我们打开App.vue编译过的template部分的代码,就能够知道怎么回事了。

WechatIMG26.png

我们可以看到源文件中src属性以相对路径开头的都被编译成为了import模式,而import请求的js文件,但是我们返回却是svg文件因此报错了。

明白了怎么回事解决办法也是简单的,我们把svg文件转换为dataURI,然后拼接export default后面返回即可。

app.use(async (ctx, next) => {
    // 其他代码如上
}).use(moduleRewrite)
  .use(nodeModuleResolve)
  .use(sfcComplie)
  .use(convertSvgToDataUri)
  
async function convertSvgToDataUri(ctx, next) {
  if (!ctx.path.endsWith('.svg')) {
    return next()
  }

  const path = root + ctx.path
  let content
  try {
    content = (await fs.readFile(path, 'utf-8')).toString()
  } catch (error) {
    ctx.status = 404
    return
  }
  const dataUrl = svgToTinyDataUri(content)
  ctx.type = 'js'
  ctx.body = `
    let url = "${dataUrl}"
    export default url
  `
  return
}  

然后刷新浏览器即可

WechatIMG27.png 我们可以看到我们要的文件全部加载了,也没有报错,点击按钮交互也是正常了的,接下来我们的工作是加载css了。

对于css可以创建一个style标签,然后把编译过的css代码添加style标签内textContent即可。

async function sfcComplie(ctx, next) {
  // 其他代码同上
  let jsCode = ''
  
  if (desc.descriptor.script) {
    jsCode += rewrite(desc.descriptor.script.content, true)
  } else {
    // 组件没有script代码,导出一个空对象,后续看有没有render添加即可
    jsCode += `const __script = {}; export default __script`
  }
  if (desc.descriptor.template) {
    jsCode += compileTemplate({
      source: desc.descriptor.template.content,
      filename: sourcePath
    }).code

    jsCode += `;__script.render = render`
  }
  jsCode = rewrite(jsCode) // 去除template编译引入vue依赖路径问题
  if (desc.descriptor.styles) {
    desc.descriptor.styles.forEach((style, index) => {
      let id = hash(sourcePath)
      const { code, errors } = compileStyle({
        source: style.content,
        filename: sourcePath,
        id
      })

      if (errors.length) {
        ctx.status = 500
        return
      }

      let str = `
        const styleId = 'vue-style-${id}-${index}'
        const style = document.createElement('style')
        style.id = styleId
        document.head.appendChild(style)
        style.textContent = ${JSON.stringify(code)}
      `
      jsCode += str
    })
  }
  ctx.type = 'js'
  ctx.body = jsCode
}

刷新浏览器即可看到以下效果

WechatIMG28.png

可以看到组件内的css已经成功的加载了。

现在有一个需求,我们想支持css写在独立的文件内,然后通过import './styles.css'导入css。对于这个需求我们可以用上面的方法来实现。

在src文件夹中新建style.css文件,然后添加以下css代码。

:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;

  color-scheme: light dark;
  color: rgba(255, 255, 255, 0.87);
  background-color: #242424;

  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;
}

a {
  font-weight: 500;
  color: #646cff;
  text-decoration: inherit;
}
a:hover {
  color: #535bf2;
}

a {
  font-weight: 500;
  color: #646cff;
  text-decoration: inherit;
}
a:hover {
  color: #535bf2;
}

body {
  margin: 0;
  display: flex;
  place-items: center;
  min-width: 320px;
  min-height: 100vh;
}

h1 {
  font-size: 3.2em;
  line-height: 1.1;
}

button {
  border-radius: 8px;
  border: 1px solid transparent;
  padding: 0.6em 1.2em;
  font-size: 1em;
  font-weight: 500;
  font-family: inherit;
  background-color: #1a1a1a;
  cursor: pointer;
  transition: border-color 0.25s;
}
button:hover {
  border-color: #646cff;
}
button:focus,
button:focus-visible {
  outline: 4px auto -webkit-focus-ring-color;
}

.card {
  padding: 2em;
}

#app {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
  text-align: center;
}

@media (prefers-color-scheme: light) {
  :root {
    color: #213547;
    background-color: #ffffff;
  }
  a:hover {
    color: #747bff;
  }
  button {
    background-color: #f9f9f9;
  }
}

import 'styles.css'同样是js请求,因此我们必须返回js代码。经过以上分析我们很容易写出下面的代码。

app.use(async (ctx, next) => {
  // 省略其他代码  
  await next()
}).use(moduleRewrite)
  .use(nodeModuleResolve)
  .use(sfcComplie)
  .use(convertSvgToDataUri)
  .use(handleCss)

async function handleCss(ctx, next) {
  if (!ctx.path.endsWith('.css')) {
    return next()
  }

  const path = root + ctx.path

  let content
  try {
    content = (await fs.readFile(path, 'utf-8')).toString()
  } catch (error) {
    ctx.status = 404
    return
  }


  let str = `
  const styleId = 'vue-global-${hash(path)}'
  const style = document.createElement('style')
  style.id = styleId
  document.head.appendChild(style)
  style.textContent = ${JSON.stringify(content)}
`
  ctx.type = 'js'
  ctx.body = str
  return
}

写完之后我们刷新浏览器即可看到以下效果。

WechatIMG29.png

实现hmr

为了实现hmr,我们应该实现下面三个需求。

  1. 应该有某种方式,当本地的文件被修改的时候,我们能够知道。
  2. 知道本地文件被修改后,本地server应该以某种方式,把修改文件主动推送消息给浏览器。
  3. 浏览器接到通知之后,应该根据文件的信息重新请求文件。

对于需求1我们可以使用chokidar这个类库即可,需求2我们使用websocket技术。 至于需求3我们要好好的分析了,当然最简单的方法是,当我们接到通知的使用window.location.reload()来刷新,然后所有请求会重新发送,自然能够得到最新的文件了,这种方式对于稍微大一点的项目简直是灾难,因此我们必须寻求其他方式更新。

理想的情况下对于vue的sfc,template或者script的改变应该只有影响自己而不会影响其他部分,而script的改变才导致整个sfc的更新,但是sfc的script刷新,应该只有影响到自己,而不影响其他组件。幸运的是vue的HMRRuntime提供了rerender和reload方法,rerender方法可以把template更新的部分,重新渲染到页面,reload方法可以重新渲染组件,而至于style部分我们是自己加载的,因此我们也可以自行解决。

现在我们先来把整体的代码框架写出来。

我们先通过npm安装chokidar和ws。安装完成后我们打开index.js添加以下代码。

import { WebSocketServer } from 'ws'
//其他代码如上
const server = app.listen(3000)

const wss = new WebSocketServer({ server })
const connects = new Set()
wss.on('connection', (conn) => {
  connects.add(conn)
  conn.send(JSON.stringify({type: 'connected'}))
  console.info('socket connect')
  conn.on('close', () => {
    connects.delete(conn)
  })
})

wss.on('error', (e) => {
  if (e.code !== 'EADDRINUSE') {
    console.error(e)
  }
})

为了测试我们websocket是否有建立成功,我们打开index.html文件,添加如下代码。

    <-- 其他代码省略-->
    <script type="module">
      const ws = new WebSocket(`ws://${location.host}`)

      ws.addEventListener('message', ({ data }) => {
        const { type, path, id, index } = JSON.parse(data)
        switch (type) {
            case 'connected':
              console.info('[vite] connected')
              break
            }
      })
    </script>

刷新浏览器后,我们在控制台可以看到输出了[vite] connected可以证明我们浏览器可以正确接受来自server的消息

WechatIMG30.png

我们写检测本地文件改变的逻辑。往index.js添加如下代码。

function createFileWatcher(cwd, notify) {
  const fileWatch = chokidar.watch(cwd, {
    ignored: [/node_modules/]
  })
  fileWatch.on('change', async (file) => {
    const resourcePath = '/' + path.relative(cwd, file)
    const send = (payload) => {
      console.log(`[hmr] ${JSON.stringify(payload)}`)
      notify(payload)
    }

    if (file.endsWith('.vue')) {
      send({
        type: 'reload',
        path: resourcePath
      })
    } else {
      send({
        type: 'full-reload'
      })
    }
  })
}

createFileWatcher(
  root,
  (payload) => connects.forEach(conn.send(payload)) 
)

我们使用chokidar.watch观察项目目录除了node_modules下的一切文件的改变,一旦监听的change事件就调用已经建立连接的websocket,往浏览器主动发消息。

我们往index.html添加一些代码来测试一下效果。

<script type="module">
// 其他代码同上
ws.addEventListener('message', ({ data }) => {
        const { type, path, id, index } = JSON.parse(data)
        switch (type) {
            case 'connected':
              console.info('[vite] connected')
              break
            case 'reload': 
              console.info('sfc fille change path is', path)  
              break
            case 'full-reload':  
              console.info('wow! has to full-reload')
              break
        }
})
</script>

重启nodejs,并且刷新浏览器看一下。在本地分别在App.vue和main.js的末尾添加一些换行符,结果在控制台输入如下。

WechatIMG31.png

至此我们的框架已经搭建起来了,是时候考虑一下vue sfc如何更新了。

我们首先要解决的第一个问题是,sfc由三部分组成(script,template, style),我们要知道具体是哪一部分被改变。

对于这个问题我们解决方案是,编译sfc然后分别检查script,template,style是否相比于旧文件编译出来结果不同。前面处理.vue为结尾的js请求,我们也是编译vue文件,因此我们把这部分的逻辑抽取出来。

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

  if (errors) {
    throw Error('compile sfc error')
  }

  const prev = cache.get(filename)
  if (saveCache) {
    cache.set(filename, descriptor)
  }
  return [descriptor, prev]
}

同时我们要修改函数sfcComplie的逻辑,修改如下。

async function sfcComplie(ctx, next) {
  // 其他代码省略
  const [descriptor] = await parseSFC(root, ctx.path, true)
  if (!descriptor) {
    ctx.status = 404
    return
  }

  let jsCode = ''
  
  if (descriptor.script) {
    jsCode += rewrite(descriptor.script.content, true)
  } else {
    // 组件没有script代码,导出一个空对象,后续看有没有render添加即可
    jsCode += `const __script = {}; export default __script`
  }
}

注意此时要第调用parseSFC的时候三个参数设置为true,来缓存编译结果,以便后续hmr比较是否发生更新。

检查文件修改的逻辑如下。

async function createFileWatcher(cwd, notify) {
  const fileWatch = chokidar.watch(cwd, {
    ignored: [/node_modules/]
  })
  fileWatch.on('change', async (file) => {
    const resourcePath = '/' + path.relative(cwd, file)
    const send = (payload) => {
      console.log(`[hmr] ${JSON.stringify(payload)}`)
      notify(JSON.stringify(payload))
    }
    if (file.endsWith('.vue')) {
      const [descriptor, prevDescriptor] = await parseSFC(cwd, resourcePath)

      if (!descriptor || !prevDescriptor) {
        return
      }

      if (!isEqual(descriptor.script, prevDescriptor.script)) {
        console.info('[hmr] vue component script was chaned')
        send({
          type: 'reload',
          path: resourcePath
        })
        return
      }

      if (!isEqual(descriptor.template, prevDescriptor.template)) {
        console.info('[hmr] vue component template was chaned')
        send({
          type: 'rerender',
          path: resourcePath
        })
        return
      }
    } else {
      send({
        type: 'full-reload'
      })
    }
  })
}

function isEqual(a, b) {
  if (!a || !b) return false
  if (a.content !== b.content) return false
  const keysA = Object.keys(a.attrs)
  const keysB = Object.keys(b.attrs)
  if (keysA.length !== keysB.length) {
    return false
  }
  return keysA.every((key) => a.attrs[key] === b.attrs[key])
}

此时你可以在App.vue改写一些代码,看是控制台是否打印出定义的log。

现在我们要对complie重构,使其支持分别编译script,template和style。

async function sfcComplie(ctx, next) {
  if (!ctx.path.endsWith('.vue')) {
    return next()
  }

  const parsed = url.parse(ctx.url, true)
  const pathname = parsed.pathname
  const query = parsed.query
  const filename = path.join(root, pathname.slice(1))
  const [descriptor] = await parseSFC(filename, true)
  if (!descriptor) {
    ctx.status = 404
    return
  }
  // 首次请求
  if (!query.type) {
    return compileSFCMain(ctx, descriptor, pathname, query.t)
  }

  if (query.type === 'template') {
    return compileSFCTemplate(
      ctx, 
      descriptor.template, 
      filename,
      pathname
      )
  }

  if (query.type === 'style') {
    return compileSFCStyle(
      ctx,
      descriptor.styles[Number(query.index)],
      query.index,
      filename,
      pathname
    )
  }
}

compileSFCMain函数实现如下。

function compileSFCMain(
  ctx,
  descriptor,
  pathname,
  timestamp
) {
  timestamp = timestamp ? `&t=${timestamp}` : ``
  let code = ''
  if (descriptor.script) { // let __script; export default (__script = 
    code += rewrite(
      descriptor.script.content,
      true /* rewrite default export to `script` */
    )
  } else {
    code += `const __script = {}; export default __script`
  }

  if (descriptor.styles) {
    descriptor.styles.forEach((_, i) => {
      code += `\nimport ${JSON.stringify(
        pathname + `?type=style&index=${i}${timestamp}`
      )}`
    })
  }
  if (descriptor.template) {
    code += `\nimport { render as __render } from ${JSON.stringify(
      pathname + `?type=template${timestamp}`
    )}`
    code += `\n__script.render = __render`
  }

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

complieSFCstyle函数实现如下。

function compileSFCStyle(
  ctx,
  style,
  index,
  filename,
  pathname
) {
  const id = hash(pathname)
  const { code, errors } = compileStyle({
    source: style.content,
    filename,
    id: ''
  })

  if (errors.length) {
    ctx.status = 500
    return
  }
  sendJS(
    ctx,
    `
const id = "vue-style-${id}-${index}"
let style = document.getElementById(id)
if (!style) {
  style = document.createElement('style')
  style.id = id
  document.head.appendChild(style)
}
style.textContent = ${JSON.stringify(code)}
  `.trim()
  )
}

现在刷新一下浏览器,看到一下结果。

WechatIMG32.png 现在对每个sfc多出了2次请求,分别是请求template和css。

这样子处理之后我们实现sfc热更新就简单了,只要server检查sfc文件改变,然后把文件路径和对应是什么类型发生改变,发送到浏览器,浏览器根据路径拼接出请求路径,然后使用import请求资源即可,在对应的更新资源拿到后,调用__VUE_HMR_RUNTIME__(改对象只要下载了vue文件,浏览器的全局环境就会有该对象)对象下对应的方法更新即可。

我们先来实现template的热更新,打开index.html文件。

ws.addEventListener('message', ({ data }) => {
        const { type, path, id, index } = JSON.parse(data)
        switch (type) {
            case 'connected':
              console.info('[vite] connected')
              break
            case 'reload': 
               import(`${path}?t=${Date.now()}`).then((m) => {
                    __VUE_HMR_RUNTIME__.reload(path, m.default)
                    console.log(`[vite][hmr] ${path} reloaded.`)
                  })
              break
            case 'rerender':
              import(`${path}?type=template&t=${Date.now()}`).then((m) => {
                __VUE_HMR_RUNTIME__.rerender(path, m.render)
                console.log(`[vite][hmr] ${path} template updated.`)
              })
              break
            case 'full-reload':  
              window.location.reload()
              break
        }
      }, true)

此时如果你往App.vue的编辑template或者script代码,我们不用刷新浏览器就能够在看到了结果了。

接下来我们要解决sfc中css热更新的问题。

我们打开index.js文件,往creatFileWatcher补充如下检查css代码是否发生变化的代码。

async function createFileWatcher(root, notify) {
  // 省略其他代码
  if (file.endsWith('.vue')) {
     // 其他代码省略
      const prevStyles = prevDescriptor.styles || []
      const nextStyles = descriptor.styles || []
      nextStyles.forEach((_, i) => {
        if (!prevStyles[i] || !isEqual(prevStyles[i], nextStyles[i])) {
          send({
            type: 'style-update',
            path: resourcePath,
            index: i
          })
        }
      })
      prevStyles.slice(nextStyles.length).forEach((_, i) => {
        send({
          type: 'style-remove',
          path: resourcePath,
          id: `${hash_sum(resourcePath)}-${i + nextStyles.length}`
        })
      })
  }
}

循环新的数组以此取得新数组每一项跟旧的比较,如果发现新旧不相同要把新的更新上去。当新的循环完之后,我们用新的数组长度来slice旧数组,这种情况下有两种情况,1.旧数组比新数组长度短将会slice到一个空数组不会进入循环,2.新数组比旧的短,这种情况下意味着旧数组的style我们要删除掉。

然后我们打开index.html把浏览器接受到css更新后要进行的处理补充完整。

<script>
        switch (type) {
            switch (type) {
            case 'connected':
              console.info('[vite] connected')
              break
            case 'style-update':
              console.log(`[vite] ${path} style${index > 0 ? `#${index}` : ``} updated.`)
              import(`${path}?type=style&index=${index}&t=${Date.now()}`)
              break
            case 'style-remove':
              const style = document.getElementById(`vue-style-${id}`)
              if (style) {
                style.parentNode!.removeChild(style)
              }
            break
            case 'full-reload':  
              console.info('wow! has to full-reload')
              break
        }
     }
</script>

至此我们逻辑已经全部处理完成了,希望本文章对你学习深入学习vite源码有所帮助完整代码链接点击领取