去哪儿网迷你React的研发心得

#1

##去哪儿网迷你React的研发心得

去哪儿网迷你React是年初立项的新作品,在这前,去哪儿网已经深耕多年,拥有QRN(react-native的公司制定版),HY(基于React的hybird方案), yo(基于React的移动UI库),QRN-web(基于React的三端合一移植方案),此外,像机票等部门也大规模将React用于前台页面,后台页面就更不在话下。

如此广泛地应用React,我们熟晓其优缺点。优点是代码的可维护性大大提高,性能卓然!但缺点也明显,由于体积太大,React.js+React-DOM.js超过3万行,体量过3MB,已经加上immutable.js , redux, redux-react, react-router等全家桶,工程师一行代码没有写,已经好几MB了。这在过去绝对不可想象,要知道,体积意味着流量,流量代码着金钱,越大越烧钱,越大下载速度越慢,用户体验越差,用户就会流失。现在问题摆在我们面前,我们就得解决。虽然webpack官网有各种瘦身方案,但瘦死的大象也比狗大。这是一个如此普遍存在的问题,因此外国人肯定也遇到过,思考过,提出什么新点子——所有这一切,都指向一个名词,“迷你React”。

作为一个生态圈成熟的标志,一个库出名了,就各种偏门补丁,闪光效果往上加,库难免会膨胀,不爽的人就会推出迷你化方案。上一个世代是jQuery与zepto。React的迷你方案也有不少,preact, inferno, react-lite, dio, rax……

但问题不是简单到直接从github找一个迷你React库替换上就能搞定。之所以称为迷你,肯定有所欠缺,如果是内部实现的改良也罢,如果阉割了功能可不是闹着玩。恰恰他们就好这口,因此现有方案不能满足我们。我们需要一个能直接替换,或至少95%的业务代码不用动。这意味着这迷你框架,需要与React的接口完全一致,并且全面支持它的全家桶。当然细化一下来的需来就更多了,早期说只要支持移动,因此取名为react-mobile,后来连iE8也要支持,因此这活不能指望别人,我们自己动手撸了。

我们先来一趟竞品分析。为了加快产出速度,能借鉴的尽可能借鉴。React推出以后,针对其性能研究衍生出不少库,针对其体积也诞生出大量仿品。它比jQuery更加缤纷多采。市面上竟然拥有100多个虚拟DOM库。虚拟DOM库,就是React出来后的一种新式库,以虚拟DOM与diff算法为核心,屏蔽DOM操作,操作数据即操作视图。这听起来有点像MVVM,MVVM也是屏蔽DOM操作,操作数据即操作视图,但它是以VM为核心。

React及其他虚拟DOM库已经将虚拟DOM的生成交由JSX与babel处理了,因此不同点是,虚拟DOM的结构与diff算法。虚拟DOM万宗不离其变是三大属性,type, props, children,当然也可以改一下别名,babel可以做相应配置。此外,虚拟DOM可以加入更多冗余标识,以帮diff算法的改良。

React最初推出时也不火,那时的招牌与现在性能不什么两样,也是高性能。但是JSX离经背道,与业界宣扬了多年的前后端分离,数据结构样式分离等教条差太远了,一直默默在角落里画圈。直到RN出来,解决原生编写界面的痛苦才一炮而红。大家才留意它的性能,它的性能背后的diff算法。最早研究React的diff算法是virtual-dom这个库,是基于经典的DFS算法。后来相应的算法就多起来。最后才是从接口进行模拟,就是所谓的React-like框架。因此虚拟DOM库是分为两大派系:算法派与拟态派。

下面将从这两大派系进行扼要的描述。

将前端回归到算法上的探索是前端框架史上的一个巨大进步。之前是MVVM将数据从繁复的DOM操作分离出来。正因为有了纯数据,并且数据结构可控,那么算法才有发挥的余地。

最开始出现的是 virtual-dom这个库,是大家好奇React为什么这么快而搞鼓出来的。它的实现是非常学院风格,通过深度优先搜索与in-order tree来实现高效的diff。它与React后来公开出来的算法是很不一样。

然后是cito.js的横空出世,它对今后所有虚拟DOM的算法都有重大影响。它采用两端同时进行比较的算法,将diff速度拉高到几个层次。

紧随其后的是kivi.js,在cito.js的基出提出两项优化方案,使用key实现移动追踪及基于key的编辑长度矩离算法应用(算法复杂度 为O(n^2))。

但这样的diff算法太过复杂了,于是后来者snabbdom将kivi.js进行简化,去掉编辑长度矩离算法,调整两端比较算法。速度略有损失,但可读性大大提高。再之后,就是著名的vue2.0 把sanbbdom整个库整合掉了。

当然算法派的老大是inferno,它使用多种优化方案将性能提至最高,因此其作者便邀请进react core team,负责react的性能优化了。这个我后面会详细。

再看拟态派。React的接口并不多,但是其组件的实现是相当有难度。它的生命周期是如何运作,需要对源码有深刻的理解,因此它们出来得比较晚。我们的学习对象也就是它们几个。

先说虚拟DOM。虚拟DOM就是一个普通的JS对象,通常拥有三个属性,type, props, children。但无状态组件出来后,children改放到props中。此外,有些元素还有ref属性,可以是字符串与函数。在数组里,为了提高diff速度,又多出了key属性。bable会将JSX这些属性转换为一个VNode对象。这是虚拟DOM的最小单元。所有虚拟DOM会组成一棵树,叫虚拟DOM树。

为了防止每次都是整个树进行diff,需要形成子树的概念,于是出现组件了。组件有render方法,会返回一个虚拟DOM,这个虚拟DOM及其子孙,就形成一棵子树。但render方法不是虚拟DOM的东西,于是我们规定当虚拟DOM为一个函数时,如果这个函数继承于React.Component,这个方法的实例必须有render方法。于是我们就像虚拟DOM与组件统合起来了。或者衍生这两个称呼,原子虚拟DOM与组件虚拟DOM。原子虚拟DOM对应元素节点,而组件则是用来产出原子虚拟DOM。此外,原子虚拟DOM还能包含一些东西,字符串与数字与null。字符串与数字对应文本节点,null对应注释节点。

一个组件虚拟DOM实例化为组件后,会返回原子虚拟DOM或另一个组件虚拟DOM。这就形成函数式编程上的高阶函数的机制,因此进行出无状态函数组件,就是虚拟DOM的type属性就是一个函数,不继承其他东西了。

组件虚拟DOM的实例化过程是非常复杂,如果能简化这过程,简化其结构,这性能就上去了。

此外组件的实例本身就巨耗性能,因此官方推荐页面的结构如下,通过最少量的有状态组件(smart component)控制无状态组件(dumb component)的变化,所有状态通过redux在路由进行分发。

经过一番比较后,我们着手开发自己的迷你React。这个过程也比较坎坷,这还是有前人参照物的情况下,可想而知,当初facebook开发出React这样一个独行特立的框架时,是多么艰辛。我们在这半年总共搞了三个东西,第一还孵化失败。

最初是基于react-lite,考虑时当时是我们母公司的人搞的,方便交流。但后来发现它的事件系统太鸡肋,难以扩展,最后放弃了。

第二代是基于preact,代号qreact, 国内也有许多公司基于它做自己的迷你化方案,因为官方提供了一个preact-compat的模块。但是preact-compat是使用Object.defineProperty来实现一些属性名映射与同步的,因此不支持IE8,并且使用了Object.defineProperty会严重拖慢速度。preact本身也有不少BUG,最著名的有三个,生命周期的unmount钩子不能保证在mount之前执行,元素节点的重复利用没有清理样式会导致出错,同一组孩子下可能存在同名的key导致排序失败。这些我们都为官方提issue,并且在我们的版本中进行修复了。第二代也公司内部几个项目中试水落地,反映不错。

第三代是我独力开发,anu或叫preact2.0。在对preact缝缝补补的过程,掌握不少核心知识,新的框架是使用全新的算法,全新的结构。由于不使用高级API,理论上能支持到IE6,但我们公司只需支持到IE8。

preact2.0使用requestAnimationFrame来稳定它的运行帧数,保证在60帧每秒的流畅速度。由于bable会对type进行打补丁,内部统一用typeNumber代替type进行类型判定。使用列队保证生命周期钩子按顺序执行。使用__rerender标识一个组件在一次大的更新只会被render一次。凡此种种,经过大量测试,它们的接口与React别无二致,甚至React废弃的接口createClass, PropTypes,我们都有相应的polyfill。

let  __type = Object.prototype.toString;
let numberMap = {
  //null undefined IE6-8这里会返回[object Object]
  "[object Boolean]": 2,
  "[object Number]": 3,
  "[object String]": 4,
  "[object Function]": 5,
  "[object Symbol]": 6,
  "[object Array]": 7
};
// undefined: 0, null: 1, boolean:2, number: 3, string: 4, function: 5, array: 6, object:8
export function typeNumber(data) {
  if (data === null) {
    return 1;
  }
  if (data === void 666) {
    return 0;
  }
  var a = numberMap[__type.call(data)];
  return a || 8;
}

下面是preact2.0的测试数据。
从性能上,直追preact。

从体积上,是官方react的20%。

这里透露一下React性能爆表的秘密,除了diff算法外,setState的合并操作也是一个关键。当组件没有插入到DOM树前,用户在componentWillMount方法多次执行setState,这些state是不会触发更新,而是存放到一个数组中,然后在render方法里进行合并与应用。当一个组件进行更新,可能是用户在componentDidMount或者事件回调执行setState,这时更新是即时的,同步的,但这之次再setState,它就不会更新了,它的state会进入列队。此外,如果用户在componentWillReceiveProps多次执行setState,也会产生延迟。React这种行为是保证页面的更新次数最少,同时用户不会察觉它没有更新。它只是将state进行了合并。qreact2.0是完全遵循了这些规则,从而实现了高性能。

而在体积上,则通过删除一些对线上没用的代码实现迷你化效果。

但仅是这样不足以大吹特吹了。为了超越React的性能,从inferno与preact借签了不少手段。

重复利用元素特指回收文本节点与注释节点,因为元素节点的回收需要做大量的清洗工作,比如移除事件,清空样式与类名,去掉自定义属性,工作太麻烦烦时,得不偿失。

最值得一提的是hydrate机制,通过合并相邻字符串,从而减少文本节点的生成,从而减少diff次数。

最后还有一个压轴大戏,不做测试不知道,原来高级API是如何耗性能。通过去掉Object.freeze, Object.defineProperty这些es5方法, 框架就有10帧的提升!

说了这么多,来些实际可运行的例子吧。目前,qreact/anu跑通内部几套测试,已经在金融与大搜的项目使用。它的第二版也在机票一个拥有820个模块的大项目中试水,在IE8下良好运行。

如果大家想在项目中使用qreact/anu,可以在webpack或ykit的config中如下配置:

如果大家对qreact/anu在感兴趣,欢迎与我联系,也欢迎大家为我的框架加星。

1 Like
#2

问题是如果真的要选择一个第三方实现,我为什么不选择用preact呢。

#3

我已经说了,preact就是算上preact-compat,就不完全兼容官方react,是有BUG的。我们早期的版本就是基于preact做的。认真看文章!

#4

对不起,确实没有完整看完。 我重看。

#6

感觉文档比react好多了:joy: