为美好的世界献上 Hot Reload

标题来自:为美好的世界献上祝福

近期在使用一个无需构建即可运行的前端框架,runtime 一时爽,但是怎么样快速查看自己的变更是否有效却是一个很大的问题——

想象一下这样的场景:

阿库娅)(某个幸运 E 的低智商女神):『和真!我每次写完了都要刷新,刷新了还有各种 Error!』
和真(某个幸运 EX 的男人):『一口气写完再刷新好了。』
阿库娅:『和……真……!你怎么可以这么对待女神!』

于是,我们就只能开发自己的 Hot Reload 体系,来应付这种开发场景。毕竟,每次都要刷新对于「阿库娅」并不是很友好的,尤其是主流框架 + webpack Hot Reload 使用习惯了之后,对于这种情况会有很大的抵触情绪。

webpack 的 HMR

开始之前,我们先了解一下 webpack 的 HMR 是怎么实现的,由于本文不是解析 webpack 的 HMR,所以我们不会花太多的篇幅来介绍这一点,但是 webpack 的 HMR 具有一定的参考价值,如果想要了解详细的 webpack HMR 原理,可以读这一篇文章:https://zhuanlan.zhihu.com/p/30669007

HMR.jpg
在图中我们简单的可以看出以下几点核心思想:

  1. 监听文件变化
  2. 服务器与客户端通信
  3. 替换流程
  4. 降级操作

当然,由于 webpack 本身有个很成熟的模块思想和生态,因此整个架构设计会比我们实现的 HMR 复杂很多。在模块热替换中,是由 webpack 的全部流程出力来完成这一操作的,而并没有局限于 webpack-dev-serverwebpack 以及业务代码本身,实际上,起到更重要作用的是各类 loader,它们需要使用 HMR API 来实现 Hot Reload 的逻辑,决定什么时候注册模块、什么时候卸载模块;如何注册和卸载模块。而 webpack 本身更像是一个调用方的角色,不需要考虑具体的注册和反注册逻辑。

HMR 的核心组织

经过了上面的分析,我们基本上确认了一个思路,也就是分析 webpack HMR 得出的结论。但是由于我们只有 runtime,所以实现 Hot Reload 变成了一个下图的简单流程:

15552198769600.jpg

  1. Server 启动一个 HTTP 服务器,并且注册和启动 WebSocket 服务,用于届时与客户端通信
  2. 在启动 Static 服务器后返回页面前注入 HMR 的客户端代码,业务方无需关心 HMR 的具体实现和添加对应的支持代码
  3. 服务端监听磁盘文件的变更,将文件变更通过 WebSocket 发送给客户端
  4. 客户端收到文件变更消息后进行对应的模块处理
  5. (模块处理失败,降级为 Live Reload)

一步步开始实现 HMR

live reload?

在实现 HMR 之前,我们可以先实现一个简单的 Live Reload 来保证我们 1-3 步的实现没有异常。

const Koa = require('koa')
const WebSocket = require('ws')
const chokidar = require('chokidar')

const app = new Koa()
const fs = require('fs').promises
const wss = new WebSocket.Server({ port: 8000 })
const dir = './static'

const watcher = chokidar.watch('./static', {
  ignored: /node_modules|\.git|[\/\\]\./
})

wss.on('connection', (ws) => {
  watcher
    .on('add', path => console.log(`File ${path} added`))
    .on('change', path => console.log(`File ${path} has been changed`))
    .on('unlink', path => console.log(`File ${path} has been moved`))
    .on('all', async (event, path) => {
      // Simple Live Reload
      ws.send('reload')
    })

  ws.on('message', (message) => {
    console.log('received: %s', message)
  })
  ws.send('HMR Client is Ready')
})

const injectedData = `<script>{
  const socket = new WebSocket('ws://localhost:8000');
  socket.addEventListener('open', (event) => {
    socket.send('[HMR] is Ready')
    console.log('[HMR] Start')
  });
  socket.addEventListener('message', function (event) {
    // Simple Live Reload
    if (event.data === 'reload') window.location.reload()
  })};
</script>`

app.use(async (ctx, next) => {
  let file = ctx.path
  if (ctx.path.endsWith('/')) {
    file = ctx.path + 'index.html'
  }
  let body
  try {
    body = await fs.readFile(dir + file, {
      encoding: 'utf-8'
    })
  } catch(e) {
    ctx.status = 404
    return next()
  }

  if (file.endsWith('.html')) body = body.replace('<body>', `<body>${injectedData}`)
  if (file.endsWith('.css')) ctx.type = 'text/css'
  ctx.body = body
  next()
})

app.listen(3001)

console.log('listen on port 3001')

上述代码中,简单的使用了 chokidar 这个文件监听库,它极大的减轻了我们的工作量;而 WebSocket 和服务器的实现上暂不赘述,之所以不直接使用 koa-static 的原因是因为我们需要对于 HTML 文件进行一些注入操作,以上 Live Reload 的实现非常简单,基本可以总结为一句话:得知文件变化后向客户端发送 reload 消息,客户端收到消息执行页面刷新操作

实现了一个 Live Reload 之后,接下来我们只需要变更注入的代码发送到客户端的消息两个部分即可,其实 Hot Reload 和 Live Reload 最大的区别也就是「最小模块替换」与「刷新页面」的区别,因此其他部分都是不用变动的。

替换 HTML 和 CSS 则是其中最简单的两项任务。

HTML

通常来说,我们要覆盖 HTML 中的内容,除了刷新这一操作外,还有一个就是 document.write(),实际上我们也是通过这个函数来实现 HTML 的 Hot Reload 的:

// 监听
    .on('all', async (event, path) => {
      if (path.endsWith('.html')) {
        body = await fs.readFile(path, {
          encoding: 'utf-8'
        })
        const message = JSON.stringify({ type: 'html', content: body })
        ws.send(message)
      }
    })
// 注入
let data = {}
try {
  data = JSON.parse(event.data)
} catch (e) {
  // return
}
console.log(data)
if (data.type === 'html') {
  document.write(data.content);
  document.close();
  console.log('[HMR] updated HTML');
}

那么读者最大的困惑可能变成了:精度怎么粗糙的热更新,好像跟直接刷页面并没有什么区别?

如果我们要进行精度更高的热更新,那么带来的性能差异其实是巨大的,我们来考虑一下如果我们希望尽可能细粒度的热更新操作,接下来需要哪些操作:

  1. 读取文件
  2. 构造语法树
  3. 对比和之前的语法树的差异
  4. 通信将差异传给客户端
  5. 将差异转换为对应的 DOM 操作

那样不可避免的,我们就要在内存中缓存每个页面最初的语法树,对于模块化的组件来说,HTML 本身的变更其实是并不太多的,没有必要进行这么复杂的操作(其实是截稿日期快到了没有办法玩啦)。

CSS

CSS 也比较简单,只要移除旧的 CSS 文件重新引入就能更新 CSS 了,这次,我们的代码将会更加精简。

// 监听
if (path.endsWith('.css')) {
  const message = JSON.stringify({ type: 'css', content: path.split('static/')[1] })
  ws.send(message)
}
// 注入
if (data.type === 'css') {
  const host = location.host
  document.querySelectorAll('link[rel="stylesheet"]').forEach(el => {
    const resource = el.href.split(host + '/')[1]
    console.log(resource)
    if (resource === data.content) el.remove()
  })
  document.head.insertAdjacentHTML('beforeend', '<link rel="stylesheet" href="' + data.content + '" />')
  console.log('[HMR] updated CSS');
}

相比 HTML 来说,CSS 显得更加「无公害」——即使是整个文件替换更新,也不会带来什么坏处,甚至你都不需要对文件内容进行读取,只需要重新加载文件内容。

JavaScript

最大的难点在于 JavaScript 热更新的实现,如果我们参考 HTML 和 CSS 的实现,简单的进行二次写入,很快的就会遇到各种各样的问题。在这里,我们通过 eval 的方式进行再写入。

假设我们对按钮绑定了一个点击事件,console.log(123),然后变成 console.log(1),使用原本的方法写入之后,就会响应两次事件,分别输出 「123」和「1」。(这里就不贴代码了,感兴趣的同学可以自己做这个实验)

但是如同 HTML 的实现部分一样,我们并不像进行复杂的语法树构建来感知操作的是哪一个 DOM,那么这个需求就变的很难处理。

得益于组件化,我们现在并不用太过关心这个问题,当我更新了一个文件的时候,我必然是更新了一个组件,只需要把这个组件的实例化移除并且重新载入即可,那样与之绑定的相关事件也会被删除。

整理一下思路,要执行 JS 的热更新,我们大概会有以下几个步骤:

  1. 感知每一个热更新的组件:建立一个 k-v 结构,确保存入每个组件的实例,便于之后更新时删除 DOM 并且更新
  2. 执行 eval 写入代码
  3. 遍历 k-v 结构,删除原先创建的 DOM,而实例渲染到 DOM 中的步骤是由框架本身处理的,我们甚至可以不用做任何操作

这里我们以我最近在使用的那个无需构建即可运行的前端框架为例,从上述步骤中,我们可以知道,最重要的就是要劫持构造函数,在转换为 DOM 时存入我们的 k-v 结构,方便以后使用。

  // 劫持构造函数
  const JKL = window.Jinkela
  const storage = {}
  let latest = true

  window.Jinkela = class jkl extends JKL {
    constructor(...args) {
      super(...args)
      const values = storage[this.constructor.name]
      if (!latest) {
        storage[this.constructor.name].forEach(el => el.remove())
        latest = true
      }
      storage[this.constructor.name] = values ? [...values, this.element] : [ this.element ]
    }
  }
// 注入
if (data.type === 'js') {
  latest = false
  eval(data.content)
  console.log('[HMR] updated JS');
}

这样在执行 eval 的过程中就会先记性一遍 DOM 的整理,执行完毕后新的组件就被渲染上去了。

当然,读者可以发现这里有一个前提条件,那就是没有一个内容处于全局作用域,否则就会遇到重复声明的 error 导致热更新失败。

效果预览与总结

效果预览图:
2019-04-14 15.20.39.gif
项目地址:https://github.com/csvwolf/jinkela-hot-reload

基本上来说是一个非常简单的 Hot Reload,可以完善的地方还是相当多的:

  1. 没有维持连接的心跳包
  2. 频繁对磁盘文件读
  3. 降级 Live Reload 的操作
  4. 目前这种 Hot Reload 只支持单文件组件
  5. 不支持继承

那么,到底能不能有一个通用的支持所有 JS 的 hot reload 呢?目前为止感觉还不能解决重复声明的问题,实际上,webpack 的由 loader 实现大致也是因为各个模块会有其自己的风格,需要单独去处理。

这里仅作抛砖引玉之用——如有错误,敬请指出。

参考资料

如果您觉得文章不错,可以通过赞助支持我

标签: JavaScript

仅有一条评论

  1. 邪气满满

    所以总结一句话就是:监听文件操作后,document.write()重绘一遍所有内容就行了...

添加新评论