overreactedby Dan Abramov

我对热重载的愿望清单

December 8, 2018

你是否有过这样一个项目:你反复尝试,时而成功,时而失败,然后搁置一段时间,过后又再试——年复一年?对有些人来说,也许是路由器或虚拟列表滚动器。对我来说,则是热重载。

我第一次接触到“代码热更新”这个想法,是在青少年时期读的一本 Erlang 书中,书里有一笔带过。很久以后,和许多人一样,我被 Bret Victor 的精彩演示深深吸引。我曾在某处读到,Bret 对人们只挑他演示中“简单”部分、却搞砸了整体愿景感到不满。(我不确定这是否属实。)**无论如何,对我来说,哪怕只是发布一些后来被大家视为理所当然的小改进,也是成功。**更聪明的人会去追逐下一个伟大的想法。

现在我想说明,本文讨论的所有想法都不是我原创的。我受到许多项目和人的启发。事实上,甚至有些我从未用过他们项目的人,偶尔也会说我抄袭了他们的东西。

我不是发明家。如果说我有“原则”,那就是把那些激励我的愿景,通过文字、代码和演示分享给更多人。

而热重载,正是让我充满激情的东西。


我曾多次尝试为 React 实现热重载。

回头看,我拼凑的第一个演示改变了我的人生。它让我收获了第一批 Twitter 粉丝、第一个千星 GitHub 项目,后来还登上了HN 头版,甚至有了我的第一个大会演讲(顺便催生了 Redux,哈哈)。这第一个版本表现还不错。但很快,React 开始放弃createClass,让可靠实现变得更加困难。

此后,我又做了几次尝试来修复它,每次都以不同方式存在缺陷。其中一个版本至今还在 React Native 中使用(函数热重载在那儿不能用,是我的锅——抱歉!)

因为无法绕过某些问题,也没时间继续折腾,我把 React Hot Loader 移交给了几位才华横溢的贡献者。他们不断推进项目进展,为我的设计缺陷找到了巧妙的变通方法。感谢他们在各种挑战下依然让项目保持良好状态。


需要说明的是,React 的热重载现在已经相当可用了。 事实上,这个博客用的 Gatsby,底层就用到了 React Hot Loader。我在编辑器里保存这篇文章,它就能自动更新,无需刷新。简直像魔法!某种程度上,我曾担心永远无法普及的愿景,如今已经快变得“无聊”了。

但仍有不少人觉得它还不够好。有些人把它当作噱头,这让我有点难过,但我认为他们其实是在说:**体验不够无缝。**如果你总是不确定热重载是否生效,或者它以令人困惑的方式出错,甚至还不如直接刷新页面,那它就不值得用。我完全同意,但对我来说,这只是说明我们还有很多工作要做。我很期待思考未来 React 官方支持热重载会是什么样子。

(如果你用的是 Elm、Reason 或 ClojureScript 这样的语言,也许这些问题在你的生态里已经解决了。你开心我也开心。但这不会阻止我继续尝试把好东西带到 JavaScript 里。)


我觉得我已经准备好再试一次了。原因如下。

自从 createClass 不再是定义组件的主要方式后,**热重载组件最大的复杂性和脆弱性来源,就是动态替换类方法。**你如何用新“版本”的方法去修补已存在的类实例?简单的答案是“替换原型上的方法”,但即使用 Proxy,我的经验是这里有太多棘手的边界情况,难以可靠实现。

相比之下,热重载函数就简单多了。一个 Babel 插件可以把模块里导出的任意函数组件拆成两个函数:

// 重新分配到最新版本
window.latest_Button = function(props) {
  // 你的实际代码由插件移动到这里
  return <button>Hello</button>;
}
 
// 可以把它看作“代理”
// 其他组件会用它
export default function Button(props) {
  // 始终指向最新版本
  return window.latest_Button(props);
}

每次编辑后,这个模块重新执行时,window.latest_Button 就会指向最新的实现。多次模块求值时复用同一个 Button 函数,可以“骗过” React,让它不卸载组件,即使我们换掉了实现。

很长一段时间里,我觉得只实现函数的可靠热重载,会让人们为了避免用类而写出晦涩的代码。但有了 Hooks,函数组件已经功能齐全,这个担忧不再存在。而且这种方式对 Hooks 也“天然兼容”:

// 重新分配到最新版本
window.latest_Button = function(props) {
  // 你的实际代码由插件移动到这里
  const [name, setName] = useState('Mary');
  const handleChange = e => setName(e.target.value);
  return (
    <>
      <input value={name} onChange={handleChange} />
      <h1>Hello, {name}</h1>
    </>
  );
}
 
// 可以把它看作“代理”
// 其他组件会用它
export default function Button(props) {
  // 始终指向最新版本
  return window.latest_Button(props);
}

只要 Hook 的调用顺序不变,即使 window.latest_Button 在文件编辑间被替换,我们也能保留状态。事件处理函数的替换也“天然生效”——因为 Hooks 依赖闭包,而我们替换的是整个函数。


这只是其中一种可能方案的粗略草图。还有其他方案(有些差别很大)。我们该如何评估和比较它们?

在我对某种可能有缺陷的方案产生过多执念之前,我决定先写下我认为评判任何组件热重载实现时重要的几个原则。

以后能把这些原则转成测试用例就更好了。这些规则并不绝对,有时可以合理权衡。但如果我们决定打破它们,应该是有意识的设计决策,而不是事后才发现的意外。

以下是我对 React 组件热重载的愿望清单:

正确性

  • 在首次编辑前,热重载应当不可察觉。 在你保存文件之前,代码的行为应与未启用热重载时完全一致。像 fn.toString() 不匹配这种情况可以接受(压缩后本来也这样),但不能破坏合理的应用和库逻辑。

  • 热重载不应破坏 React 规则。 组件不应出现生命周期异常调用、意外交换无关树的状态,或做出其他不符合 React 规范的行为。

  • 元素类型应始终与预期类型匹配。 有些方案会包裹组件类型,但这可能导致 <MyThing />.type === MyThing 不成立。这是常见的 bug 来源,必须避免。

  • 应易于支持所有 React 类型。 lazymemoforwardRef 等都应支持,而且添加新类型支持不应困难。像 memo(memo(...)) 这样的嵌套变体也要能正常工作。类型结构变更时应始终重新挂载。

  • 不应重实现 React 的复杂部分。 React 发展很快,如果方案重写了 React 的大块逻辑,长期来看会跟不上 React 新特性(如 Suspense)。

  • 重新导出不应出错。 如果组件从其他模块(无论是自己写的还是 node_modules 里的)重新导出,不应因此出问题。

  • 静态字段不应出错。 如果你定义了 ProfilePage.onEnter 方法,希望导入模块能正常读取。有些库依赖这一点,因此必须能读写静态属性,且组件本身也能“看到”自己身上的这些值。

  • 宁可丢失局部状态,也不要行为异常。 如果某些东西(比如类)无法可靠修补,宁可丢掉其局部状态,也不要尝试“半成功”地更新。开发者本来也会怀疑,最终很可能强制刷新。我们要明确哪些场景能自信处理,其他的就直接放弃。

  • 宁可丢失局部状态,也不要用旧版本。 这是上条原则的具体化。例如,如果某个类无法热重载,代码应强制重新挂载这些组件的新版本,而不是继续渲染“僵尸”实例。

局部性

  • 编辑一个模块时,应尽量少重新执行其他模块。 组件模块初始化时的副作用本就不推荐。你执行的代码越多,越容易因多次调用导致混乱。我们写的是 JavaScript,React 组件虽相对纯粹,但也没有强保证。所以,编辑一个模块时,热重载应只重新执行该模块,并尽量止步于此。

  • 编辑组件不应破坏父级或兄弟的状态。 类似于 setState() 只影响下方树,编辑组件也不应影响其父级或兄弟。

  • 对非 React 代码的编辑应向上传递。 如果你编辑了一个被多个组件导入的常量或纯函数文件,这些组件应能更新。此类文件丢失模块状态是可以接受的。

  • 热重载期间引入的运行时错误不应扩散。 如果你在一个组件里写错了,不应导致整个应用崩溃。在 React 里通常用错误边界解决,但对于我们编辑时常见的拼写错误等,错误边界太粗了。我应该能在编辑组件时随意制造和修复运行时错误,而不会导致父级或兄弟卸载。但那些不是热重载期间发生的错误(即代码本身的 bug),应交给最近的错误边界处理。

  • 默认应尽量保留自身状态,除非开发者明确不需要。 仅仅调整样式时,每次编辑都重置状态很烦人。相反,如果你改了状态结构或初始值,通常希望它重置。默认我们应尽量保留状态。但如果热重载期间出错,通常说明某些假设已变,这时应重置状态并重试渲染。注释掉再加回来很常见,这种情况要优雅处理。例如,只要是末尾移除 Hook,就不应重置状态。

  • 明确时应主动丢弃状态。 有些情况下我们也能主动检测到用户想重置。例如,Hook 顺序变了,或像 useState 这样的基础 Hook 初始状态类型变了。我们也可以提供轻量注释,比如 // !,让你专注于组件挂载时,随时强制每次编辑都重置,非常方便。

  • 支持更新“固定”内容。 如果组件被 memo() 包裹,热重载也应能更新。如果 effect 用了 [],也应能替换。代码本身就像个隐形变量。以前我觉得像 renderRow={this.renderRow} 这种要强制深度更新,但在 Hooks 世界里我们本就依赖闭包,这似乎已没必要。只要引用变了就够了。

  • 支持一个文件里多个组件。 多个组件定义在同一个文件很常见。即使只保留函数组件的状态,也要确保它们放在同一个文件不会导致状态丢失。这些组件甚至可能互相递归。

  • 尽量保留子组件状态。 编辑组件时,子组件意外丢失状态总是让人沮丧。只要子组件类型定义在其他文件,我们就期望其状态能保留。如果在同一文件,也要尽力而为。

  • 支持自定义 Hook。 对写得好的自定义 Hook(有些如 useInterval() 比较棘手),热重载任何参数(包括函数)都应生效。这不应需要额外工作,只要不妨碍 Hooks 设计即可。

  • 支持 render props。 这通常没什么问题,但值得确认它们能按预期工作和更新。

  • 支持高阶组件。 用高阶组件(如 connect)包裹导出不应破坏热重载或状态保留。如果你在 JSX 里用 HOC 创建的组件(比如 styled),且该组件是类,预期它在被编辑文件实例化时会丢失状态。但 HOC 返回的是函数组件(可能用 Hooks),即使定义在同一文件,也不应丢失状态。实际上,连参数(如 mapStateToProps)的编辑也应能反映出来。

反馈

  • 无论成功还是失败,都应有视觉反馈。 你应始终能确定热重载是成功还是失败。如果出现运行时或语法错误,应有覆盖层提示,并在错误消失后自动关闭。热重载成功时,也应有视觉反馈,比如高亮更新的组件或通知。

  • 语法错误不应导致运行时错误或刷新。 编辑代码时如果有语法错误,应在弹窗覆盖层显示(最好能一键跳转到编辑器)。如果再犯新语法错误,覆盖层应更新。只有修复语法错误后才尝试热重载。语法错误不应让你丢失状态。

  • 热重载后产生的语法错误仍应可见。 如果你看到语法错误弹窗并刷新页面,仍应看到这个错误。绝不能让你运行上次成功的版本(有些环境下会这样)。

  • 考虑暴露高级用户工具。 热重载让代码本身可以是你的“终端”。除了假想的 // ! 强制重挂命令,还可以有如 // inspect 这样的命令,在组件旁边显示 props 面板。可以大胆创新!

  • 尽量减少噪音。 DevTools 和警告信息不应暴露我们做了特殊处理。避免破坏 displayName 或在调试输出里加无用包裹。

  • 主流浏览器调试应显示最新代码。 虽然这不完全取决于我们,但应尽力保证浏览器调试器能显示任意文件的最新版本,断点也能正常工作。

  • 优化快速迭代,而非长时间重构。 这是 JavaScript,不是 Elm。长时间连续编辑很难热重载好,因为会有一堆错误要逐一修复。遇到疑问时,应优先优化紧密循环中微调组件的场景,而不是大规模重构。要可预测。如果开发者失去信任,他们反正会刷新。


这就是我对 React 热重载——或任何不仅仅是模板的组件系统——应有的愿望清单。以后可能还会补充。

我不知道这些目标有多少能用 JavaScript 实现。但我还有一个理由期待再次投入热重载的开发。作为工程师,我现在比以前更有条理了。尤其是,我终于学会了在动手实现前,先写下这样的需求文档。

也许这次真的能成!但即使失败了,至少我为下一个尝试的人留下了一些线索。

Pay what you like

Edit on GitHub