本文主要讲述了React的诞生过程和优化思路。 内容整理自2014年vjeux的OSCON-ReactArchitecture,虽然从今天(2018年)开始可能会有历史感,但还是值得学习的。以史为鉴,我们也可以窥见Facebook优秀的工程管理文化。 CharacterEra-2004年 回到2004年,马克扎克伯格还在他的宿舍里开发最初的Facebook。 今年大家都在用PHP的StringConcatenation功能开发网站。$str='
'`;`foreach(`$talksas$talk`){$str+='- '.$谈话`->名字。'
'`;}$str+='
'`;` 这种网站开发的方法在当时看来是非常正确的,因为无论后端开发还是前端都可以使用-结束开发,甚至完全没有开发经验建立一个大型网站。 唯一的缺点就是这种开发方式容易造成XSS注入等安全问题。如果$talk->name中包含恶意代码且没有任何保护措施,则攻击者可以注入任意JS代码。因此,“永远不要相信用户输入”安全规则。 最简单的处理方法是对用户的任何输入进行转义(Escape)。然而,这也带来了其他麻烦。如果对字符串进行多次转义,转义次数必须相同,否则得不到原来的内容。如果不小心对HTML标签(Markup)进行了转义,那么HTML标签会直接显示给用户,导致用户体验很差。 XHP时代-2010年 2010年,为了更高效的编码,避免转义HTML标签的错误,Facebook开发了XHP。XHP是PHP的语法扩展,它允许开发人员直接在PHP中使用HTML标记而不是字符串。$content=
;foreach(`$talksas$talk`){$content`->appendChild(
{$talk->name});`} 所有HTML标签使用与PHP不同的语法,我们可以很容易地分辨出哪些需要转义,哪些不需要。 不久之后,Facebook工程师发现他们还可以创建自定义标签,并且组合自定义标签有助于构建更大的应用程序。 这正是实现语义网和网络组件概念的一种方式。$content=
;foreach(`$talksas$talk`){$content`->appendChild(
);`} 后Facebook在JS中尝试更多新技术方法,以减少客户端和服务器之间的延迟。诸如跨浏览器DOM库和数据绑定之类的东西,但都不是理想的。 JSX-2013年 等到2013年,突然有一天,前端工程师JordanWalke向他的经理提出了一个大胆的想法:将XHP的扩展功能迁移到JS。一开始大家都觉得他疯了,因为它和当时大家都看好的JS框架不兼容。但他最终坚持并说服他的经理给他六个月的时间来测试这个想法。在这里不得不说,Facebook良好的工程师管理理念令人敬佩,值得借鉴。附:LeeByron谈Facebook工程师文化:WhyInvestinTools 如果要将XHP的扩展功能迁移到JS,首要任务是需要一个扩展让JS支持XML语法,这就是JSX.当时,随着Node.js的兴起,Facebook内部已经有了相当多的翻译JS的工程实践。所以实施JSX轻而易举,只用了大约一周的时间。常量内容=(
{talk.map(talk=>)}); React 从此开始了React的万里长征,更大的困难还在后面。其中,最棘手的是如何在PHP中重现更新机制。 在PHP中,只要有数据变化,只需要跳转到PHP渲染的新页面即可。 从开发者的角度来看,用这种方式开发应用是非常简单的,因为不需要担心变化,当界面上用户数据发生变化时,所有的内容都会同步。 会在数据发生变化时重新呈现整个页面。 虽然简单粗暴,但是这种方法的缺点特别突出,就是速度很慢。 “Youneedtoberightbeforebeinggood”是指为了验证迁移方案的可行性,开发者必须迅速实现一个可用的版本,暂时不管性能问题。 DOM 的灵感来自PHP。在JS中实现重新渲染最简单的方法是:当任何内容发生变化时,重建整个DOM,然后用新的DOM替换旧的DOM。 这个方法可以,但是有些场景不适用。 例如,它会丢失当前获得焦点的元素和光标,以及文本选择和页面滚动位置,这些都是页面的当前状态。 换句话说,DOM节点包含状态。 既然包含了state,那么把旧DOM的state记下来,在新DOM上恢复不就可以了吗? 但不幸的是,这种方法不仅实现起来复杂,而且无法覆盖所有情况。 在OSX电脑上滚动时,会有一定的滚动惯性。但是JS并没有提供相应的API来读写滚动惯性。 对于包含iframe的页面,情况就比较复杂了。如果它来自另一个域,浏览器安全策略限制根本不允许我们查看其中的内容,更不用说恢复它了。 所以可以看出DOM不仅有状态,它还包含隐藏的、不可达的状态。 既然恢复状态不行,那我们换个思路绕过去。 对于未更改的DOM节点,保持不变,仅创建和替换更改的DOM节点。 该方法实现了DOM节点重用(Reuse)。 至此,只要能识别出哪些节点发生了变化,就可以更新DOM了。所以问题就变成了如何比较两个DOM之间的差异。 Diff 说到比较差异,相信大家马上就会想到版本控制(VersionControl)。它的原理很简单,记录多个代码快照,然后使用diff算法对比前后两个快照,从而产生“删除5行”、“添加3行”、“替换”等一系列变化词”等;通过将这一系列的更改应用到之前的代码快照中来获取后续的代码快照。 而这正是React所需要的,除了它适用于DOM而不是文本文件。 难怪有人说:“我倾向于将React视为DOM的版本控制”。 DOM是树状结构,所以diff算法必须针对树状结构。目前已知的完整树结构diff算法的复杂度为O(n^3)。 如果页面中有10000个DOM节点,这个数字看似庞大,但并非不可想象。为了计算这种复杂性的数量级,我们还假设我们可以在一个CPU周期内进行一次比较操作(尽管这是不可能的),并且CPU的时钟频率为1GHz。在这种情况下,差异所花费的时间如下: 是难以想象的17分钟之久! 虽然在验证阶段没有考虑性能问题,但我们还是可以简单了解一下算法是如何实现的。附件:完整的Treediff实现算法。新树上的每个节点都与旧树上的每个节点进行比较。如果父节点相同,则继续比较子树 在上面的树中,根据最小运算原则,可以找到三个嵌套循环比较。 但是仔细想想,其实在web应用中,很少有将一个元素移动到另一个地方的场景。一个例子可能是将一个元素拖放到另一个地方,但这并不常见。 唯一常见的用例是在子元素之间移动元素,例如在列表中添加、删除和移动元素。既然如此,就只能比较同级别的节点了。 如上图所示,只对相同颜色的节点做diff,可以将时间复杂度降低到O(n^2)。 key 对于同级元素的比较,引入另一个问题。 当同一层的元素名称不同时,可以直接判断为不匹配;当它们相同时,就不那么简单了。 如果在某个节点下,上次渲染了3个
,下次渲染又渲染了2个。这个时候diff会是什么结果呢? 最直观的结果就是前两个不变,第三个删掉。 当然也可以删除第一个,保留最后两个。 如果不嫌麻烦的话,也可以把旧的三个元素删掉,再加入两个新的元素。 这表明对于标签名称相同的节点,我们没有足够的信息来比较前后的差异。 如果添加了元素的属性呢?例如value,如果标签名称和value属性前后两次相同,则认为该元素匹配,无需更改。但实际情况是这样行不通,因为值总是随着用户键入而变化,导致元素一直被替换,导致失去焦点;更糟糕的是,并不是所有的HTML元素都有这个属性。 使用所有元素都有的id属性怎么样?这是有可能的,如上图所示,我们可以很容易的识别前后DOM的区别。考虑到表单的情况,表单模型的输入通常与一个id相关联,但是如果使用AJAX提交表单,我们通常不会为输入设置id属性。因此,更好的做法是专门引入一个新的属性名来辅助diff算法。此属性最终确定为key。这就是为什么在React中使用列表时,需要在子元素上设置key属性。 结合key和hashtable,diff算法最终达到了O(n)的最优复杂度。 至此,我们可以看到从XHP迁移到JS的方案是可行的。然后你就可以逐步优化每一个环节。附:diff详解:不可思议的reactdiff。 持续优化 虚拟DOM 前面说了,React其实是对DOM节点实现了版本控制。 做过JS应用优化的人可能都知道DOM很复杂,对它的操作(尤其是查询和创建)非常慢,而且很耗资源。看下面的例子,仅仅创建一个空白的div就有231个实例属性。//Chromev63constdiv=document.createElement(`'div'`);letm=0;for(letkindiv){m++;}console.log(m);//231 之所以如此Multipleattributes,是因为DOM节点在浏览器的渲染管线中有很多进程用到。 浏览器首先根据CSS规则搜索匹配的节点。这个进程会缓存很多元信息,比如它维护了一张对应DOM节点的id映射表。 然后根据样式计算节点布局,位置和屏幕定位信息,还有很多其他的元信息都缓存在这里。浏览器会尽量避免重新计算布局,所以这些数据会被缓存起来。 可见整个渲染过程消耗了大量的内存和CPU资源。 现在想想React,它在diff算法中只用到了DOM节点,也只用到了标签名和一些属性。 如果将复杂的DOM节点替换成更轻量级的JS对象,然后将DOM上的diff操作转移到JS对象上,就可以避免很多对DOM的查询操作。这种方法称为虚拟DOM。 的过程是这样的:维护一个用JS对象表示的VirtualDOM,与真实DOM进行一对一比较,前后两个VirtualDOM进行diff,产生变异(Mutation)并将更改应用到真实DOM,生成最新的真实DOM 可以看出,由于需要将更改应用到真实DOM,所以还是免不了直接操作DOM,但是React的diff算法会最小化DOM更改的次数。 至此,已经完成了React的两大优化:diff算法和VirtualDOM。再加上XHP时代的数据绑定尝试,已经是可以使用的版本了。 这时候Facebook做出了一个重大的决定,那就是开源React! React的开源可以说是一石激起千层浪,社区开发者被这种全新的web开发方式所吸引,因此React迅速占据了JS开源库榜首。 很多大公司也将React应用到了生产环境中,大量的社区开发者为React贡献了代码。 接下来我要说的两个优化来自于开源社区。 批处理 知名浏览器厂商Opera将回流和重绘(ReflowandRepaint)列为影响页面性能的三大原因之一。 我们说DOM很慢,除了上面说的复杂和庞大之外,还有一个原因就是重排和重绘。 DOM修改时,浏览器必须更新元素的位置和真实像素; 尝试从DOM中读取属性时,为了保证读取的值是正确的,浏览器也会触发回流和重绘。 因此,重复的“读取、修改、读取、修改……”操作会触发大量的重排和重绘。 另外,由于浏览器本身对DOM操作进行了优化,比如将两个相近的“修改”操作合并为一个“修改”操作。 因此,如果将“读取、修改、读取、修改...”重新排列为“读取、读取...”和“修改、修改...”,则行数和重绘。但这种故意的、手动的级联方法并不安全。 同时,常规的JS写法容易触发重排重绘。 在减少回流和重绘的道路上,React处于一个尴尬的位置。 最后,社区贡献者BenAlpert通过使用批处理挽救了这一尴尬局面。 在React中,开发者通过调用组件的setState方法告诉React当前组件即将发生变化。 BenAlpert的做法是在调用setState时不立即将变化同步到VirtualDOM,而只是将相应的元素标记为“待更新”。如果在组件中多次调用setState,都会执行相同的标记操作。 等到初始化事件完全广播完毕,然后从上到下开始重新渲染(Re-Render)过程。这确保React只渲染元素一次。 这里需要注意两点:这里的重新渲染指的是将setState的变化同步到VirtualDOM;之后,执行差异操作以生成真正的DOM更改。与上面提到的“重新渲染整个DOM”不同,真正的重新渲染只渲染被标记的元素及其子元素,也就是说只会重新渲染上图中蓝色圆圈代表的元素。rendered 这也提醒开发者,有状态的组件要尽量靠近叶子节点,这样可以减少重新渲染的范围。 Pruning 随着应用越来越大,React管理的组件状态越来越多,这意味着重新渲染的范围也会越来越大。 仔细观察上面的批处理过程,我们可以发现,VirtualDOM右下角的三个元素并没有发生变化,只是因为它们的父节点发生了变化,所以重新渲染,多了一些无用的操作已经完成。 对于这种情况,React本身已经考虑到了,为此提供了boolshouldComponentUpdate(nextProps,nextState)接口。开发者可以手动实现该接口,对比前后的状态和属性,判断是否需要重新渲染。这样的话,重新渲染就变成了下图所示的过程。 当时React虽然提供了shouldComponentUpdate接口,但是并没有提供默认的实现(alwaysrender),需要开发者手动实现才能达到预期的效果。 原因是在JS中,我们通常使用对象来保存状态,而在修改状态时,直接修改状态对象。即修改前后两个不同的状态指向同一个对象,所以当直接比较两个对象是否发生变化时,它们是一样的,即使状态发生了变化。 为此,DavidNolen提出了基于不可变数据结构的解决方案。 此方案的灵感来自ClojureScript,其中大多数值都是不可变的。也就是说,当一个值需要更新时,程序并不修改原值,而是在原值的基础上创建一个新值,然后使用新值进行赋值。 David使用ClojureScript为React编写了一个不可变数据结构解决方案:Om,它为shouldComponentUpdate提供了一个默认实现。 但是,由于不可变数据结构并没有被web工程师广泛接受,这个特性当时并没有被纳入React。 不幸的是,到目前为止,shouldComponentUpdate仍然没有提供默认实现。 但是David为开发者开辟了一个很好的研究方向。 如果真的想使用不可变数据结构来提升React性能,可以参考FacebookImmutable.js,和React同校。是React的好搭档! 结束语 React的优化还在继续,比如React16新引入的Fiber,就是对核心算法的重构,即重新设计检测变化的方法和时机,让渲染过程分段完成,而不必一次完成。 由于篇幅限制,本文不会深入介绍Fiber。有兴趣的可以参考什么是ReactFiber。 最后,感谢Facebook为开源社区带来这么棒的项目!