我对热重载的愿望清单
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 类型。
lazy
、memo
、forwardRef
等都应支持,而且添加新类型支持不应困难。像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