React 是怎样炼成的
本文主要讲述 React 的诞生过程和优化思路。 内容整理自 2014 年的 OSCON - React Architecture by vjeux,虽然从今天(2018)来看可能会有点历史感,但仍然值得学习了解。以史为鉴,从中也可以管窥 Facebook 优秀的工程管理文化。 字符拼接时代 - 2004时间回到 2004 年,Mark Zuckerberg 当时还在宿舍捣鼓最初版的 Facebook 。 $str = '<ul>'; foreach ($talks as $talk) { $str += '<li>' . $talk->name . '</li>'; } $str += '</ul>'; 这种网站开发方式在当时看来是非常正确的,因为不管是后端开发还是前端开发,甚至根本没有开发经验,都可以使用这种方式搭建一个大型网站。 唯一不足的是,这种开发方式容易造成 XSS 注入等安全问题。如果 最简单的应对方法是对用户的任何输入都进行转义(Escape)。然而这也带来了其他麻烦,如果对字符串进行多次转义,那么反转义的次数也必须是相同的,否则会无法得到原内容。如果又不小心把 HTML 标签(Markup)给转义了,那么 HTML 标签会直接显示给用户,从而导致很差的用户体验。 XHP 时代 - 2010到了 2010 年,为了更加高效的编码,同时也避免转义 HTML 标签的错误,Facebook 开发了 XHP 。XHP 是对 PHP 的语法拓展,它允许开发者直接在 PHP 中使用 HTML 标签,而不再使用字符串。 $content = <ul />; foreach ($talks as $talk) { $content->appendChild(<li>{$talk->name}</li>); } 这样的话,所有的 HTML 标签都使用不同于 PHP 的语法,我们可以轻易的分辨哪些需要转义哪些不需要转义。 不久的后来,Facebook 的工程师又发现他们还可以创建自定义标签,而且通过组合自定义标签有助于构建大型应用。 $content = <talk:list />; foreach ($talks as $talk) { $content->appendChild(<talk talk={$talk} />); } 之后,Facebook 在 JS 中尝试了更多的新技术方式以减小客户端和服务端之间的延时。比如跨浏览器 DOM 库和数据绑定,但是都不是很理想。 JSX - 2013等到 2013 年,突然有一天,前端工程师 Jordan Walke 向他的经理提出了一个大胆的想法:把 XHP 的拓展功能迁移到 JS 中。最开始大家都以为他疯了,因为这与当时大家都看好的 JS 框架格格不入。不过他最终还是执着地说服了经理,允许他用 6 个月的时间来验证这个想法。这里不得不说 Facebook 良好的工程师管理哲学让人敬佩,值得借鉴。 附:Lee Byron 谈 Facebook 工程师文化: Why Invest in Tools 要想把 XHP 的拓展功能迁移到 JS ,首要任务是需要一个拓展来让 JS 支持 XML 语法,该拓展称为 JSX 。当时,随着 Node.js 的兴起,Facebook 内部对于转换 JS 已经有相当多的工程实践了。所以实现 JSX 简直轻而易举,仅仅花费了大概一周的时间。 const content = ( <TalkList> { talks.map(talk => <Talk talk={talk} />)} </TalkList> ); React自此,开始了 React 的万里长征,更大的困难还在后头。其中,最棘手的是如何再现 PHP 中的更新机制。 在 PHP 中,每当有数据改变时,只需要跳到一个由 PHP 全新渲染的新页面即可。 虽然简单粗暴,但是这种方式的缺点也尤为突出,那就是它非常慢。 “You need to be right before being good”,意思是说,为了验证迁移方案的可行性,开发者必须快速实现一个可用版本,暂时不考虑性能问题。 DOM取自于 PHP 的灵感,在 JS 中实现重新渲染的最简单办法是:当任何内容改变时,都重新构建整个 DOM,然后用新 DOM 取代旧 DOM 。
这种方式是可以工作的,但在有些场景下不适用。
既然包含状态,那么记下旧 DOM 的状态然后在新 DOM 上还原不就行了么? 在 OSX 电脑上滚动页面时,会伴随着一定的滚动惯性。但是 JS 并没有提供相应的 API 来读取或者写入滚动惯性。 既然还原状态行不通,那就换一种方式绕过去。 至此,只要能够识别出哪些节点改变了,那么就可以实现对 DOM 的更新。于是问题就转化为如何比对两个 DOM 的差异。 Diff说到对比差异,相信大家马上就能联想到版本控制(Version Control)。它的原理很简单,记录多个代码快照,然后使用 diff 算法比对前后两个快照,从而生成一系列诸如“删除 5 行”、“新增 3 行”、“替换单词”等的改动;通过把这一系列的改动应用到先前的代码快照就可以得到之后的代码快照。 而这正是 React 所需要的,只不过它的处理对象是 DOM 而不是文本文件。 DOM 是树形结构,所以 diff 算法必须是针对树形结构的。目前已知的完整树形结构 diff 算法复杂度为 O(n^3) 。 假如页面中有 10,000 个 DOM 节点,这个数字看起来很庞大,但其实并不是不可想象。为了计算该复杂度的数量级大小,我们还假设在一个 CPU 周期我们可以完成单次对比操作(虽然不可能完成),且 CPU 主频为 1 GHz 。这种情况下,diff 要花费的时间如下:
整整有 17 分钟之长,简直无法想象! 虽然说验证阶段暂不考虑性能问题,但是我们还是可以简单了解下该算法是如何实现的。 附: 完整的 Tree diff 实现算法。
在上图的树中,依据最小操作原则,可以找到三个嵌套的循环对比。 但如果认真思考下,其实在 Web 应用中,很少有移动一个元素到另一个地方的场景。一个例子可能的是拖拽(Drag)并放置(Drop)元素到另一个地方,但它并不常见。 唯一的常用场景是在子元素之间移动元素,例如在列表中新增、删除和移动元素。既然如此,那可以仅仅对比同层级的节点。
如上图所示,仅对相同颜色的节点做 diff ,这样能把时间复杂度降到了 O(n^2) 。 key
针对同级元素的比较,又引入了另一个问题。 最直观的结果是前面两个保持不变,删除第三个。
如果再加上元素的属性呢?比如
那使用所有元素都有的
结合 附:详细的 diff 理解: 不可思议的 react diff 。 持续优化Virtual DOM前面说到,React 其实实现了对 DOM 节点的版本控制。 // Chrome v63 const div = document.createElement('div'); let m = 0; for (let k in div) { m++; } console.log(m); // 231 之所以有这么多属性,是因为 DOM 节点被用于浏览器渲染管道的很多过程中。 现在回过头来想想 React ,其实它只在 diff 算法中用到了 DOM 节点,而且只用到了标签名称和部分属性。
其过程如下:
可以看出,因为要把变更应用到真实 DOM 上,所以还是避免不了要直接操作 DOM ,但是 React 的 diff 算法会把 DOM 改动次数降到最低。 至此,React 的两大优化:diff 算法和 Virtual DOM ,均已完成。再加上 XHP 时代尝试的数据绑定,已经算是一个可用版本了。 React 的开源可谓是一石激起千层浪,社区开发者都被这种全新的 Web 开发方式所吸引,React 因此迅速占领了 JS 开源库的榜首。 接下来要说的两大优化就是来自于开源社区。 批处理(Batching)著名浏览器厂商 Opera 把重排和重绘(Reflow and Repaint)列为影响页面性能的三大原因之一。 我们说 DOM 是很慢的,除了前面说到的它的复杂和庞大,还有另一个原因就是重排和重绘。 当 DOM 被修改后,浏览器必须更新元素的位置和真实像素; 另外,由于浏览器本身对 DOM 操作进行了优化,比如把两次很近的“修改”操作合并成一个“修改”操作。 与此同时,常规的 JS 写法又很容易触发重排和重绘。 最终,社区贡献者 Ben Alpert 使用批处理的方式拯救了这个尴尬的处境。 在 React 中,开发者通过调用组件的
Ben Alpert 的做法是,调用 等到初始化事件被完全广播开以后,就开始进行从顶部到底部的重新渲染(Re-Render)过程。这就确保了 React 只对元素进行了一次渲染。 这里要注意两点:
这也提醒开发者,应该让拥有状态的组件尽量靠近叶子节点,这样可以缩小重新渲染的范围。 裁剪(Pruning)随着应用越来越大,React 管理的组件状态也会越来越多,这就意味着重新渲染的范围也会越来越大。 认真观察上面批处理的过程可以发现,该 Virtual DOM 右下角的三个元素其实是没有变更的,但是因为其父节点的变更也导致了它们的重新渲染,多做了无用操作。
对于这种情况,React 本身已经考虑到了,为此它提供了
当时,React 虽然提供了 其原因是,在 JS 中,我们通常使用对象来保存状态,修改状态时是直接修改该状态对象的。也就是说,修改前后的两个不同状态指向了同一个对象,所以当直接比较两个对象是否变更时,它们是相同的,即使状态已经改变。 对此,David Nolen 提出了基于不可变数据结构(Immutable Data Structure)的解决方案。 David 使用 ClojureScript 写了一个针对 React 的不可变数据结构方案:Om ,为 不过,由于不可变数据结构并未被 Web 工程师广为接受,所以当时并未把这项功能合并进 React 。 如果真想利用不可变数据结构来提高 React 性能,可以参考与 React 师出同门的 Facebook Immutable.js,它是 React 好搭档! 结束语React 的优化仍在继续,比如 React 16 中新引入 Fiber,它是对核心算法的一次重构,即重新设计了检测变更的方法和时机,允许渲染过程可以分段完成,而不必一次性完成。 最后,感谢 Facebook 给开源社区带来了如此优秀的项目! (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |