Vite: 如何实现热更新

14,186 阅读3分钟

前言

之前的一篇文章说了 Vite: 如何不使用 webpack 开发应用,按照约定,接下来要说一下热更新的问题,Vite自己通过WebSocket实现了浏览器与服务器之间的通信,从而达成热更新的目的。

但首先我们需要知道热更新是什么意思,它跟直接刷新页面更新有什么不同的地方,实现的方式又有什么不同?下面的内容主要参考 前端工程化精讲

Live Reload

我们通常为了在本地调试应用,基本使用webpack-dev-server, 而他的主要作用就是启动一个本地服务,主要的代码配置如下图所示:

// webpack.config.js
module.exports = {
   //...
   devServer: {
    contentBase: './dist', //为./dist目录中的静态页面文件提供本地服务渲染
    open: true          //启动服务后自动打开浏览器网页
  }
};

// package.json
"scripts": {
  "dev:reload": "webpack-dev-server"
}

执行 dev:reload 之后,会启动一个服务器然后在代码变更的时候会自动刷新浏览器进行重新加载。

而他的原理其实就是通过WebSocket建立网页和本地服务间建立持久化的通信。当代码改变时,会向页面发送消息,页面接收消息后会刷新界面进行更新。

而这样的模式下,存在着一定的缺陷,比如说我们在一个弹框中进行调试,当我每次修改代码自动刷新后,弹框会直接消失。因为浏览器直接刷新造成了状态的丢失,给调试带来了些许的不方便。尤其是相对复杂的操作之后需要调试的情况下,更是苦不堪言。

Hot Module Replacement

Hot Module Replacement 表示模块热替换也就是大家所说的热更新,为了解决上面说的页面刷新导致的状态丢失问题,webpack 提出了模块热替换的概念。在 webpack-dev-server 中,有一个配置hot,如果设置为true, 则会直接自动添加 webpack.HotModuleReplacementPlugin,下面我们创建一个简单地例子来切实看下热更新的具体体现:

// src/index.js
import './index.css'

// src/index.css
div {
  background:  red;
}

// dist/index.html
...
 <script src="./main.js"></script>
...

// webpack.config.js
const path = require("path");

module.exports = {
  entry: "./src/index.js",
  devServer: {
    contentBase: "./dist",
    open: true,
    hot: true // 开启热更新
  },
  output: {
    filename: "main.js",
    path: path.resolve(__dirname, "dist"),
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
};

// package.json
"scripts": {
  "dev:reload": "webpack-dev-server"
}

这里新增 module 的配置,使用 style-loader 和 css-loader 来解析导入的 CSS 文件。其中 css-loader 处理的是将导入的 CSS 文件转化为模块供后续 Loader 处理;而 style-loader 则是负责将 CSS 模块的内容在运行时添加到页面的 style 标签中。运行之后可以看到:

而这时候修改 css 的代码,再回到浏览器,就会发现网络面板中心新建了两个请求:

然而,如果修改 JS 文件内容,依旧会出现刷新的情况, 至于原因接下来说,首先看下经过style-loader 处理后的文件会变成如下的代码:

var api = __webpack_require__(/*! ../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js */ "./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var content = __webpack_require__(/*! !../node_modules/css-loader/dist/cjs.js!./index.css */ "./node_modules/css-loader/dist/cjs.js!./src/index.css");

content = content.__esModule ? content.default : content;

if (typeof content === 'string') {
  content = [[module.i, content, '']];
}

var options = {};

options.insert = "head";
options.singleton = false;

var update = api(content, options);

var oldLocals = content.locals;

    module.hot.accept(
       // 依赖模块
       "./node_modules/css-loader/dist/cjs.js!./src/index.css",
       // 回调方法
       function() {
        ...
        // 修改 style 标签里的内容
        update(content);
      }
    )
  }

  module.hot.dispose(function() {
    // 移除 style 标签
    update();
  });
}

module.exports = content.locals || {};

模块热替换插件

上面的 module.hot 实际上是一个来自 webpack 的基础插件 HotModuleReplacementPlugin,该插件作为热替换功能的基础插件,其 API 方法导出到了 module.hot 的属性中。

  • hot.accept:当依赖模块发生改变时则执行插入的回调方法,上文中就是更新 style 标签的内容
  • hot.dispose:当代码上下文的模块被移除时,其回调方法就会被执行。上文中就是移除 style 标签

所以因为没有为 js 添加此插件的代码,代码的解析也没有配置额外能对特定代码调用热替换 API 的 Loader,所以在 js 修改后则会直接刷新界面,如果需要对 js 做热替换的处理,加入以下类似的代码即可:

./text.js
export const text = 'Hello World'
./index.js
import {text} from './text.js'

const div = document.createElement('div')
document.body.appendChild(div)
function render() {
  div.innerHTML = text;
}
render()
if (module.hot) {
  // 再 text.js 代码更新后不会重新刷新页面
  module.hot.accept('./text.js', function() {
    render()
  })
}

Vite 热更新实现

通过上面的了解,大概知道了热更新的逻辑与原理。Vite实现热更新的方式也大同小异,主要是通过创建WebSocket建立浏览器与服务器建立通信,通过监听文件的改变像客户端发出消息,客户端对应不同的文件进行不同的操作的更新

以下部分内容参照 Vite 原理浅析

服务端

代码转到server/serverPluginHmr.ts以及server/serverPluginVue.ts

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

handleVueReload

async function handleVueReload(
    file: string,
    timestamp: number = Date.now(),
    content?: string
) {
  ...
  const cacheEntry = vueCache.get(file) // 获取缓存里的内容
  // @vue/compiler-sfc 编译 Vue 文件
  const descriptor = await parseSFC(root, file, content)
  const prevDescriptor = cacheEntry && cacheEntry.descriptor // 获取前一次的缓存
  if (!prevDescriptor) {
    // 这个文件之前从未被访问过(本次是第一次访问),也就没必要热更新
    return
  }
  // 判断是否需要 rerender 
  let needRerender = false
  // 向客户端发出 reload 消息的方法
  const sendReload = () => {
    send({
      type: 'vue-reload',
      path: publicPath,
      changeSrcPath: publicPath,
      timestamp
    })
  }
  // script 不同则直接 reload
  if (
    !isEqualBlock(descriptor.script, prevDescriptor.script) ||
    !isEqualBlock(descriptor.scriptSetup, prevDescriptor.scriptSetup)
  ) {
    return sendReload()
  }
  // 如果 template 部分不同则需要 rerender
  if (!isEqual(descriptor.template, prevDescriptor.template)) {
    needRerender = true
  }
  
  // 获取之前的 style 以及下一次(或者说热更新)的 style
  const prevStyles = prevDescriptor.styles || []
  const nextStyles = descriptor.styles || []
  // css module | vars injection | scopes 改变则直接 reload
  if (
    prevStyles.some((s) => s.module != null) ||
    nextStyles.some((s) => s.module != null)
  ) {
    return sendReload()
  }
  if (
    prevStyles.some((s, i) => {
      const next = nextStyles[i]
      if (s.attrs.vars && (!next || next.attrs.vars !== s.attrs.vars)) {
        return true
      }
    })
  ) {
    return sendReload()
  }
  if (prevStyles.some((s) => s.scoped) !== nextStyles.some((s) => s.scoped)) {
    return sendReload()
  }
  
  // 如果以上情况都不是,则 style 改变时 rerender
  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-remove` 的通知
  prevStyles.slice(nextStyles.length).forEach((_, i) => {
    didUpdateStyle = true
    send({
      type: 'style-remove',
      path: publicPath,
      id: `${styleId}-${i + nextStyles.length}`
    })
  })
  // 如果需要 reredner 则发送 vue-rerender
  if (needRerender) {
    send({
      type: 'vue-rerender',
      path: publicPath,
      changeSrcPath: publicPath,
      timestamp
    })
  }
}

handleJSReload

关于 js 文件的重载,则直接递归调用walkImportChain去查找是谁引用了它(importer),如果一直找不到引用者则 hasDeadEnd 返回 true;

  const hmrBoundaries = new Set<string>() // 引用者如果是 vue 文件则存放在这
  const dirtyFiles = new Set<string>() // 引用者如果是 js 文件则存放在这
  
  const hasDeadEnd = walkImportChain(
    publicPath,
    importers || new Set(),
    hmrBoundaries,
    dirtyFiles
  )

如果 hasDeadEndtrue,则直接发送 full-reload。如果查找到需要热更新的文件,则发起热更新通知:

if (hasDeadEnd) {
  send({
    type: 'full-reload',
    path: publicPath
  })
} else {
  const boundaries = [...hmrBoundaries]
  const file =
    boundaries.length === 1 ? boundaries[0] : `${boundaries.length} files`
  send({
    type: 'multi',
    updates: boundaries.map((boundary) => {
      return {
        type: boundary.endsWith('vue') ? 'vue-reload' : 'js-update',
        path: boundary,
        changeSrcPath: publicPath,
        timestamp
      }
    })
  })
}

客户端

上面讲到服务端监听到变更后会向客户端发布消息,代码转到 src/client/client.ts, 这里主要就是创建WebSocket客户端,然后监听服务端传过来的消息进行更新操作

主要坚挺的消息以及对应的措施主要包括:

  • connected: WebSocket 连接成功
  • vue-reload: Vue 组件重新加载(当你修改了 script 里的内容时)
  • vue-rerender: Vue 组件重新渲染(当你修改了 template 里的内容时)
  • style-update: 样式更新
  • style-remove: 样式移除
  • js-update: js 文件更新
  • full-reload: fallback 机制,网页重刷新

这里的更新主要是通过timestamp刷新重新请求获取更新后的内容,vue 文件再通过HMRRuntime实现更新

import { HMRRuntime } from 'vue'
declare var __VUE_HMR_RUNTIME__: HMRRuntime

const socket = new WebSocket(socketUrl, 'vite-hmr')
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 'connected':
      console.log(`[vite] connected.`)
      break
    case 'vue-reload':
      // 加入到更新队列,之后一起更新
      queueUpdate(
        // 重新请求
        import(`${path}?t=${timestamp}`)
          .catch((err) => warnFailedFetch(err, path))
          .then((m) => () => {
            // 调用 HMRRUNTIME 的方法更新
            __VUE_HMR_RUNTIME__.reload(path, m.default)
            console.log(`[vite] ${path} reloaded.`)
          })
      )
      break
    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
    case 'style-update':
      // check if this is referenced in html via <link>
      const el = document.querySelector(`link[href*='${path}']`)
      if (el) {
        el.setAttribute(
          'href',
          `${path}${path.includes('?') ? '&' : '?'}t=${timestamp}`
        )
        break
      }
      // imported CSS
      const importQuery = path.includes('?') ? '&import' : '?import'
      await import(`${path}${importQuery}&t=${timestamp}`)
      console.log(`[vite] ${path} updated.`)
      break
    case 'style-remove':
      removeStyle(payload.id)
      break
    case 'js-update':
      queueUpdate(updateModule(path, changeSrcPath, timestamp))
      break
    case 'full-reload':
      // 直接刷新页面
      if (path.endsWith('.html')) {
        // if html file is edited, only reload the page if the browser is
        // currently on that page.
        const pagePath = location.pathname
        if (
          pagePath === path ||
          (pagePath.endsWith('/') && pagePath + 'index.html' === path)
        ) {
          location.reload()
        }
        return
      } else {
        location.reload()
      }
  }
}

这里有个细节的东西说下,当我们请求 type=css或者css文件的模块时,顶部会有一个特殊的 import 请求:

import { updateStyle } from "/vite/client" // <--- 这里

const css = "\nimg {\n    height: 100px;\n}\n"
// updateStyle 方法用来更新 css 内容,所以对于 css 文件只需要重新请求就可以更新
updateStyle("7ac74a55-0", css)
export default css

url/vite/clientserverPluginClient 进行了静态文件的配置,返回的时候 client/client.js的内容,这也是大家常说的客户端代码注入

export const clientFilePath = path.resolve(__dirname, '../../client/client.js')

export const clientPlugin: ServerPlugin = ({ app, config }) => {
  const clientCode = fs
    .readFileSync(clientFilePath, 'utf-8')
    ...
  app.use(async (ctx, next) => {
    if (ctx.path === clientPublicPath) {
      ctx.type = 'js'
      ctx.status = 200
      // 返回 client.js 下的内容
      ctx.body = clientCode.replace(`__PORT__`, ctx.port.toString())
    }
    ...
  })
}