75

Preact:一个备胎的自我修养

 6 years ago
source link: https://zhuanlan.zhihu.com/p/30796007?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Preact:一个备胎的自我修养

前一段时间由于React Licence的问题,团队内部积极的探索React的替代方案,同时考虑到之后可能开展的移动端业务,团队目标是希望能够找到一个迁移成本低,体量小的替代产品。经过多方探索,Preact进入了我们的视野。从接触到Preact开始,一路学习下来折损了许多头发,也收获不少思考,这里想和大家介绍一下Preact的实现思路,也分享一下自己的思考所得。

Preact是什么

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

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

v2-8cca5ebd85d25698f1cd1cd5d2d57013_720w.jpg

长列表初始化时间对比

Preact的工作流程

简单介绍了Preact的前生今世以后,接下来说下Preact的工作流程,主要包含五个模块:

  • component
  • 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的工作流程之后,接下来会对上文提到的五个模块一一解读。

1. Component

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

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

hook函数

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

  • 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);
  }
}

2. 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>
    )
  }
}

3. 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;

4. Diff算法

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

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

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

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原链接

5. 回收机制

关键词:回收池&Enhanced Mount

回收池&Enhanced Mount

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

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

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


Recommend

  • 49
    • www.woshipm.com 5 years ago
    • Cache

    一个文案的自我修养

    文案是“纸上销售术”,是个“带着镣铐跳舞”的活。写文案时,别再当一个放飞自我的文艺青年了。 在没有做文案的时候,大多数人对文案都有...

  • 37

    对于一个支付产品经理而言,理解支付的相关名词,了解简单会计原理是入行的基本。 概述:该篇会从基础的角度介绍支付相关名词及简单会计原理,增强初入行支付产品经理对业务的理解,因此,从事金融、财会相关职业的读者可能会觉得浅显,可以直接跳过。 下篇将开始...

  • 66

    故事起源 本来今天想写.NET Core实战之CMS系统第十五篇文章的。哈,奈何今天在新生命人脉群里面看到石头哥分享的一张图片,然后大家就议论了起来,不过我看的很懵逼,这图什么意思啊?当一个朋友讲述了

  • 36
    • hellofrank.github.io 4 years ago
    • Cache

    一个程序员的自我修养

    混迹江湖多年,见过了太多的程序员。有天赋异禀的大牛,如周伯通和杨过一般的武学奇才。也有资质平庸的大牛,如郭靖一般,资质平庸但异常努力,稳扎稳打,最终成为一代宗师。 更见过许多PPT架构师,嘴炮程序员。这类人凭借名校出...

  • 16
    • www.uisdc.com 4 years ago
    • Cache

    一个UI按钮的自我修养

    编者按:一个合格的 UI 按钮到底需要具备什么样的素质?在很多设计师眼里似乎并不是一个太大的问题的,但是在实际设计的时候,...

  • 4
    • yanhaijing.com 3 years ago
    • Cache

    一个Mac小白的自我修养

    原文网址:http://yanhaijing.com/mac/2017/07/19/my-mac-note/ 作为一个前端,坚守了9年的windows平台,也是惭愧,...

  • 1
    • wiki-power.com 2 years ago
    • Cache

    一个舵机的自我修养

    一个舵机的自我修养如何把一个舵机改装成 360° 连转舵机,以及用代码解决杂音?

  • 5

    《黑客与画家》--- 一个程序员的自我修养 一本断断续续看了好久,看了好几遍的书,被作者身上的那份自信和独立深刻的思考所吸引。

  • 9
    • www.tmtpost.com 2 years ago
    • Cache

    一个场记的自我修养

    文 | 毒眸,作者 | 张颖,编辑 | 赵普通电影是集体的艺术。每一颗小小的螺丝钉都坚固地运转,才让大银幕的光照到更远的地方。过去几年,我们和电影从业者们走得很近,透过他们的眼睛,看到了电影行业鲜活生动的模样。也...

  • 3
    • www.ikanchai.com 2 years ago
    • Cache

    一个“品”的自我修养

    一个“品”的自我修养 营销大师科特勒是这样说市场营销的:"市场营销是个人或群体通过创造、提供,同他人交换有价值的产品,以满足各自的需要和...

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK