从零到一,带你彻底搞懂 vite 中的 HMR 原理(源码分析)

前言

距离尤大大在微博宣布「vite」的出现,不知不觉间已经过了 2 个月多。

当时,「vite」只是支持对 .vue 文件的即时编译和 importrewrite,相应地「Plugin」也没有几个。并且,最初在「GitHub」上「vite」的 slogan 是这样的:

—— No-bundle Dev Server for Vue 3 Single-File Components.

可以看到,起初介绍「vite」是一个不需要打包的开发阶段的服务器。但是,现在再回首,这句 slogan 已经消失了,而「vite」也已经处于 「beta」 阶段。并且,不仅仅是一个开发阶段的服务器这么简单。相应地也实现了很多「Feature」,例如:Web Assembly、JSX
、CSS Pre-processors、Dev Server Proxy 等等。

有兴趣了解这些「Feature」的同学,可以移步GitHub自行阅读

这两个月的时间,「vite」发展的劲头是非(xue)常(bu)猛(dong)的。并且,也出现了很多关于「vite」的文章,可以说是:“ 如雨后春笋般,络绎不绝 ”。

那么,作为一名「Vue」爱好者,我同样对「vite」充满了好奇。所以,回到本次文章,我会先浅析 webpack-dev-server的「HMR」,然后再循序渐进地讲解「vite」在「HMR」这个过程做了什么。

Webpack 的 HMR 过程

提及「HMR」,不可避免地是会想起现在我们家喻户晓webpack-dev-server 中的「HMR」。所以,我们先来了解一番webpack-dev-server的「HMR」。

首先,我们先对「HMR」建立一个基础的认知。「HMR」 全称即 Hot Module Replacement。相比较「live load」,它具有以下优点:

  • 可以实现局部更新,避免多余的资源请求,提高开发效率
  • 在更新的时候可以保存应用原有状态
  • 在代码修改和页面更新方面,实现所见即所得

而在 webpack-dev-server 中实现「HMR」的核心就是 HotModuleReplacementPlugin ,它是「Webpack」内置的「Plugin」。在我们平常开发中,之所以改一个文件,例如 .vue 文件,会触发「HMR」,是因为在 vue-loader 中已经内置了使用 HotModuleReplacementPlugin 的逻辑。它看起来会是这样

  1. Helloworld.vue
<template>
  <div>hello world</div>
</template>
<script lang="ts">
  import { Vue, Component } from 'vue-property-decorator'
  @Component
  export default class Helloworld extends Vue() {}
</script>
  1. main.js(手动实现「HMR」效果)
import Vue from 'vue'
import HelloWorld from '_c/HelloWorld'

if (module.hot) {
  module.hot.accept('_c/HelloWorld', ()=>{
    // 拉取更新过的 HelloWorld.vue 文件
  })
}

new Vue({
  el: '#app',
  template: '<HelloWorld/>'
  component: { HelloWorld }
})

那么,这个就是 webpack-dev-server 实现「HMR」的本质吗?显然不是,上面说的只是,如果你要通过 webpack-dev-server 实现「HMR」,你可以这么写来实现。

如果究其底层实现,是有两个关键的点

1.与本地服务器建立「socket」连接,注册 hashok 两个事件,发生文件修改时,给客户端推送 hash 事件。客户端根据 hash 事件中返回的参数来拉取更新后的文件。

2.HotModuleReplacementPlugin 会在文件修改后,生成两个文件,用于被客户端拉取使用。例如:

hash.hot-update.json

{
	"c": {
		"chunkname": true
	},
	"h": "d69324ef62c3872485a2"
}

chunkname.d69324ef62c3872485a2.hot-update.js,这里的 chunkname 即上面 c 中对于 key

webpackHotUpdate("main",{
   "./src/test.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    eval(....)
  })
})

当然,在这之前还会涉及到对原模块代码的注入,让它具备拉取文件的能力。而这其中实现的细节就不去扣了,要不然有点喧兵夺主的感觉。

基于 native ES Module 的 devServer

基于 native ES Module 的 devServer 是「vite」实现「HMR」的重要一环。总体来说,它会做这么两件事:

  • 初始化本地服务器
  • 加载并执行对应的 Plugin,例如 sourceMapPluginmoduleRewritePluginhtmlRewritePlugin 等等。

所以,本质上,devServer 做的最重要的一件事就是加载执行 Plugin。目前,「vite」总共具备了 11Plugin

这里大致列举几点 Plugin 会做:

  • 拦截请求,处理「ES Module」语法相关的代码,转化为浏览器可识别的「ES Module」语法,例如第三方模块的 import 转化为 /@module/vue.js
  • .ts.vue 进行即时的编译以及 sassless 的预编译
  • 建立模块间的导入导出关系,即 importeeMap和客户端建立 socket 连接,用于实现「HMR」

这里就列举 devServer 几个常见的 Plugin 需要做的事,至于其他像 wasmPluginwebWorkerPlugin 之类的 Plugin 会做些什么,有兴趣的同学可以自行去了解。

然后,我们再从代码地角度看看它是怎么实现我们上述所说的:

1.首先,我们执行 vite 命令.实际上是运行 cli.js 这个文件,这里我摘取了其中核心的逻辑:

(async () => {
  const { help, h, mode, m, version, v } = argv
  ...
  const envMode = mode || m || defaultMode
  const options = await resolveOptions(envMode)
  // 开发环境下,我们会命中 runServer
  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)
  }
})()
async function runServe(options: UserConfig) {
  // 在 createServer() 的时候会对 HRM、serverConfig 之类的进行初始化
  const server = require('./server').createServer(options)
  ...
}

可以看到,在自执行函数中,我们会命中 runServer() 的逻辑,而它的核心是调用 server.js 文件中的 createServer()

createServer 方法:

export function createServer(config: ServerConfig): Server {
  const {
    ...,
    enableEsbuild = true
  } = config

  const app = new Koa<State, Context>()
  const server = resolveServer(config, app.callback())
  const watcher = chokidar.watch(root, {
    ignored: [/\bnode_modules\b/, /\b\.git\b/]
  }) as HMRWatcher
  const resolver = createResolver(root, resolvers, alias)

  const context: ServerPluginContext = {
    ...
    watcher
    ...
  }

  app.use((ctx, next) => {
    Object.assign(ctx, context)
    ctx.read = cachedRead.bind(null, ctx)
    return next()
  })

  const resolvedPlugins = [
    ...,
    moduleRewritePlugin,
    hmrPlugin,
    ...
  ]
  // 核心逻辑执行 hmrPlugin
  resolvedPlugins.forEach((m) => m && m(context))

  const listen = server.listen.bind(server)
  server.listen = (async (port: number, ...args: any[]) => {
    ...
    }) as any

  return server
}

createServer 方法做了这么几件事:

  • 创建一个 koa 实例
  • 创建监听除了 node_modules 之外的文件的 watcher,并传入 context
  • context 上下文传入并调用每一个 Plugin

到这里,「vite」的 devServer 的创建过程就已经完成。那么,接下来我们去领略一番属于「vite」的「HMR」过程!

vite 的 HMR 过程

在「vite」中「HMR」的实现是以 serverPluginHmr 这个 Plugin 为核心实现。这里我们以 .vue 文件的修改触发的「HMR」为例,这个过程会涉及三个 PluginserverPluginHtmlserverPluginHmrserverPluginVue,这个过程看起来会是这样:

serverPluginHtml

从前面的流程图可以看到,首先是 serverPluginHtml 这个 Plugin 向 index.html 中注入了获取 hmr 模块的代码:

export const htmlRewritePlugin: ServerPlugin = ({
  root,
  app,
  watcher,
  resolver,
  config
}) => {
  const devInjectionCode =
    `\n<script type="module">\n` +
    `import "${hmrClientPublicPath}"\n` +
    `window.process = { env: { NODE_ENV: ${JSON.stringify(
      config.mode || 'development'
    )} }}\n` +
    `</script>\n`

  const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
  const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/

  async function rewriteHtml(importer: string, html: string) {
    ...
    html = html!.replace(scriptRE, (matched, openTag, script) => {
      ...
      return injectScriptToHtml(html, devInjectionCode)
  }

  app.use(async (ctx, next) => {
    await next()
    ...

    if (ctx.response.is('html') && ctx.body) {
      const importer = ctx.path
      const html = await readBody(ctx.body)
      if (rewriteHtmlPluginCache.has(html)) {
        ...
      } else {
        if (!html) return
        // 在这里给 index.html 文件注入代码块
        ctx.body = await rewriteHtml(importer, html)
        rewriteHtmlPluginCache.set(html, ctx.body)
      }
      return
    }
  })

}

所以,当我们访问一个 「vite」 启动的项目的时候,我们会在「network」中看到服务器返回给我们的 index.html 中的代码会多了这么一段代码:

<script type="module">
import "/vite/hmr"
window.process = { env: { NODE_ENV: "development" }}
</script>

而这一段代码,也是确保我们后续正常触发「HMR」的关键点。因为,在这里浏览器会向服务器发送请求获取 vite/hmr 模块,然后,在 serverPluginHmr 中会拦截 ctx.path===‘/vite/hmr’ 的请求,建立 socket 连接。那么,接下来我们看看 serverPluginHmr 是进行这些过程的。

serverPluginHmr

上面我们说了 serverPluginHmr 它会劫持导入 /vite/hmr 的请求,然后返回 client.js 文件。所以,我们分点来细致化地分析这个过程:

1.读取 cliten.js 文件,劫持导入 /vite/hmr 的请求

 export const hmrClientFilePath = path.resolve(
   __dirname,
   '../../client/client.js'
 )
 export const hmrClientPublicPath = `/vite/hmr`
 const hmrClient = fs
    .readFileSync(hmrClientFilePath, 'utf-8')
    .replace(`__SW_ENABLED__`, String(!!config.serviceWorker))

  app.use(async (ctx, next) => {
    if (ctx.path === hmrClientPublicPath) {
      ctx.type = 'js'
      ctx.status = 200
      ctx.body = hmrClient.replace(`__PORT__`, ctx.port.toString())
    } else {
      ...
    }
  })

这里通过 readFileSync() 读取 client.js 文件,然后分别 replace 读取到的文件内容(字符串),一个是用于判断是否支持 serviceWorker,另一个用于建立 socket 连接时的端口设置。

2.定义 send 方法,并赋值给 watcher.send,用于其他 Plugin 在热更新时向浏览器推送更新信息:

const send = (watcher.send = (payload: HMRPayload) => {
    const stringified = JSON.stringify(payload, null, 2)
    debugHmr(`update: ${stringified}`)

    wss.clients.forEach((client) => {
      // OPEN 表示已经建立连接
      if (client.readyState === WebSocket.OPEN) {
        client.send(stringified)
      }
    })
  })

3.client.js,它会做这两件事:

  • 建立和服务器的 socket 连接,监听 message 事件,拿到服务器推送的 data,例如我们只修改 .vue 文件,它的 data 类型定义会是这样:
export interface UpdatePayload {
  type: 'js-update' | 'vue-reload' | 'vue-rerender' | 'style-update'
  path: string
  changeSrcPath: string
  timestamp: number
}
  • 对不同的 data.type 执行不同的逻辑,目前存在 type 有:vue-reloadvue-rerenderstyle-updatestyle-removejs-updatecustomfull-reload。本次我们只分析 vue-rerender 的逻辑,其实现的核心代码如下:
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'
const socketUrl = `${socketProtocol}://${location.hostname}:${__PORT__}`
const socket = new WebSocket(socketUrl, 'vite-hmr')
// 监听 message 事件,拿到服务端推送的 data
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 '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
    ...
  }
}

其实,这个过程还用到了 serviceWorker 做一些处理,但是看了下提交记录是 wip,所以这里就没有分析这个逻辑,有兴趣的同学可以自己了解。

serverPluginVue

前面,我们讲了 serverPluginHtmlserverPluginHmr 在 HMR 过程会做的一些前期准备。然后,我们这次分析的修改 .vue 文件触发的「HMR」逻辑,它的开始是在
serverPluginVue

首先,它会解析 .vue 文件,做一些 compiler 处理,然后通过 watcher 监听 .vue 文件的修改:

watcher.on('change', (file) => {
  if (file.endsWith('.vue')) {
    handleVueReload(file)
  }
})

可以看到在 change 事件的回调中调用了 handleVueReload(),针对我们这个 case,它会是这样:

const handleVueReload = (watcher.handleVueReload = async (
    filePath: string,
    timestamp: number = Date.now(),
    content?: string
  ) => {
    const publicPath = resolver.fileToRequest(filePath)
    const cacheEntry = vueCache.get(filePath)
    const { send } = watcher
    ...
    let needRerender = false
    ...
    if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
      needRerender = true
    }
    ...
    if (needRerender) {
      send({
        type: 'vue-rerender',
        path: publicPath,
        changeSrcPath: publicPath,
        timestamp
      })
    }
  })

handleVueReload() 它会针对 .vue 文件中,不同情况走不同的逻辑。这里,我们只是修改了 .vue 文件,给它加了一行代码,那么此时就会命中 isEqualBlock()false 的逻辑,所以 needRerendertrue,最终通过 send() 方法向浏览器推送 typevue-rerender 以及携带修改的文件路径的信息。然后,我们前面 client.js 中监听 message 的地方就会拿到对应的 data,再通过 import 发起获取该模块的请求。

小结

到这里,整个「vite」实现「HMR」的逻辑已经分析结束了。当然,这次只是针对 .vue 文件的修改来分析整个 HMR 的逻辑,相应地还有 .js.css 的文件的修改触发的 HMR 的逻辑,但是,可以说的是只要理解这个过程是如何进行的,那么每一个 case 的分析,也只是依葫芦画瓢

并且,其实在「HMR」的过程中还有一些辅助变量和概念,例如 hrmBoundariesimport chainchild importer 等等,它们都是用于帮助更好地进行 HMR 处理。所以,这里我就没有提及这些。有兴趣的同学可以自己去看源码中的讲解这些辅助变量和概念的意义

写在最后

其实,在当初尤大大发微博的时候,我就想着写一篇关于「vite」源码分析的文章。这两个月,我也经历一些起起伏伏,所以,一直到现在才开始交上这份答卷。但是,现在再看「vite」源码,已经不是仅仅 devServer 这么简单了,所以这次只分析了「HMR」这个点的实现,其他方面后续有时间应该会继续写其他方面,例如 rewritebundlecompiler 等等。最后,文章中可能会存在表述不对或不好的地方,欢迎各位同学提 Issue。

写作不易,如果你觉得有收获的话,可以帅气三连击!!!

  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: 在 Vite ,你可以通过使用插件来实现 CSS 文件的单独输出。一个常用的插件是 vite-plugin-extract-css,你可以使用如下步骤来实现: 1. 安装插件: ``` npm install --save-dev vite-plugin-extract-css ``` 2. 在 vite.config.js 配置插件: ```javascript import extractCss from 'vite-plugin-extract-css' export default { plugins: [ extractCss({ filename: 'css/[name].css' }) ] } ``` 3. 在你的 Vue 组件使用 CSS: ```javascript <style> /* ... */ </style> ``` 4. 运行你的 Vite 项目: ``` npm run dev ``` 以上就是 Vite 将 CSS 单独输出到一个文件的方法。 ### 回答2: 在 Vite ,可以通过 `rollup-plugin-css-only` 插件将 CSS 单独输出到一个文件。 首先,需要在项目的根目录下安装 `rollup-plugin-css-only`: ``` npm install --save-dev rollup-plugin-css-only ``` 安装完成后,在 `vite.config.js` 配置文件添加以下代码: ```javascript import css from 'rollup-plugin-css-only'; export default { // ...其他配置 build: { rollupOptions: { plugins: [ css({ output: 'dist/style.css' }) ] } } } ``` 上述代码,`css({ output: 'dist/style.css' })` 指定了 CSS 输出的文件路径为 `dist/style.css`,你可以自定义路径和文件名。 然后,启动 Vite 开发服务器,Vite 将会将 CSS 单独输出到指定的文件。 需要注意的是,此插件仅适用于纯 CSS 文件,如果你的代码还包含了 SCSS/LESS/Stylus 等预处理器语法,Vite 默认会使用 PostCSS 进行处理,并将其嵌入到 JavaScript 文件,而不是单独输出。如果你希望将预处理器转换为纯 CSS 文件并单独输出,可能需要自行配置相关的 Rollup 插件,并修改 Vite 的配置文件。 ### 回答3: 在Vite,可以通过以下步骤将CSS文件单独输出到一个文件。 1. 在项目的根目录创建一个新的CSS文件,例如style.css,并将所有的CSS代码放在这个文件。 2. 打开项目的入口文件(通常是main.js或app.js)。 3. 使用导入语句将CSS文件导入到入口文件,例如`import './style.css'`。 4. 在vite.config.js配置文件,添加一个新的插件或修改现有插件来将CSS文件单独输出。 - 如果已经有`vite-plugin-xxx`类似的插件配置(例如vite-plugin-vue),找到对应的插件配置项,并将其配置为将CSS文件导出。 - 如果没有现有插件配置,可以使用`vite-plugin-css-modules`或`vite-plugin-style-modules`等插件将CSS文件单独输出。在vite.config.js文件添加相应的插件配置。 5. 重新启动Vite开发服务器。 经过上述步骤,Vite将会将CSS代码从入口文件提取出来,并单独输出到一个CSS文件。此外,Vite还会自动处理CSS模块化,确保CSS样式不会对其他模块造成污染。 希望以上解答能对您有所帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值