Preact:一个备胎的自我修养

原发于知乎专栏:zhuanlan.zhihu.com/ne-fenode

前一段时间因为React Licence的问题,团队内部积极的探索React的替代方案,同时考虑到以后可能开展的移动端业务,团队目标是但愿可以找到一个迁移成本低,体量小的替代产品。通过多方探索,Preact进入了咱们的视野。从接触到Preact开始,一路学习下来折损了许多头发,也收获很多思考,这里想和你们介绍一下Preact的实现思路,也分享一下本身的思考所得。react

Preact是什么

一句话介绍Preact,它是React的3KB轻量替代方案,拥有一样的ES6 API。若是以为就这么一句话太模糊的话,我还能够再啰嗦几句。Preact = performance + react,这是Preact名字的由来,其中一个performance足以窥见做者的用心。下面这张图反映了在长列表初始化的场景下,不一样框架的表现,能够看出Preact确实性能出众。git

高性能,轻量,即时生产是Preact关注的核心。基于这些主题,Preact关注于React的核心功能,实现了一套简单可预测的diff算法使它成为最快的虚拟 DOM 框架之一,同时preact-compat为兼容性提供了保证,使得Preact能够无缝对接React生态中的大量组件,同时也补充了不少Preact没有实现的功能。
github

长列表初始化时间对比
长列表初始化时间对比

Preact的工做流程

简单介绍了Preact的前生今世之后,接下来讲下Preact的工做流程,主要包含五个模块:算法

  • component
  • h函数
  • render
  • diff算法
  • 回收机制

流转过程见下图。缓存

首先是咱们定义好的组件,在渲染开始的时候,首先会进入h函数生成对应的virtual node(若是是JSX编写,以前还须要一步转码)。每个vnode中包含自身节点的信息,以及子节点的信息,由此而连结成为一棵virtual dom树。基于生成的vnode,render模块会结合当前dom树的状况进行流程控制,并为后续的diff操做作一些准备工做。Preact的diff算法实现有别于react基于双virtual dom树的思路,Preact只维持一棵新的virtual dom树,diff过程当中会基于dom树还原出旧的virtual dom树,再将二者进行比较,并在比较过程当中实时对dom树进行patch操做,最终生成新的dom树。与此同时,diff过程当中被卸载的组件和节点不会被直接删除,而是被分别放入回收池中缓存,当再次有同类型的组件或节点被构建时,能够在回收池中找到同名元素进行改造,避免从零构建的开销。
闭包

Preact工做流程图
Preact工做流程图

在了解了Preact的工做流程以后,接下来会对上文提到的五个模块一一解读。

Component

关键词:hook, linkState, 批量更新app

相信有过react开发经验的同窗对component的概念都不会陌生,这里也不作过多解释,只是介绍一些Preact在component层面上的添加的新特性。框架

hook函数

除了基本的生命周期函数外,Preact还提供三个hook函数,方便用户在指定的时间点执行统一操做。dom

  • afterMount
  • afterUpdate
  • beforeUnmount

linkState

linkState针对的场景是在render方法中为用户操做的回调绑定this,这样每次渲染都在局部建立一个函数闭包,这样效率十分低下并且会迫使垃圾回收器作许多没必要要的工做。linkState理想中的应用场景以下。

export default class App extends Component {
  constructor() {
    super();
    this.state = {
      text: 'initial'
    }
  }

  handleChange = e => {
    this.setState({
      text: e.target.value
    })
  }

  render({desc}, {text}} {
    return (
      <div> <input value={text} onChange={this.linkState('text', 'target.value')}> <div>{text}</div> </div> ) } }复制代码

然而linkState的实现方式。。。是在组件初始化的时候为每一个回调建立闭包,绑定this,同时建立一个实例属性将绑定后回调函数缓存起来,这样再次render的时候就不须要再次绑定。实际效果等同于在组件的constructor中绑定。尴尬之处在于,linkState内部只实现了setState操做,同时也不支持自定义参数,使用场景比较有限。

//linkState源码
//缓存回调
linkState(key, eventPath) {
  let c = this._linkedStates || (this._linkedStates = {});
  return c[key+eventPath] || (c[key+eventPath] = createLinkedState(this, key, eventPath));
}

//首次注册回调的时候建立闭包
export function createLinkedState(component, key, eventPath) {
  let path = key.split('.');
  return function(e) {
    let t = e && e.target || this,
      state = {},
      obj = state,
      v = isString(eventPath) ? delve(e, eventPath) : t.nodeName ? (t.type.match(/^che|rad/) ? t.checked : t.value) : e,
      i = 0;
    for ( ; i<path.length-1; i++) {
      obj = obj[path[i]] || (obj[path[i]] = !i && component.state[path[i]] || {});
    }
    obj[path[i]] = v;
    component.setState(state);
  };
}复制代码

批量更新

Preact实现了组件的批量更新,具体实现思路就是每次执行state or props更新之时,对应的属性会被马上更新,可是基于new state or props的渲染操做会被push进到一个更新队列中,在当前event loop的最后或者是在下一个event loop的开始,才会将队列中的操做一一执行。同一个组件状态的屡次更新,不会重复进入队列。以下图所示,属性更新以后,组件渲染以前,_dirty值为true,所以,组件渲染以前后续的属性更新操做都不会使组件重复入队。

//更新队列源码
export function enqueueRender(component) {
  if (!component._dirty && (component._dirty = true) && items.push(component)==1) {
    (options.debounceRendering || defer)(rerender);
  }
}复制代码

h函数

关键词:节点合并

h函数的做用如同React.CreateElement,用于生成virtual node。其接受的输入格式以下,三个参数分别为节点类型,节点属性,子元素。

h('a', { href: '/', h{'span', null, 'Home'}})复制代码

节点合并

h函数在生成vnode的过程当中,会对相邻的简单节点进行合并操做,目的是为了减小节点数量,减轻diff负担。 请看下面的例子。

import { h, Component } from 'preact';
const innerinnerchildren = [['innerchild2', 'innerchild3'], 'innerchild4'];
const innerchildren = [
  <div>
    {innerinnerchildren}
  </div>,
  <span>desc</span>
]

export default class App extends Component {
  render() {
    return (
      <div>
        {innerchildren}
      </div>
    )
  }
}复制代码

Render

关键词:流程控制,diff准备

首先先解释一下,这里的render模块泛指整个流程中将vnode插入到dom树中的操做,然而这类操做中又有一部分工做被diff模块承担,因此实际上render模块的更多承担的是流程控制以及进入diff的前置工做。

流程控制

所谓流程控制,具体的内容分为两部分,节点类型的判断,是自定义的组件仍是原生的dom节点,渲染类型的判断,是首次渲染仍是更新操做。根据不一样状况,指定不一样的渲染路线,执行相应的生命周期方法,hook函数和渲染逻辑。

Diff准备

如前所述,Preact在内存中只维持一棵包含更新内容的新的virtual dom树,另外一个表明被更新的旧的virtual dom树其实是从dom树还原回来的,与此同时,dom树的更新操做也是在比较过程当中,一边比较一边patch的。为了确保上述操做不出现混乱,在生成/更新的dom树的以前,须要在dom节点上添加一些自定义的属性记录状态。

//建立自定义属性记录
export function renderComponent(component, opts, mountAll, isChild) {
  if (component._disable) return;

  let skip, rendered,
    props = component.props,
    state = component.state,
    context = component.context,
    previousProps = component.prevProps || props,
    previousState = component.prevState || state,
    previousContext = component.prevContext || context,
    isUpdate = component.base,
    nextBase = component.nextBase,
    initialBase = isUpdate || nextBase,
    initialChildComponent = component._component,
    inst, cbase;复制代码

Diff算法

关键词:DOM依赖,Disconnected or Not,DocumentFragment

diff过程主要分为两个阶段,第一个阶段是创建virual node与dom节点之间的对应关系,第二个阶段即是对二者进行比较并更新dom节点。

  • 在实际执行过程当中,diff操做的起点是update组件的根节点与表明其下一个状态的vnode以前的比较。这一步中二者之间的对应关系十分明确,而到了下一步,则须要在二者的子元素中肯定对应关系,具体的方法是首先对相同key值的子节点配对,以后将同类型的节点配对,最后没有被配对的vnode视为新添加的节点,而落单的dom节点的命运则是被回收。
  • 进入到更新阶段以后,会根据virtual node的类型和dom树中参照节点的状况分类处理,并在diff的过程当中实时的进行patch操做,最终生成新的dom节点,而后对子节点递归。
    Diff流程图
    Diff流程图

DOM依赖

通过前面的介绍,相信你们对Preact的virtual dom实现已经有了必定的了解,这里再也不赘述。这种实现方式,优势在于总能真实的反映以前virtual dom树的状况,缺点就是存在内存泄露的风险。

Disconnected or Not

  • What does Disconnected mean

咱们都知道,当咱们向dom树中的节点执行appendChild,removeChild操做的时候,每执行一次,就会触发一次页面的reflow,这是一个具备至关开销的行为。所以当咱们必须执行一系列这样的操做的时候,能够采起这样的优化手段,首先建立一个节点,在这个节点上执行过全部子节点的append操做以后,再将以这个节点做为根节点的子树一次性的append或者replace到dom树中,只触发一次reflow,就完成了整个子树的更新,这样的更新方式称之为disconnected。

与之相对,在建立节点以后,马上将节点插入到dom树中,而后继续进行子节点的操做,则称之为connected。

  • Go ahead to Preact

在阐明了这个前提以后,再来看Preact的实现方式,Disconnected or Connected,是一座围城。尽管做者声称Preact的渲染方式是disconnected,然而事实的真相是,not always true。 从一个简单的状况提及,textnode的值被修改或者旧的节点被替换成textnode。Preact所作的就是建立一个textnode或者修改以前textnode的nodeValue。虽然纠结这个场景是没有意义的,可是为了完整的介绍diff流程,有必要先说明一下。 进入重点。先看第一个例子。为了说明问题,咱们用一个稍微极端点的例子。

在这个例子中能够看到,当输入text以后,有一个div子树向section子树的更新,这里为了描述一个极端状况,更新先后的子节点是同样的。

//例一 placeholder所在子树只有根节点不一样
import { h, Component } from 'preact';

export default class App extends Component {
  constructor() {
    super();
    this.state = {
      text: ''
    }
  }

  handlechang = e => {
    this.setState({
      text: e.target.value
    })
  }

  render({desc}, { text }) {
    return (
      <div> <input value={text} onChange={this.handlechang}/> {text ? <section key='placeholder'> <h2>placeholder</h2> </section>: <div key='placeholder'> <h2>placeholder</h2> </div>} </div> ) } }复制代码

接下来看一下针对这种场景,diff操做的详细流程。

//原生dom的idiff逻辑
let out = dom,  //注释1
  nodeName = String(vnode.nodeName),
  prevSvgMode = isSvgMode,
  vchildren = vnode.children;

isSvgMode = nodeName==='svg' ? true : nodeName==='foreignObject' ? false : isSvgMode;

if (!dom) {  //注释2
  out = createNode(nodeName, isSvgMode);
}
else if (!isNamedNode(dom, nodeName)) {  //注释3
  out = createNode(nodeName, isSvgMode);
  while (dom.firstChild) out.appendChild(dom.firstChild);
  if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
  recollectNodeTree(dom);
}

//子节点递归
……
else if (vchildren && vchildren.length || fc) {
  innerDiffNode(out, vchildren, context, mountAll);
}
……复制代码

不管参与diff的元素是自定义组件仍是原生dom,通过层层解构,最终都是以dom的形式进行比较。所以咱们只须要关注原生dom的diff逻辑。

首先看注释1的位置,dom表示dom树上的节点,也就是要被更新掉的节点,vnode就是待渲染的虚拟节点。在例一中,diff的起点就是最外层的div,也就是第一轮的dom变量,所以注释2注释3处的断定均为false。以后会对out节点的子节点和对应的vnode的子节点进行递归的diff操做。

那么这里首先说明了第一处问题,渲染操做的起点始终是connected状态的

if (vlen) {
  for (let i=0; i<vlen; i++) {
    vchild = vchildren[i];
    child = null;

    let key = vchild.key;
    // 相同key值匹配
    if (key!=null) {
      if (keyedLen && key in keyed) {
    child = keyed[key];
    keyed[key] = undefined;
    keyedLen--;
      }
    }
    // 相同nodeName匹配 
    else if (!child && min<childrenLen) {
      for (j=min; j<childrenLen; j++) {
    c = children[j];
    if (c && isSameNodeType(c, vchild)) {
      child = c;
      children[j] = undefined;
      if (j===childrenLen-1) childrenLen--;
          if (j===min) min++;
      break;
    }
      }
    }
    // vnode为section节点时,dom树中既无同key节点,也无同nodeName节点,所以为null
    child = idiff(child, vchild, context, mountAll);
……复制代码

子节点之间的对应关系的确立依据,要么key值相同,要么nodeName相同,能够知道section和div的关系并不知足上述两种状况。所以当再次进入idiff方法的时候,在注释2的位置,因为dom不存在,会新建一个section节点赋给out,这样再次进行子元素diff的时候,因为out是一个新建节点,不包含任何子元素,section的全部子元素diff的对象都是null,这就意味这section的全部子元素最后都是被新建出来的(不管是否设置了key值),尽管它们和旧的dom上的节点如出一辙。。。因此总结一下就是例一这种状况,section全部的子节点都是被新建出来的,而不是被复用的,可是整个操做过程是在disconnected状况下进行的

那么若是给二者加上相同的key值呢?

// 例二,组件结构相同,惟一的区别是placeholder所在子树添加了相同的key值
import { h, Component } from 'preact';

export default class App extends Component {
  constructor() {
    super();
    this.state = {
      text: ''
    }
  }

  handlechang = e => {
    this.setState({
      text: e.target.value
    })
  }


  render({desc}, { text }) {
    return (
      <div> <input value={text} onChange={this.handlechang}/> {text ? <section key='placeholder'> <h2>placeholder</h2> </section>: <div key='placeholder'> <h2>placeholder</h2> </div>} </div> ) } }复制代码

由于二者具备相同的key值,因此在vnode与dom肯定对应关系时能够成功的配对,进入diff环节。然而一个replace操做又让后续的全部操做都变成了connected。好消息是相同的子节点被复用了。

// 原生dom的diff逻辑
// dom节点,即div存在,且与vnode节点类型section不一样类型
else if (!isNamedNode(dom, nodeName)) {
  out = createNode(nodeName, isSvgMode);
  while (dom.firstChild) out.appendChild(dom.firstChild);
  if (dom.parentNode) dom.parentNode.replaceChild(out, dom);
  recollectNodeTree(dom);
}复制代码

DocumentFragment

除去上面介绍过的disconnected方法,还能够经过DocumentFragment将一系列节点一次性插入dom。DocumentFragment 节点插入文档树时,插入的不是 DocumentFragment 自身,而是它的全部子孙节点。这使得 DocumentFragment 成了有用的占位符,暂时存放那些一次插入文档的节点。github上也有人向做者提出了一样的问题,做者表示他曾经也尝试过用DocumentFragment的方式试图减小reflow的次数,然而最终的结果却使人意外。

上图为做者编写的测试案例的性能对比图,横坐标为Operation per second,数值越大表明执行效率越高。能够看出不管connected仍是disconnected的状况,DocumentFragement的表现都更差。具体缘由还有待考究。BenchMark原连接

回收机制

关键词:回收池&Enhanced Mount

回收池&Enhanced Mount

在将节点从dom中移除时,不会将节点直接删除,而是会根据节点类型(组件 or node),执行一些清理逻辑以后,分别存入到两个回收池中。在每次执行Mount操做的时候,建立方法会在回收池里寻找同类型节点,一旦找到这样的同类节点,它会被做为待更新的参照节点传入diff算法中,这样再后续的比较过程当中,来自回收池的节点会被做为原型进行patch改造,产生新的节点。至关于变Mount为Update,从而避免从零构建的额外开销。

现实的结局每每没有童话故事般美好,回收机制最终仍是出现了意外。案发现场传送门,回收机制会在某些状况下致使节点被错误的复用……因此,如同发炎的阑尾,可能很快回收机制就会从咱们的视线里消失了。

结束语

本文着重介绍了Preact的工做流程以及其中各个模块的一些工做细节,但愿能够达到抛砖引玉的做用,吸引更多的人参与到社区的交流中来。对于文章所谈及内容感兴趣的朋友欢迎随时找我交流,若是线上交流有欠畅爽的话,能够把简历投到colaz1667@163.com。我能想到最浪漫的事就是和你一路收藏点点滴滴的欢笑,留到之后,坐在工位上,慢慢聊。

相关文章
相关标签/搜索