Usw 发布的文章

原文地址:

Debouncing and Throttling Explained Through Examples | CSS-Tricks

本文中斜体部分为阅读时的个人理解说明,请注意区分。并且代码中使用的debounce函数和throtte函数均来自于lodash库。

Debounce(bounce意为弹跳,de前缀表示”否定“,所以被称为防抖)和Throttle(作名词讲意为”节流阀“,指控制油料进入发动机量大小的装置)是两种极其相似却又大不相同的技术,用于控制一个函数随着时间的流失执行多少次。

将业务函数的防抖版本或节流版本用在处理DOM事件上是非常有用的。为什么?因为这样可以在事件机制和业务函数执行之间进行控制。毕竟无法那些被动触发的DOM事件的评率不在我们的控制范围内。

举个例子:滚动事件

var i = 0;
var $counter = $('#counter');
$(document).ready(function(){
  $(document).on('scroll', function(){
    $counter.html(i);
    i++; 
  });
});

当使用触控板,鼠标滚轮或者拖拽滚动条进行滚动操作是,每秒将会触发30个滚动事件。但是在手机上,即使是缓慢的滚动(滑动屏幕),每秒也会触发多达100个事件。你的滚动处理函数能够应对这样的执行速率吗?

2011年,推特的网站上出现一个问题:当你不断滚动信息流,网页会变得越来越慢无法响应。John Resig在文章《a blog post about the problem》中阐释了,将一个执行代价很高的函数直接绑定到scroll事件,是一件多么愚蠢的做法。

在那篇文章中,John建议的解决方案是:在onScroll event以外使用一个每250ms执行一次的循环。这种方式实现handler和event的解耦合。通过这种简单的技术,可以避免给用户带来的糟糕体验。

最近又出现了一些稍微复杂一点的方式处理事件。让我来介绍一下Debounce,Throttle和rAF。我们还将研究具体的使用场景

Debounce

防抖技术允许我们将多个顺序的调用,聚合到单次调用。

https://i.loli.net/2020/07/28/DN6pv2uiQ1hGCxT.png

想象你在一个电梯里。电梯门开始关闭,突然另一个人想要进入电梯。电梯不能进行工作去别的楼层,电梯门重新打开。又有另一个人要上电梯,这样的事情还会发生。虽然电梯的行动(上下楼)被突然上人延迟了,但是明显优化了资源利用。

这是一个mousemove事件的例子:

https://codepen.io/dcorb/pen/KVxGqN

你能看到单个防抖事件,是如何代替快速发生的顺序事件的。如果事件发生的间隔事件,超过防抖设置的延迟,那么就不会进行(也没必要进行)防抖处理。

在进行下面的文章之前,要插入介绍一下lodash中debounce函数:

*_.debounce(func, [wait=0], [options={}])*

options的三个配置说明如下:

  • leading:默认false,指定在延迟开始前调用
  • maxWait:允许被延迟的最大值。也就是说抖动持续超过这个时间,函数还是要被触发的
  • trailing:默认true,指定在延迟结束后调用

Leading属性(或者“立即”)

你会发现令人头疼的一点,在触发函数执行之前,防抖事件总是等待,直到事件不再快速的重复发生。为何不立即触发让函数执行,让它的行为和非防抖handler一样?而是在快速的重复调用过程中不会触发,直到出现一个较长的时间间隔再触发。

你当然可以值么做!设置leading选项:

https://i.loli.net/2020/07/28/XKYeLf1H7qdMhBy.png

上面的例子可以理解成防抖处理的函数,在抖动开始时就执行一次,后续的抖动过程中不再执行。没有leading选项(默认trailing选项)时,就相当于抖动结束了后再执行。而抖动结束的判断条件是第二个参数决定的延迟。

https://codepen.io/dcorb/pen/GZWqNV

防抖的实现

我(原文作者)第一次看到JavaScript版本防抖的实现,是在2009年John Hann的博文中(原文已经找不到了),防抖的概念也是从这篇文章开始的。之后很快,Ben Alman开发了一个jQuery插件,再然后,Jeremy Ashkenas将其添加的underscore.js库中。再之后又被加入到lodash库中。这三种实现(jQuery插件,underscore.js和lodash)内部有细微的不同,但是对外暴露的接口几乎一致。

之前有一段时间underscore.js采纳了lodash的防抖/节流实现,但是在2013年,我发现了_.debounce的一个bug(这个bug也无从查证了),从那之后,两个库的实现方式彻底分开。

lodash的debounce函数和throttle函数增加了很多特性,比如leading属性和trailing属性联合取代了immediate属性,默认是trailing模式,但也可以设置成头尾都执行。另外增加了maxWait选项,很有用,实际上节流函数就是借助debounce+maxWait实现的。

源码分析单独写一篇文章,这里不做过多的介绍

防抖的应用场景

resize事件

当调整浏览器窗口尺寸时,会频繁触发resize事件,用于处理拖动过程。

$(window).on('resize', function(){
    display_info($left_panel);
  });
  
$(window).on('resize', _.debounce(function() {
  display_info($right_panel);
}, 400));

使用了默认的trailing模式,因为只关心resize的最终结果。从打印结果上不难发现,不使用防抖,会打印出很多拖拽过程中的尺寸信息,假如有一个业务逻辑需要根据尺寸重新布局,或者进行复杂的计算,一方面可能造成卡顿,一方面执行的结果可能只会展示一瞬间, 对用户来说也许并没有意义。

键入信息与异步请求自动补全

用户持续输入时,为何还要每50ms发送一次异步请求?防抖避免了输入过程中不必要的网络工作,当用户结束输入延迟一段时间后(认为是抖动结束)才请求数据。在这个业务中,使用leading模式将导致无法按照预期工作,因为我们关注的是用户输入结束后输入的内容,而不是输入的过程。

如何使用防抖和节流,以及常见错误

作者推荐使用lodash或者underscore.js库提供的相关工具函数,这里略过

常见的错误:重复调用debounce函数。debounce函数用于产生一个防抖版的handler,将这个防抖版的handler绑定到事件处理,而不是在事件处理中调用debounce。

// WRONG
$(window).on('scroll', function() {
   _.debounce(doSomething, 300); 
});

// RIGHT
$(window).on('scroll', _.debounce(doSomething, 200));

根据lodash的文档说明,debounce创建的函数带有cancel函数,可以用于取消防抖限制:

var debounced_version = _.debounce(doSomething, 200);
$(window).on('scroll', debounced_version);

// If you need it
debounced_version.cancel();

Throttle

使用节流技术,我们不允许我们的函数每X毫秒执行超过1次。节流和防抖的主要区别,是节流保证了函数是周期执行的。(同样是应对”抖动“场景,防抖意味着不抖动了在执行,忽略所有抖动过程;节流意味着通过自己定义一个频率,让无规律的抖动变成有规律的”抖动“,即可以关注抖动过程中的某些状态,也可以减少多余的工作量

节流的应用场景

无极滚动

无极滚动是一个很常见的场景。用户在你的页面上会不断的向下滚动。你需要检查用户滚动离底部的距离。如果用户滚动接近了底部,我们就需要请求更多的数据追加到页面上。这个时候防抖显然不那么合适。防抖只会在用户停止滚动后触发一次,但需求是在到达底部之前就要准备好更多数据。使用节流我们可以保证是在持续不断的检查到底部的距离。

// Very simple example.
// Probably you would want to use a 
// full-featured plugin like
// https://github.com/infinite-scroll/infinite-scroll/blob/master/jquery.infinitescroll.js
$(document).ready(function(){
  
  // Check every 300ms the scroll position
  $(document).on('scroll', _.throttle(function(){
    check_if_needs_more_content();
  }, 300));

  function check_if_needs_more_content() {     
    pixelsFromWindowBottomToBottom = 0 + $(document).height() - $(window).scrollTop() -$(window).height();
    
  // console.log($(document).height());
  // console.log($(window).scrollTop());
  // console.log($(window).height());
  //console.log(pixelsFromWindowBottomToBottom);
    
    
    if (pixelsFromWindowBottomToBottom < 200){
      // Here it would go an ajax request
      $('body').append($('.item').clone()); 
      
    }
  }

requestAnimationFrame (rAF)

requestAnimationFrame是另一种限制函数执行频率的方式。它可以被认为是_.throttle(dosomething, 16) 。因为它是浏览器为了更好的准确性而提供的原生的API,所以保真度更高。我们可以使用rAF作为节流方式,它具有以下优缺点:

优点

  • 针对浏览器60fps渲染频率,每一帧16ms,理想情况下是每一帧执行一次,但实际上浏览器还是会选择合适的时机执行。
  • 相当简单且标准的API,未来也不一定会有大的变更,可以减少维护量。

缺点

  • rAF的启停都必须手动处理,而防抖和节流都在内部通过定时器进行管理。
  • 如果浏览器标签页处于未激活状态,rAF是不会执行的。对于滚动或者鼠标移动等行为,这一点并不影响。
  • IE9,Opera Mini和老版本的安卓不提供该API。可能需要polyfill。
  • rAF仅限于浏览器环境,node.js无法使用,无法使用rAF来节流服务端文件系统事件。

根据经验,如果你的JavaScript函数是用于绘制或者动画,那么我推荐使用rAF,对于那些包含重新计算元素位置的操作都很适用。而为了进行Ajax请求,或者决定添加/移除样式名(这可能会引起CSS动画),我会考虑使用debounce和throttle,这样你可以设置一个比较低的执行频率。

rAF的应用场景

https://codepen.io/dcorb/pen/pgOKKw

通过一个滚动的例子,对比节流和rAF的效果。由于场景比较简单,所以感觉不出有什么差异,但是在复杂的场景下,可能rAF有更加精确地表现。

总结

使用防抖,节流和rAF都可以优化你的事件处理。每一个技术都有些许差异,但是他们都很有用,并且可以相互补充。

总而言之:

  • 防抖:将一系列突发的事件爆发(比如连击按键)聚合成一个单独的事件。
  • 节流:保证了一个周期为X毫秒的持续的执行流。比如每200ms检查一次滚动位置。
  • rAF:可选的节流方案。当你的函数涉及到重绘和渲染,且想要一定的流畅性或者动画,可以使用rAF。注意不支持IE9。

写在最后:当有人问你一个问题,你回答到一半又问了你一个问题,你停下来回答新的问题,到一半时又被问了第三个问题...每个问题可能都没有答完,这样你整个人的精神状态就可以被称为”抖动“。防抖,也就是拒绝抖动,那我可以在回答问题之前稍微等一下,看你会不会再问下一个问题,要是问了我就干脆不回答了,等到我发现你不会突然打断我了,我再回答问题。这样就不会出现话说一半的尴尬。节流,降低抖动的频率,一个问题都不回答好像也有一些不礼貌,那我就自己心里默念,每过10个数进行一次回答。简单而形象的理解,具体实现可能还有细节,至少这样不会搞得太混。Peace

基于这篇文章学习讨论Fiber,基本为翻译内容

Dive deep into React's new architecture called Fiber and learn about two main phases of the new reconciliation algorithm. We'll take a detailed look at how React updates state and props and processes children.

React是一个用于构建用户界面的JavaScript库。核心机制(change detection有专门的文章介绍)是追踪(tracks)组件状态变化并将其更新到显示界面上。React中这个过程通常被称为“协调”(reconciliation),调用setState 方法,框架检查state或者props是否变更,然后将组件重新渲染到UI。

React文档提供了一篇文章,从较高的层面概述了这种机制:React元素的地位,生命周期方法与render方法,以及diffing算法如何应用到子组件。render方法返回一颗不可变(immutable)的,由React元素组成的树,也就是俗称的“虚拟DOM”(之前的文章也提到过,React不再使用“虚拟DOM”这个概念)。这一术语早期被用来辅助解释说明React体系,但也引起一些困惑,现在已不再使用。下文将使用“React元素树”来表达。

除了React元素树,框架也总持有一个由内部实例(组件,DOM节点等)构成的树,用于保存状态。从v16开始,React推出了Fiber—重新实现了内部实例树的组成,以及相应的管理算法。了解Fiber架构带来的优点,请查阅The how and why on React’s usage of linked list in Fiber这篇文章(稍后再来学习这篇文章)。

本文是系列文章的第一篇,目标是介绍React的内部结构。这篇文章将提供一个深度的概述,包括重要的概念和算法相关的数据结构。当有了足够的背景知识,再讨论具体算法和用于遍历处理fiber树的主要函数。系列的下一篇文章会演示(demonstrate)React如何使用算法执(perform)行初始化渲染,并处理state和props更新,从中将深入到调度器的细节,子协调进程,和构建副作用列表的机制。

先介绍一些高级知识,强烈建议阅读,理解并发(Concurrent)React内部工作背后的技巧。如果你希望参与到React的开发,本系列文章将给你提供很好的指引。作者是一个逆向工程师,所以本文会有很多链接到新版本(v16.6.0)源码的内容。

吸收理解肯定吃力,但是不要觉得有压力。花费的精力肯定是值得的。注意:你不需要了解本文内容也可以使用React,本文是关于React内部的工作机制。

Setting the background 背景知识

一个简单的计数器应用:

class ClickCounter extends React.Component {
    constructor(props) {
        super(props);
        this.state = {count: 0};
        this.handleClick = this.handleClick.bind(this);
    }

    handleClick() {
        this.setState((state) => {
            return {count: state.count + 1};
        });
    }

    render() {
        return [
            <button key="1" onClick={this.handleClick}>Update counter</button>,
            <span key="2">{this.state.count}</span>
        ]
    }
}

render方法返回这个简单的组件,包含两个子元素buttonspan。一点按钮,组件的状态就在内部处理器被更新。反过来,结果以文本的形式更新到 span元素内。

协调过程中React执行了很多动作。比如,第一次渲染过程以及状态更新后,执行了很多高级操作:

  • 更新ClickCounter组件状态state中的 count属性
  • 检索(retrieves)并比较ClickCounter的子组件及其props
  • 更新span元素的props(innerHtml算作span元素的props)

当然在协调过程中还有其他的动作(activities)被执行,比如生命周期或者引用(refs)更新。所有这些动作,在Fiber架构中,被统一称为“work”(暂时译作“作业”)。作业的类型通常依赖于React元素的类型,比如对于类组件来说,React要创建一个实例,函数组件不需要。如你所知,React元素有很多种,类组件、函数组件以及宿主组件(host components,浏览器环境对应DOM节点)以及传送门(portals)等。React元素的类型有createElement函数的第一个参数决定。这个函数通常用于render方法中创建元素。

在开始探究fiber主要算法之前,先熟悉一下内部使用的数据结构。

From React Elements to Fiber nodes 从React元素到Fiber节点

React中每一个组件都代表一个具体的UI,通常称为“视图”或者“有render方法返回的模板”。上面的例子转换成模板可以表达为:

<button key="1" onClick={this.onClick}>Update counter</button>
<span key="2">{this.state.count}</span>

React Elements React元素

当模板被JSX编译器处理后,会得到一簇(a bunch of)React元素。这是React组件的render方法实际返回的内容,并不是HTML片段。JSX不是必须的,所以ClickCounter组件的render方法也可以被重写为以下形式:

class ClickCounter {
    ...
    render() {
        return [
            React.createElement(
                'button',
                {
                    key: '1',
                    onClick: this.onClick
                },
                'Update counter'
            ),
            React.createElement(
                'span',
                {
                    key: '2'
                },
                this.state.count
            )
        ]
    }
}

组件的render方法中调用的React.createElement会创建两个数据结构,如下:

[
    {
        $$typeof: Symbol(react.element),
        type: 'button',
        key: "1",
        props: {
            children: 'Update counter',
            onClick: () => { ... }
        }
    },
    {
        $$typeof: Symbol(react.element),
        type: 'span',
        key: "2",
        props: {
            children: 0
        }
    }
]

React使用$$typeof属性来标识它们是React元素。然后是typekeyprops 字段用于描述组件的实际情况,具体值由React.createElement函数参数决定。React将文本内容视为spanbutton节点的子节点(children)。然后点击处理器是button元素props的一部分。React元素还有一些其他的字段不在本文讨论的内容,比如ref

ClickCounter组件本身对应的React元素没有props(从组件声明上可以看出并没有接受显式的props)和key值:

{
    $$typeof: Symbol(react.element),
    key: null,
    props: {},
    ref: null,
    type: ClickCounter
}

Fiber nodes Fiber节点

协调过程中,每一个React元素render方法返回的数据,被合并(merged into)到fiber节点的树。每一个React元素都有一个对应的fiber节点。不像React元素,fiber们不会在每次渲染时重新创建。这些可变数据结构持有组件状态和DOM。

之前讨论到,React依据元素类型执行不同的动作。在之前的例子中,对于ClickCounter类组件,会调用其生命周期方法以及render方法,而对span这样的宿主元素(浏览器环境下DOM节点),将执行DOM操作。所以每个React元素被转化为对应类型的Fiber节点,这个类型就决定了需要完成的作业。

你可以将fiber想成这样的数据结构:代表了一些需要执行的作业,或者说一个作业单元(a unit of work),也就是作业的基本单位。Fiber架构提供了方便的方式进行追踪、调度、暂停和放弃。

当一个React元素一开始被转化为fiber节点,React在createFiberFromTypeAndProps函数中,使用元素的数据创建一个fiber。在随后的(consequent)更新中,React重用fiber节点,使用对应的React元素的数据,只更新必须的属性。在key属性基础上,同层次可能只需要移动节点,或者对应的React元素不再返回数据时删除节点。

可以查看ChildReconciler函数,包含了具体的动作(activities)以及相应的函数。这里简单的列举一些,源码可以查看这里

deleteChild,deleteRemainingChildren,mapRemainingChildren,placeChild,placeSingleChild,updateTextNode,updateElement,updatePortal,updateFragment,createChild,updateSlot,updateFromMap等。

React为每一个React元素创建fiber,而React元素以树型结构相互关联,因此也会得到一个fiber节点构成的树。在ClickCounter的例子中,就会得到如下的树形关系:

https://i.loli.net/2020/05/11/DEXPLgfJ7xM1kWZ.png

由于ClickCounter也会被渲染到某个节点,所以这里用HostRoot来代替。所有的fiber节点,通过childsiblingreturn属性相互联系,形成一个链表结构。更多细节请查看The how and why on React’s usage of linked list in Fiber这篇文章(后续应该也会翻译)。

Current and work in progress trees 当前和处理中树

首次渲染后,React得到一个fiber树,映射了应用用于渲染视图的状态。这棵树通常被称为current当前树。当React开始处理更新时,还会创建一个workInProgress树,这棵树映射了将来要渲染(flushed)到页面的状态。

所有作业被执行于workInProgress树的fibers。React遍历current树,对每一个存在的fiber节点都会创建一个备用(alternate)节点,由这些备用节点构成workInProgress树。这样的节点也是由React元素render方法返回的数据构造的。一旦更新被处理,且所有相关工作都完成了,React将得到一个备用(alternate)树,准备渲染到屏幕上。当这个workInProgress 树被渲染到屏幕上,它就成了current树。

React的一个核心原则就是一致性(consistency)。React更新DOM总是一气呵成(in one go)—也就是不会展示部分(partial)结果。那么workInProgress树就像一张对用户不可见的草图(draft),所以React首先会处理所有的组件,然后在将整体的变更展示出来。

在上一节引用ChildReconciler的源码中,有很多代表动作的函数,它们接受的fiber节点参数可以来自current树也可以来自workInProgress树。比如:

function updateHostComponent(current, workInProgress, renderExpirationTime) {...}

每一个fiber节点,都会通过alternate字段来持有一个其”替身“的引用,current树的节点通过alternate字段持有workInProgress树中对应节点的引用,反之亦然(vice versa)。

Side-effects 副作用

React中的组件可以想象成是一个函数,利用state和props来计算UI展示的方式。所有其他行为,比如DOM变更或者调用生命周期方法,都被认为是一个副作用,或者简单的叫它影响(effect)。Effects也在这篇文档中提到。

You’ve likely performed data fetching, subscriptions, or manually changing the DOM from React components before. We call these operations “side effects” (or “effects” for short) because they can affect other components and can’t be done during rendering.

你可能会执行数据获取操作,订阅操作或者在React之前操作DOM。这些操作都被称为副作用,因为它们会影响其他组件,并且不能在渲染过程中完成。

你会发现大部分state和props更新都将引发副作用。并且因为践行(applying)副作用也是作业的一种,一个fiber节点,就成为一种很方便的机制,来追踪除了更新意外的副作用。每个fiber节点可以拥有其相关联的副作用,它们以编码的形式,通过effectTag字段来体现。

所以副作用在Fiber体系中,基本上定义了那些在更新被处理完之后,实例应当完成的作业。SideEffectTag是一个特殊的数值,在源码中有体现,比如没有副作用NoEffect用0b00000000000也就是0来表示。对于宿主组件,浏览器环境来说就是DOM元素,由增加、更新和移除元素构成作业。对于类组件,React可能需要更新refs并调用componentDidMount和componentDidUpdate生命周期方法。当然还有其他副作用,分别有不同类型的fiber与之对应。

Effects list (暂不知道怎么表达)

React处理更新非常迅速,为了达到这么优秀的性能,使用了一些非常有趣的技术。其中之一就是为了快速迭代,构建了一个线性表,其中的元素都是因为状态更新而收到影响的fiber节点(原文为:fiber node with effects)。遍历线性表要比遍历树快很多,而且也没有必要在那些没有副作用的节点上。

这个线性表的目标,是标记那些有DOM更新或者其他与之关联的节点。这个列表是finishedWork树的子集,通过nextEffect属性链接起来,并不是current树和workInProgress树中的child属性。

Dan Abramov有这样一个关于effects list的比喻。他将其想成一棵圣诞树,所有effectful节点都绑着圣诞彩灯。为了可视化理解,想象下面这样的树,其中高亮的节点表示有些工作要做。比如,更新导致c2被插入到DOM中,d2c1变更属性,b2触发一个生命周期。effect list会将这些节点连起来,React就能跳过其他的节点了。

https://i.loli.net/2020/05/13/AbQy4W5jt9h36Vx.png

可以看出受影响的节点是如何连接在一起的。当要重新处理这些节点时,React通过firstEffect指针来确定从哪里开始。所以下面的图可以简述这一线性表的结构:

https://i.loli.net/2020/05/13/EXchFmDflHuG2iI.png

Root of the fiber tree fiber树的根节点

每个React应用都有一个或多个像容器一样的DOM元素。也就是ReactDOM.render函数的第二个参数,应当是一个DOM元素:

const domContainer = document.querySelector('#container');
ReactDOM.render(React.createElement(ClickCounter), domContainer);

React为每一个这样的容器创建一个fiber根对象

const fiberRoot = query('#container')._reactRootContainer._internalRoot

没错,你没有看错,是直接挂在了DOM元素上!

React通过fiber根对象持有整个fiber树的引用,这个引用保存在fiber根对象的current属性。

const hostRootFiberNode = fiberRoot.current

fiber树开始于一个特殊类型(HostRoot=3)的fiber节点—HostRoot。它是内部创建的,并且作为最顶层组件的父级。从HostRootFiberRoot通过stateNode属性有一个反向的连接关系:

fiberRoot.current.stateNode === fiberRoot; // true

通过fiber根访问顶层HostRoot fiber节点,从而暴露整个fiber树。或者你可以从组件实例获取私有节点:

compInstance._reactInternalFiber

Fiber node structure 节点数据结构

上面的ClickCounter组件,背后的fiber节点:

{
    stateNode: new ClickCounter,
    type: ClickCounter,
    alternate: null,
    key: null,
    updateQueue: null,
    memoizedState: {count: 0},
    pendingProps: {},
    memoizedProps: {},
    tag: 1, // 对应ClassComponent
    effectTag: 0,
    nextEffect: null
}

组件包含的span元素也有对应的fiber节点(因为其innerHTML属性依赖应用状态):

{
    stateNode: new HTMLSpanElement,
    type: "span",
    alternate: null,
    key: "2",
    updateQueue: null,
    memoizedState: null,
    pendingProps: {children: 0},
    memoizedProps: {children: 0},
    tag: 5, // 对应HostComponent
    effectTag: 0,
    nextEffect: null
}

fiber节点还是有很多字段的,其中alternateeffectTagnextEffect之前提到过,分别是current树和workInProgress树的连接点,类型和effect链表的指针。下面介绍一下其他一些重要的属性。

这里插播一丢丢小内容:

随便打开一个由React编写的站点(比如redux官方文档),打开控制台,如果安装了React调试插件,在Components标签页找一个组件,再到Elements标签页找到对应的DOM元素,邮件保存为全局临时变量temp1,然后你可以看到这样的内容:

https://i.loli.net/2020/05/13/AvOz4m1RfSlL87W.png

插播结束。

stateNode

持有组件类实例的引用,是一个DOM元素或者与当前fiber节点相关联的其他React元素。一般来说这个字段用于保存于fiber相关的本地状态。

type

用一个字符串(DOM元素的HTML标签名)或者函数(类组件的构造函数,函数组件的本体)标识fiber的类型(这个之前应该提到过)。这个字段经常用来直接明确fiber对应的具体元素。

tag

由一组枚举值标识fiber的类型。该值用于协调算法,决定对其执行何种操作。之前提到过,作业分类依赖React元素类型。函数createFiberFromTypeAndProps把一个React元素映射成对应类型的fiber节点。在应用中,ClickCounter组件的tag属性是1,代表是CLassComponent,而span的tag是5代表是HostComponent。

updateQueue

一个包含状态更新,回调和DOM更新的队列

memoizedState

之前用来创建输出的状态。处理更新时(也就是新状态到来时),这个字段反映了当前正在使用的状态(也就是相对来说的旧状态)。

memoizedProps

基本同上

pendingProps

已经被更新,但是还需要应用到子组件或子元素的Props,可以理解为没有完全生效的props。

key

用于标识一组子元素的唯一标识符,让React可以在列表中找到具体增删改的那一个。这与React原文档中”列表和key“一章直接相关。

完整的fiber节点结构可以查看这里。上文忽略了大部分字段,尤其跳过了childsiblingreturn这三个指针,而这三个指针正是构成树形结构的关键。并且有一类字段,比如expirationTimechildExpirationTimemode,这些和调度器相关。

General algorithm 广义算法

React在两个主要阶段执行作业:rendercommit

在首次渲染阶段,React将更新应用到那些调用了setState或者React.render的组件,并找出哪些在UI上需要作出更改。如果是初始化渲染,React将为render方法返回的元素创建一个新的fiber节点。在后续的更新中,存续的React元素的fibers会被重用并更新。渲染阶段的结果就是一颗有fiber节点构成的树,并且每个节点可能被标记有副作用。effects描述了那些在接下来的提交阶段(commit phase)需要完成的工作。在这一阶段,React拿到带有effects的fiber树,然后将effects作用到对应的实例上。在遍历effects列表的过程中,执行DOM更新和其他对用户可见的更新。

首次渲染过程中需要完成的作业,是可以被异步执行的,了解这一点非常重要。取决于可用时间,React可以处理一个或多个fiber节点,然后停下来保留完成的作业,将执行权交出(yield)到其他事件。之后还会从停下来的地方继续执行作业。尽管有些时候,可能需要丢弃已经完成的作业再从头开始。这些中断是可行的,因为这个阶段执行的作业不会导致像DOM更新这样用户可见的变更。相反的,接下来的commit阶段总是同步的。这是因为这个阶段执行的作业,总是那些会导致用户可见内容的变更。这也是为什么React需要一次性完成这些操作。

调用声明周期方法是React可能执行的一种作业。一些方法在render阶段被调用,也有的在commit阶段调用。下面是首次render阶段可被调用的生命周期:

  • [UNSAFE_]componentWillMount (deprecated)
  • [UNSAFE_]componentWillReceiveProps (deprecated)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_]componentWillUpdate (deprecated)
  • render

你是否好奇为什么这些钩子不安全甚至将要被移除?

刚才讨论过渲染阶段不产生诸如DOM更新的副作用,React可以异步的处理更新(甚至可以在多线程中进行)。但是那些带有UNSAFE标记的生命周期,总是被误解,被错误的使用。在新的异步渲染模式中,开发者倾向于将带有副作用的代码放在那些可能引起问题的方法中。虽然只有UNSAFE前缀的副本会被移除,但是他们在将来的并发模式中可能产生问题。

commit阶段会被执行的生命周期:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

这些方法在一个同步的commit阶段执行,他们可以包含副作用并接触到DOM。

那么现在了解了一些关于遍历树和执行作业的广义算法的背景知识,下面要进行深入了解。

Render phase 渲染阶段

调度算法总是通过renderRoot函数,从最顶层的HostRoot fiber节点开始。React摆脱/跳过(bails out of/skips)那些已经处理过的fiber节点,直到找到有未完成作业的节点。比如说,在组件树比较深层的地方调用setState,React会从顶开始,快速跳过父级组节点,直到调用setState的组件。

Main steps of the work loop 作业循环的主要步骤

所有的fiber节点都在“作业循环”(work loop)中被处理。下面是同步部分:

function workLoop(isYieldy) {
  if (!isYieldy) {
    while (nextUnitOfWork !== null) {
      nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
    }
  } else {...}
}

上面的代码中,nextUnitOfWork持有一个fiber节点的引用,这个节点来自于workInProgress树,并且有要执行的作业。当React遍历Fiber树时,使用这个变量获知现在是否有未完成的作业。在当前fiber节点被处理完之后,这个变量要么变成树中下一个节点的引用,要么是null。如果是null的话就会退出作业循环,然后准备好提交变更。

有四个主要的函数,被用于遍历树、初始化作业或完成作业:

为了演示他们是如何被使用的,请查看下面的动画:

https://i.loli.net/2020/05/14/JwPsxBzA6rq72Fm.gif

这个演示中使用了上面那些函数的简化实现来说明问题。每一个函数接收一个fiber节点并处理它,随着React向下,你可以看到处正在激活的fiber节点如何改变。从动画中你可以清晰的了解到算法是如何从树的一支到另一支。在回溯到父节点之前,将率先处理掉子节点的作业。

注意:垂直连接表示同级兄弟,拐弯节点是子级,上面的例子中,b1节点没有子节点,b2只有一个子节点c1

视频版在这里(好像打不太开)。概念上,你可以认为“开始(begin)”就是“步入(stepping into)”组件,“完成(complete)”就是“步出(stepping out)”组件。作者的例子有些厉害,下面也会介绍。

从前两个函数performUnitOfWorkbeginWork开始:

function performUnitOfWork(workInProgress) {
    let next = beginWork(workInProgress);
    if (next === null) {
        next = completeUnitOfWork(workInProgress);
    }
    return next;
}

function beginWork(workInProgress) {
    console.log('work performed for ' + workInProgress.name);
    return workInProgress.child;
}

performUnitOfWork函数接受一个workInProgress树中的fiber节点,调用beginWork函数开始执行作业。这个函数是所有需要对fiber执行的动作开始的地方。出于演示目的,这里用输出fiber的名字来表示(denote)作业执行。beginWork函数总是返回一个指针,指向下一个要在循环中被处理的子节点,或者返回null。

如果还有下一个要处理的子节点(也就是beginWork函数返回不是null),这个节点会被赋值给nextUnitOfWork变量(workLoop函数中循环的依据)。但是如果没有下一个节点了,React知道到了分支的重点,然后就可以complete当前节点。一旦节点被complete,就会对兄弟节点执行作业,之后再回溯到父节点处。这些是在completeUnitOfWork函数中完成的:

function completeUnitOfWork(workInProgress) {
    while (true) {
        let returnFiber = workInProgress.return;
        let siblingFiber = workInProgress.sibling;

        nextUnitOfWork = completeWork(workInProgress);

        if (siblingFiber !== null) {
            // If there is a sibling, return it
            // to perform work for this sibling
            return siblingFiber;
        } else if (returnFiber !== null) {
            // If there's no more work in this returnFiber,
            // continue the loop to complete the parent.
            workInProgress = returnFiber;
            continue;
        } else {
            // We've reached the root.
            return null;
        }
    }
}

function completeWork(workInProgress) {
    console.log('work completed for ' + workInProgress.name);
    return null;
}

不难发现函数的关键是一个while循环。当一个workInProgress树中的节点没有子节点时,React会进入该函数。在完成了当前fiber节点的作业后,会检查是否有兄弟节点。如果有,React会退出函数并交出兄弟节点的引用。由workLoop-》performUnitOfWork-》completeUnitOfWork这个顺序会发现,这个兄弟节点的引用将被赋值到nextUnitOfWork变量上,React会开始执行以这个兄弟节点为分支顶的一系列作业。此时,React只是完成了之前兄弟节点的作业,这一点非常重要。父节点对应的作业并没有被completed,因为还没有到回溯的时候。只有当子节点下面的所有分支都completed,才能complete父节点的作业并回溯

workLoopcompleteUnitOfWork主要都是用来迭代,主要活动还是发生在beginWorkcompleteWork函数中。系列后续文章,会具体讨论ClickCounter组件和span节点身上,React步入(steps into)beginWork函数和completeWork函数具体做了什么。

Commit phase 提交阶段

这个阶段从completeRoot函数开始。在这里,React更新DOM,并调用更新前后的一些生命周期方法。

当React进入这个阶段,有两棵树和一个effects列表。第一棵树代表了当前渲染在视图上的状态(state)。另一个棵备用(alternate)树在render阶段构建出来。在源码中有两个术语finishedWork和workInProgress代表需要被展示在视图上的状态。这棵备用树的连接方式和current树类似,也是通过child和sibling指针。

然后再来说effects列表,这是一个finishedWork树节点集合的子集(subset),用nextEffect指针连成线性结构。记住,effect列表是跑完render阶段后得到的结果。渲染就是为了确定哪些节点要插入,要更新或者要删除,确定哪些组件有生命周期方法要调用,就用effect list的形式表达。commit阶段要迭代的节点集合,正是effect list

出于调试目的,current树可以通过fiber root的current属性访问到。finishedWork树,可以通过current树中的HostFiber节点的alternate属性访问到。

commit阶段主要执行的函数是commitRoot。基本上执行了以下动作:

  • 在标记了Snapshot effect的节点上,调用getSnapshotBeforeUpdate生命周期
  • 在标记了Deletion effect的节点上,调用componentWillUnmount生命周期
  • 执行所有的DOM插入、更新和删除
  • 将finishedWork树置为当前树
  • 在标记了Placement effect的节点上,调用componentDidMount声明周期
  • 在标记了Update effect的节点上,调用componentDidUpdate生命周期

在调用了更新前置方法getSnapshotBeforeUpdate之后,React用一棵树提交所有的副作用。分成两个阶段(passes),第一阶段执行所有的DOM(host宿主级别)插入、更新、删除和ref卸载。之后React将finishedWork树赋值给FiberRoot,标记(marking) workInProgress树为current树。

原文:This is done after the first pass of the commit phase, so that the previous tree is still current during componentWillUnmount, but before the second pass, so that the finished work is current during componentDidMount/Update

(我的理解是新旧树交替的这一过程,是在commit阶段的第一阶段后、第二阶段前完成,所以在componentWillUnmount钩子中能访问到的旧树应该还是当前可见的DOM对应的树;在componentDidMount/Update周期当前可用的就已经是处理过的结果)

在第二阶段React调用所有其他的生命周期和ref回调。这些方法以独立开的过程执行,因此整个树中位置调整、数据更新和删除操作都已经被调用(invoked)。

关键的执行步骤描述如下:

function commitRoot(root, finishedWork) {
    commitBeforeMutationLifecycles()
    commitAllHostEffects();
    root.current = finishedWork;
    commitAllLifeCycles();
}

每一个子函数都实现了一个循环,遍历effect列表并检查effect的类型。这些函数偏离过程中会发现与执行目的相关的effect(函数名代表了归类处理effect)并应用(applies)。

Pre-mutation lifecycle methods 变异前生命周期

下面是一段示例代码,演示了如何迭代effects树和检查有Snapshot effect的节点:

function commitBeforeMutationLifecycles() {
    while (nextEffect !== null) {
        const effectTag = nextEffect.effectTag;
        if (effectTag & Snapshot) {
            const current = nextEffect.alternate;
            commitBeforeMutationLifeCycles(current, nextEffect);
        }
        nextEffect = nextEffect.nextEffect;
    }
}

对于类组件来说,这个effect意味着调用getSnapshotBeforeUpdate生命周期。

DOM updates DOM更新

React在commitAllHostEffects函数中执行DOM更新。该函数定义了一些需要完成和执行的操作类型:

function commitAllHostEffects() {
    switch (primaryEffectTag) {
        case Placement: {
            commitPlacement(nextEffect);
            ...
        }
        case PlacementAndUpdate: {
            commitPlacement(nextEffect);
            commitWork(current, nextEffect);
            ...
        }
        case Update: {
            commitWork(current, nextEffect);
            ...
        }
        case Deletion: {
            commitDeletion(nextEffect);
            ...
        }
    }
}

有趣的是React调用componentWillUnmount生命周期是删除操作的一部分,具体是在commitDeletion函数中执行的。

Post-mutation lifecycle methods 变异后生命周期

React在commitAllLifecycles函数中调用其他生命周期,比如componentDidUpdatecomponentDidMount

(原文到这里就省略号要进入系列文章的下一篇了,下一篇文章是“In-depth explanation of state and props update in React”—“深入解释React中state和props更新”)

原文作者:Maxim Koretskyi

在原文:When to useMemo and useCallback 基础上扩展学习

Review

回顾一下useMemo和useCallback:

useMemo

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

返回一个记忆化值。

第一个参数是一个拥有一定计算量的,并且返回计算结果的生成函数;第二个参数是参与计算量的一些状态变量,可以理解成computed依赖项,只不过是显式的依赖。而useMemo返回这个计算量的缓存值,当依赖项没有产生变化时,从缓存中取计算结果,而不是重新执行计算量。

根据React的工作原理以及渲染流程,类组件会初始化实例,状态变更尝试复用实例,但函数组件每次都会执行用于获取rendered节点,因此props变化会使得函数组件函数体中的代码反复执行,如果有大计算量的代码片段,且props的变化可能不会引起计算量结果变化,这时候就需要缓存化操作来减轻计算量。

官方文档这里提到了不能在useMemo针对的计算量中执行一些副作用操作,比如启停定时器,应当使用useEffect,依赖useMemo返回的记忆值也可以做。另外,useMemo没有提供依赖项,则认为计算量需要在每次渲染时重新计算,理论上就和没有用useMemo一样。

你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证

缓存系统一般来说为了保证空间效率,可能会选择放弃低热度的缓存,官方文档也提到了离屏组件中的缓存值,可能会被释放。

useCallback

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

返回一个记忆化回调函数

官方文档给出了useMemo和useCallback的关系:useCallback(fn, deps) 相当于 useMemo(() => fn, deps)。显然,同样是针对计算量,useCallback缓存计算过程,什么时候决定计算量可能还需要业务逻辑决定。useMemo在依赖项变化时,自动执行计算量产生新的值,而useCallback在依赖项产生变化时,得到一个新的计算过程,需要执行的时候会按照新的计算过程进行计算。

当你把回调函数传递给经过优化的并使用引用相等性去避免非必要渲染(例如 shouldComponentUpdate)的子组件时,它将非常有用。

这个提示非常重要,在使用callback作为props向子组件传递时,往往面临着引用相当性问题,父组件因为props变化生成新的callback,然后传递给子组件,尽快这个callback内容并没有变化(产生闭包运输的值也没有变化),但对于子组件来说props是发生了变化,又会引起子组件的重新渲染。而useCallback产生的记忆化回调函数,仅在依赖项变化时产生新的回调,否则使用缓存值,这对于子组件来说引用没有变化,并不会引起多余的更新。

useMemo和useCallback的第二个参数只是指明了依赖项,useMemo的计算量和useCallback的callback都不会接受依赖项作为参数,也就是说计算量和callback不能得到新旧值,不过computed也并不需要旧值对吧。

从useMemo和useCallback的用法上,不难发现一个问题,如果不指明任何的依赖项,缓存会在每次更新时重新计算,传说中的“击穿”,这和不用缓存一样,而且还多了额外的匿名函数声明。所以useMemo和useCallback虽然能够作为性能优化的手段,但是还需要使用缓存的成本。

优化与代价

Demo场景

原文举了一个糖果提货机的例子:

function CandyDispenser() {
  const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
  const [candies, setCandies] = React.useState(initialCandies)
  const dispense = candy => {
    setCandies(allCandies => allCandies.filter(c => c !== candy))
  }
  return (
    <div>
      <h1>Candy Dispenser</h1>
      <div>
        <div>Available Candy</div>
        {candies.length === 0 ? (
          <button onClick={() => setCandies(initialCandies)}>refill</button>
        ) : (
          <ul>
            {candies.map(candy => (
              <li key={candy}>
                <button onClick={() => dispense(candy)}>grab</button> {candy}
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  )
}

先不谈优化,假如让你来编写类似的代码,你是否会使用useCallback:

const dispense = useCallback(candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}, [])

像这样,将删除条目的动作缓存起来,并且注意这里回调中并没有读取外部数据,所以可以依赖项是空的。也就是说CandyDispenser组件重新渲染时,dispense总是新的。dispense被作为onClick的逻辑调用,那么你会看到onClick无论如何都会接受一个新的匿名函数,button也不是一个组件,没有减少更新的必要。因为原来给出了结论:不使用useCallback反而会更好。

useCallback会更糟?

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}
const dispenseCallback = React.useCallback(dispense, [])

使用useCallback可以认为是在原来的基础上增加了一步(内联函数一个匿名一个具名,这不会有太大差别)。在useCallback内部还有其他的操作,直观上来看确实会更多一些操作和消耗。不使用useCallback,函数组件每次重新渲染时,会声明新的dispense,而旧的被垃圾回收掉。但使用了useCallback,不仅要声明新的dispense,并且会把这个函数交给useCallback做缓存,在做缓存时,被缓存的callback和依赖项可能还会被内部持有引用以便进行比较,所以内存消耗上一定会有增加。

那么具有相同特性的useMemo是不是也会有更大的开销,比如将demo中的initialCandies进行缓存处理:

const initialCandies = React.useMemo(
  () => ['snickers', 'skittles', 'twix', 'milky way'],
  [],
 )

可以看到一个匿名内联函数用于返回一个字符串数组,这和直接声明一个字符串数组逻辑上几乎也没有区别,开销也是有的,尽管可以通过useMemo来lazy求值过程,但是似乎也没有那么必要。对于这些可能由props决定的值,或者仅仅是函数组价内部的一个上下文变量,不会再向下透传,那就放心大胆的声明使用吧,不用太操心因此产生的性能问题。

所以说性能优化总会带来一定的成本,有的时候成本会大于效果,就比如例子中的情况,useMemo和useCallback本身带来的时空消耗,是否能改善应用的计算量,需要在做优化的时候进行衡量。而这个过程往往是在整体逻辑确定的情况下,更容易分析上下关系带来的影响,所以在开发过程中,先大方的写起来,然后在考虑是不是有性能问题。

useMemo和useCallback的时机

其实从上面的例子可以看出,不管是initialCandies还是dispense,使用都在组件内部,并且并不依赖组件状态。用不用useMemo和useCallback都可以实现业务逻辑,且针对这个例子,带来的消耗可以忽略不计,但是如果是更大的应用,更复杂的组件关系,如何不带来过分的消耗使用useMemo和useCallback,可以先来了解这两个hooks针对的场景,或者说优化需要关注的点是什么。

了解react的工作原理可能会有助于帮助理解useMemo和useCallback。在应用中,react做的最多的事可能就是“Updating”,也就是更新应用状态。试想一个组件状态变更了,由于View-Model特性视图层也要产生相应的变化,那么直接点可能就是把组件卸载然后在按新的状态重新挂载组件。而挂载组件可以认为是求rendered元素的过程,这个过程可简单可复杂。并且组件等级越高,卸载再挂载的成本会越高,因此被更多了解的“diff”过程用于找到最小的应变更元素。本段的描述可能不完全精确,主要是表达一些分析要素。

那么来想想优化通常是做什么。比如老生常谈的防抖和节流,是为了减少频繁的运算,顺着这个思路,react应用优化也可以从减少运算入手,最多的运算就是diff寻找更新点,对于函数组件来说每次求rendered节点,函数体都会被重新执行一次。简化来看就是“对比”和“重新求值”产生了react的主要计算量。

对于diff来说,react已经设计了很好的算法解决寻找树形结构中的差异点,但是这些差异点到底是不是会产生真正变化的要素,成为优化的一个关注点。也就是说如何让react做出最有效的“比较”。

从比较说起

JavaScript语言的一个特性,拥有两种比较“==”和“===”,简单理解成弱相等和强相等,具体可以去看真值表。基础类型变量的比较比较简单,引用类型的比较就会对编程产生一些些影响:

let a = {
    name: "A"
}

console.log(a === {name: "A"})

从人类语言逻辑来说,期望这个返回是true,因为“长”的都一样,在实际业务中,可能都对应着一个展示name字段的html片段。在状态变化前后,应用中某个组件先后接到这样两个props,从程序的比较上会认为这是不同的,但是变成逻辑上视图层不会有什么变化,因此就会产生一个react认为是需要重新渲染,但是认为逻辑上不应重新渲染的情况。

看一个文章中给出的例子:

function Foo({bar, baz}) {
  const options = {bar, baz}
  React.useEffect(() => {
    buzz(options)
  }, [options]) // we want this to re-run if bar or baz change
  return <div>foobar</div>
}

function Blub() {
  return <Foo bar="bar value" baz={3} />
}

这里用到useEffect,人为逻辑上是希望当bar变化或者baz变化时进行一个buzz操作,因为这两个变量任意一个产生变化,都会使options产生变化。但是实际上,无论从Blub传下来的bar和baz是否变化,Foo组件重新渲染时,options本身都是新的,正是因为引用比较,并不能正常的进行watch逻辑。不管实际渲染到DOM上是否会有相应的操作(毕竟从字符串层面来说却是没有重新渲染的必要),但是这个副作用已经实实在在执行了,这其实就是一个多余的计算量,如果buzz是个运算量很大的过程,这个消耗就更大了。

针对上面的情况,Blub向下透传的都是基本类型数据,Foo中单独依赖就好了:

function Foo({bar, baz}) {
  React.useEffect(() => {
    const options = {bar, baz}
    buzz(options)
  }, [bar, baz]) // we want this to re-run if bar or baz change
  return <div>foobar</div>
}

如果Blub向下透传的不是基本类型,而是一个回调函数,或者一个数组:

function Blub() {
  const bar = () => {}
  const baz = [1, 2, 3]
  return <Foo bar={bar} baz={baz} />
}

那就又回到了起点,Blub每次重新渲染的时候,bar和baz都是新的引用,传下去内容虽然相同,但是对于useEffect来说引用不同仍然要执行这个副作用。向下透传回调或一个集合非常常见,比如子组件需要修改父组件的状态,可能会将state的setter通过props传递下去,又或者需要传递很多显示字段,根据参数设置的规则,通常会使用集合来减少形参,子组件依赖数据集合计算自己的显示内容,就可能出现不必要的重复工作量。

没必要的工作量

看到上面的例子,应该已经可以看到useCallback和useMemo的使用场景了,最直观的就是那些会向下透传的回调和引用类型数据。不过要注意的是,这个向下透传是指向子组件透传,对DOM元素绑定事件响应函数可以不去过多的考虑。

逻辑上想要比较内容,可以比较过程在react内部,确确实实比较的是引用。要解决这样的问题,要么提供自己的比较方式,像是在类组件中使用shouldComponentUpdate周期中进行自己的比较(比不好也挺尴尬),函数组件没有这样的过程,每次重新渲染都是函数的执行,因此就需要一个“第三方”系统,来让这些内容没变的东西保持对应的引用,也就是useCallback和useMemo,“第三方”系统就理解成一个缓存系统吧。

function Foo({bar, baz}) {
  React.useEffect(() => {
    const options = {bar, baz}
    buzz(options)
  }, [bar, baz])
  return <div>foobar</div>
}

function Blub() {
  const bar = React.useCallback(() => {}, [])
  const baz = React.useMemo(() => [1, 2, 3], [])
  return <Foo bar={bar} baz={baz} />
}

将bar和baz置成一个缓存中的引用,并且这个引用期待他在依赖不变的情况下保持不变,也就是内容不变引用就不变,方便达成代码中的比较与你脑中的比较保持一致性(这句话并不严谨)。那么达成了这样的比较一致,引用不变依赖不变,useEffect的依赖也能保持你想要的比较。

React.memo

react提供一个顶层API,用于创建一个缓存的组件,先来看一下原文中的例子:

function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
}

function DualCounter() {
  const [count1, setCount1] = React.useState(0)
  const increment1 = () => setCount1(c => c + 1)

  const [count2, setCount2] = React.useState(0)
  const increment2 = () => setCount2(c => c + 1)

  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  )
}

直白来说,子组件CountButton在响应用户操作时,会反向更新父组件的状态,点击一个按钮会导致父组件更新,然后重新渲染两个按钮。点第一个按钮,count1发生变化,但是count2并没有变,第二个按钮也被顺带着重新渲染了,这和之前讨论的内容不变的更新差不多,对于程序逻辑来说都是不必要的。这个例子只是为了说明什么样的更新是不必要的,仅针对这个例子这样做也不会产生太大的影响,可能代码会比优化后的更容易看懂。但是如果把小问题放到大规模里,可能依然会引起需要优化的问题,比如CountButton还有复杂的动画计算。

React.memo这个API接受一个组件,返回一个缓存化的组件,只有当组件接受的props发生变化时才会重新渲染。

const CountButton = React.memo(function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
})

不过回到比较的话题,上面的例子中向下透传一个回调,即使memo了,还是无法回避props变化的问题,此时useCallback也能起到减少不必要的渲染。不过还是尽量不要使用memo或者shouldComponentUpdate,因为会增加很多执行频繁的计算过程。

昂贵的计算

useMemo和useCallback都是用于解决引用比较产生的不必要的重新渲染问题,本质上都是通过让“内容一致”和“引用一致”对应起来实现的,大面上都是发生在子组件props比较时,但其实保证了引用一致,依赖计算这样的过程其实也可以优化。最常用的场景,依赖props产生局部状态。父组件透传一个数组到子组件,子组件要遍历衍生出自己需要的数据结构,这样的过程消耗相对其他的简单操作来说大得多。使用useMemo让计算过程更有保证的进行:

function RenderPrimes({iterations, multiplier}) {
  const primes = React.useMemo(() => calculatePrimes(iterations, multiplier), [
    iterations,
    multiplier,
  ])
  return <div>Primes! {primes}</div>
}

一方面能进行一个惰性计算,用到primes时才执行计算,另一方面也能在依赖项引用不变的情况下不再重新计算。同时缓存化提供的便利,也体现才输入相同时返回先前存储的结果。

总结

首先useMemo和useCallback是react针对函数组件提供的优化方式,这当然是可选的。是否使用优化取决于优化的成本,与优化收益的关系。开发过程中可能会产生不好的组件关系,产生不好的数据结构,这解决这些问题前,都不应当过早的进行优化。

原文提到的AHA编程原则:避免草率的抽象、宁可复制代码也不要错误的抽象(因为抽象通常是为了减少重复代码)和为了改变而优化。这个原则提示了优化之前应该做的事,以及优化应该在什么情况下进行。

useMemo和useCallback的成本:少量的内联函数和变量(这个影响可以忽略不计),机制内在的消耗,潜在的内存泄露,以及代码结构更加复杂。在使用之前,确定是否适用,判断是否会产生其他性能问题很重要。

其他

Hooks是否会因为创建内联函数而让渲染变慢

官方文档中Hooks FAQ有关于这个问题的解释:答案是否定的,闭包和类的性能差异在一般情况下并不显著。

相反的,hooks设计可能更高效:class相比函数来说开销大一些,比如继承关系,以及类组件需要额外的bind;在高级组件、render props和context代码库中,组件树小了。

类组件中shouldComponentUpdate遇到内联函数时可能会有比较问题,hooks体系提供了useCallback、useMemo和useReducer都是为了解决有关内联函数比较和引用比较。

React,内联函数与性能

原文可以阅读这里。内联函数与性能问题挂钩,主要是担心内联函数创建与垃圾回收的消耗,另外就是类组件中的shouldComponentUpdate问题。创建内联函数和回收可以认为不会产生性能问题,和bind对比的话,甚至bind消耗更多。

而shouldComponentUpdate在类组件中,作为一个关键环节,可以跳过不需要的diff,相当于用自己的比较方式来取代react的更新比较,这就有可能产生比原本diff消耗更高的代码。PureComponent则使用严格比较state和props的方式来决定更新。

从上面两个额外阅读来看,不用担心使用钩子时内联函数产生的影响,关注产生真正性能问题的部分。

从对象介绍到继承的几种实现,现在才要真正的看看Babel是如何处理class和extends。

Babel如何翻译class

假设我们有如下ES6代码:

// demo.js
class SuperType {
  constructor(name) {
    this.superName = name;
    this.superFriends = ['Json'];
  }

  sayHi() {
    console.log("Super HI!");
  }

  static getVersion() {
    return "12.2";
  }
}

包含了基本类型值的属性,引用类型值的属性,实例方法和一个静态方法,来看一下Babel翻译后的代码,看看如何实现class这个语法糖。!注意!静态属性这里不讨论,虽然行为上不特殊,但是需要额外的支持,先关注核心的内容。

// demo_babel.js
"use strict";

function _classCallCheck(instance, Constructor) {
  if (!(instance instanceof Constructor)) {
    throw new TypeError("Cannot call a class as a function");
  }
}

function _defineProperties(target, props) {
  for (var i = 0; i < props.length; i++) {
    var descriptor = props[i];
    descriptor.enumerable = descriptor.enumerable || false;
    descriptor.configurable = true;
    if ("value" in descriptor) descriptor.writable = true;
    Object.defineProperty(target, descriptor.key, descriptor);
  }
}

function _createClass(Constructor, protoProps, staticProps) {
  if (protoProps) _defineProperties(Constructor.prototype, protoProps);
  if (staticProps) _defineProperties(Constructor, staticProps);
  return Constructor;
}

var SuperType =
  /*#__PURE__*/
  function () {
    function SuperType(name) {
      _classCallCheck(this, SuperType);

      this.superName = name;
      this.superFriends = ['Json'];
    }

    _createClass(SuperType, [{
      key: "sayHi",
      value: function sayHi() {
        console.log("Super HI!");
      }
    }], [{
      key: "getVersion",
      value: function getVersion() {
        return "12.2";
      }
    }]);

    return SuperType;
  }();

从上到下依次介绍:

_classCallCheck:这是一个常用的,判断是普通调用还是new调用的方式。根据new操作符的执行原理,this将是一个内在的已有原型指向的对象,构造法对其进行构造,从而形成对应类型的实例。而普通调用,在没有改变执行上下文的情况下,this都是顶层对象,所以通过instanceof来判断。

_defineProperties:是_createClass依赖的工具函数,从名称和签名上可以揣测一下,应该是和Object.defineProperty类似的功能。简单看一下逻辑,其实就是可以批量的defineProperty。

_createClass:创建class的核心方法,包含向原型添加属性,和向构造函数本身添加属性,前者是处理实例定义,后者是处理静态定义。

通过一个立即执行函数创建一个大的闭包,将整个过程隔离起来。假设我们用普通的写法自己实现class,大致会像这样:

function SuperType(name) {
    // 大部分时候都会忽略new调用和普通调用要区分开
  this.name = name;
  this.superFriends = ['Json'];
}

SuperType.getVersion = function () {
  return "12.1"
};

SuperType.prototype.sayHi = function() {
  console.log("Super HI!", this.name);
};

Babel实现的class一方面增加了调用检查,另一方面,在定义多个实例方法的使用,可以通过_defineProperties来批量实现。所以看上去其实区别并不很大。

Babel如何翻译class+extends

似乎终于来到了正题,为了减少贴太多代码,下面仅包含增量代码:

// demo.js
class SubType extends SuperType {
  constructor(name, age) {
    super(name);
    this.age = age;
  }

  sayHi() {
    console.log("Sub HI!", this.name, this.age);
  }
}

翻译后的代码,第一部分,先介绍工具函数:

// demo_babel.js
function _typeof(obj) {
  if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
    _typeof = function _typeof(obj) {
      return typeof obj;
    };
  } else {
    _typeof = function _typeof(obj) {
      return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
    };
  }
  return _typeof(obj);
}

function _possibleConstructorReturn(self, call) {
  if (call && (_typeof(call) === "object" || typeof call === "function")) {
    return call;
  }
  return _assertThisInitialized(self);
}

function _assertThisInitialized(self) {
  if (self === void 0) {
    throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
  }
  return self;
}

function _getPrototypeOf(o) {
  _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) {
    return o.__proto__ || Object.getPrototypeOf(o);
  };
  return _getPrototypeOf(o);
}

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function");
  }
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      writable: true,
      configurable: true
    }
  });
  if (superClass) _setPrototypeOf(subClass, superClass);
}

function _setPrototypeOf(o, p) {
  _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
    o.__proto__ = p;
    return o;
  };
  return _setPrototypeOf(o, p);
}

// 上文介绍过的三个工具函数

// 上文列出过的SuperType的声明

_typeof:是对原生typeof的一个扩展,在那些没有Symbol特性的环境下,如果通过特殊手段实现了Symbol,该函数依然能够判断扩展的类型。注意else分支中的三元表达式。

_possibleConstructorReturn和_assertThisInitialized:这两个要一起看。在调用处会具体介绍。

*_getPrototypeOf和_setPrototypeOf*:在MDN文档中你会看到一些浏览器支持__proto__ 但不支持Object.getPrototypeOf,所以这两个方法用于兼容。

_inherits

你看到这个方法和别的工具方法不同,被H2了!单独把这个函数拎出来:

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function");
  }
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      writable: true,
      configurable: true
    }
  });
  if (superClass) _setPrototypeOf(subClass, superClass);
}

如果superClass,也就是父类这个参数,并没有传构造法,会抛出一个错误,合情合理。

接下来的代码你可能会有一些熟悉,上一篇文章介绍继承的集中方式式,最后一种“寄生组合”:

// 本函数只是在原理上介绍寄生组合,并没有考虑constructor这个属性的本身细节
function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

注意到在恢复constructor的步骤上,Babel更加严谨的使用Object.create第二个参数,没有配置enumerable特性,所以默认是false的,这个属性并不会被迭代。具体可以执行一下Object.getOwnPropertyDescriptor(Object.prototype, "constructor") 。这个时候子类的原型,是一个以父类原型为原型的对象,但是后来执行了一个过程“subClass.__proto__=superClass”,直观上看是为了继承父类的静态方法,具体作用只能先这么理解了。

等具体看到后面的代码,你会发现Babel确实是寄生组合实现的继承。

具体继承

_inherits函数基本上指明了组合寄生的道路,那么具体的实现看看还有哪些细节:

// demo_babel.js
var SubType =
  /*#__PURE__*/
  function (_SuperType) {
    _inherits(SubType, _SuperType);

    function SubType(name, age) {
      var _this;

      _classCallCheck(this, SubType);

      _this = _possibleConstructorReturn(this, _getPrototypeOf(SubType).call(this, name));
      _this.age = age;
      return _this;
    }

    _createClass(SubType, [{
      key: "sayHi",
      value: function sayHi() {
        console.log("Sub HI!", this.name, this.age);
      }
    }]);

    return SubType;
  }(SuperType);

寄生组合的思想主要是两部分:1.借用父类构造函数构造自身;2.正确处理原型关系。_inherits函数处理了2。上一篇文章的例子,通过SuperClass.call的方式,将构造过程转移到子类实例上,而Babel的做法更为精妙,仔细看:

_this = _possibleConstructorReturn(this, _getPrototypeOf(SubType).call(this, name));

先看第二个参数_getPrototypeOf(SubType).call(this, name) 会不会好奇SubType的原型为什么能用call,回想一下_inherits函数的最后一步,SubType的原型就是SuperType。这一步就可以认为是SuperType.call的过程,而_possibleConstructorReturn 函数检查了第二个参数,如果第二个参数是个对象或者函数,那么说明父类构造法有覆盖实例的情况;然后会通过_assertThisInitialized 函数来校验this 究竟有没有被父类实例化过。按照这样的逻辑,_getPrototypeOf(SubType).call(this, name)这一步理论会对this正确的父类实例化,但是这种校验不是没有道理的。

假设我们将super(name) 注释掉,也许你的编辑器会给你报错,但是这个代码是可以正确翻译的,会变成这样:

var SubType =
/*#__PURE__*/
function (_SuperType) {
  _inherits(SubType, _SuperType);

  function SubType(name, age) {
    var _this;

    _classCallCheck(this, SubType);

    // super(name);
    _this.age = age;
    return _possibleConstructorReturn(_this);
  }

  _createClass(SubType, [{
    key: "sayHi",
    value: function sayHi() {
      console.log("Sub HI!", this.name, this.age);
    }
  }]);

  return SubType;
}(SuperType);

这也解释了为什么会有一个_this出现,就是用来标记this是被父类初始化过的。可以简单的看成_this才是子类实例的一个等待状态,而this 必须要经过父类实例过程,才能使_this真正生效。

总结

至此Babel翻译class+extends基本就介绍完了,上一篇文章基本上是对《高程》第六章的复习,从理解对象,到构造对象,重点把握工厂模式,构造函数模式(new关键字的作用过程),原型以及原型对象(__proto__和prototype)。

JavaScript在ES6中增加了class和extends关键字,作为类型的语法糖。Babel的实现可能更巧妙的处理实例方法。到了继承,先从逻辑上梳理各种继承的模式,核心还是原型链的问题。通过父类实例建立原型关系,通过Object.create建立原型关系等等。Babel翻译的继承采用寄生组合,寄生表现在SuperClass.call的实现方式,Babel还增加了必要的检查,也就是super()是否调用;组合体现在原型链的构建上,Object.create直接建立关系,同时照顾constructor的特性。额外的还建立两个构造方法的原型关系,后面借助这个关系隐式的调用父类构造法,实际操作上还能直接继承父类的静态方法。

继承是面向对象编程不可避免的话题,尝试把继承看做是代码共享的一种好的方式,共享的部分约束细节从而具有类型划分的功能。一般来说继承关系是固定的,从复用代码的角度说,父类的代码被子类复用,这是一个确定的事情,总不能说我用着用着突然不想用了,想复用别的父类的代码,听上去就不靠谱,可能会引起很多问题。但是JavaScript貌似就是这样的奇葩。

虽然本文的标题是“跟着Babel学JS的继承”,但是首先要较为全面的了解JS中的继承,再来看Babel是如何处理class&extends语法糖。要想搞清楚继承,那还得从对象和面向对象说起,稍微介绍一些基础内容,再具体看看都有哪些继承的方式,最后看看Babel是如何实现更为全面的继承。

本文大部分内容都从《高程》中总结,辅助以MDN文档。

理解对象

ECMA-262将对象定义为:无序的属性集合,属性可以包含基本只,对象或者函数。高程中给了我们一个非常好的描述,用“散列表”来想象对象。对象是属性的集合,那属性的特征用什么描述,实际上在对象内部有专门用于描述属性的“特性—attribute”,而特性对于程序员来说基本可以认为是不可见的,这些特性通常用[[]] 来表述。特性用于描述属性的各项特征,所以属性就被粗略的分成两种—数据属性&访问器属性。

数据属性

根据高程的描述,数据属性包含一个数据值的位置,可读可写,有四个特性:

  • [[Configurable]] 用于描述是否可delete,是否可改为访问器属性,默认true
  • [[Enumerable]] 用于描述是否可迭代,即for-in循环中是否可枚举,默认true
  • [[Writable]] 用于描述是否可修改属性值,默认true
  • [[Value]] 用于描述属性值,读取属性实际从这个位置读取,默认undefined

通过字面量创建对象不能直接定义属性的特性,Object.defineProperty用于创建自定义特性的属性值。特定的特性可能会导致不一样的效果,比如[[Writable]] 为false时,表示属性值不能被修改,严格模式下,尝试赋值将导致报错,非严格模式则静默失败,保持原有的值。另外[[Configurable]] 一旦被设定了就无法修改,毕竟修改特性也被认为是一种配置。如果设置了false,delete操作会在非严格模式下静默失败,严格模式下报错。而且在使用Object.defineProperty时,不指明各种特性的值,将保持false。(上面说的默认true是在使用字面量设置属性时)

访问器属性

经典的来了,访问器或者叫访问器属性,是正经的属性,并不是附加在普通属性上的什么东西。访问器属性并不包含数据值,也就是没有[[Value]] ,而是通过[[Get]][[Set]] 两个特性来实现访问和修改:

  • [[Configurable]][[Enumerable]] 与数据属性的相似,这里不多赘述
  • [[Get]] 读取使用的函数,注意!该特性应当对应一个函数,默认是undefined
  • [[Set]] 修改使用的函数,默认是undefined

这些特性同样通过Object.defineProperty进行设置,严格模式下,尝试对“残缺”的属性读取或写入都将报错。并且不能同时设置[[Value]] 和getter&setter,这是Object.defineProperty不允许的,会直接报错。

使用Object.defineProperty设置特性,使用Object.getOwnPropertyDescriptor读取特性。

创建对象

理论上创建对象应当是非常复杂的一章,这里简要介绍工厂模式和构造函数,至于原型对象将在下一章节介绍。对象字面量和Object构造函数都可以创建一个朴素的对象,都是最直接的属性集合。然而面向对象的要求远远不止于此,类型要求创建对象时拥有共同细节,所以如何更好的创建对象也需要继续思考。

工厂模式

工厂模式是设计模式的一种,基于某种抽象来创建具体对象的思路。函数包含一个具体的操作流程,而这个流程就像是工厂组装的过程,只要使用工厂方法,就会用一个朴素对象组装出一个符合预期的对象。

function createPerson(name, age, job) {
  varo = newObject();
  o.name = name;
  o.age = age;
  o.job = job;
  o.sayName = function () {
    alert(this.name);
  };
  return o;
}

缺点显而易见,工厂生产出的对象虽然拥有相同的细节(都有特定的字段和方法/行为),但是除此以外并没有办法进行分类,从代码层面识别对象的分类是困难的,如果有多个字段,挨个检查非常的不合理。

构造函数模式

构造函数长得很像工厂函数,用new操作符来隐去创建对象和返回对象的过程,并内在的进行一些可以用来划分类型的操作,具体如何划分的可以关注原型对象的介绍。

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function () {
    alert(this.name);
  };
}

构造函数用一般函数的形式声明,只在new的时候有特殊的行为,为了区分构造函数和普通函数,通常用首字母大写的大驼峰命名,和其他语言声明类时相似。

简单来说new 操作有以下四个步骤:

  • 创建一个新的对象,这个过程是隐式
  • 将构造函数的上下文绑定到这个对象,也就是说构造函数中的this指向这个新的对象
  • 执行构造函数,也就是对这个新的对象进行构造
  • 返回这个新对象,作为new操作符对应的语句的返回值

构造函数的缺陷这里也简单说一下,上面的例子中,sayName是一个实例方法,这个属性值在每次初始化的时候都要声明一个匿名函数,每一个实例的sayName属性是严格不相等的,对于其他属性来说严格不相等是很重要的,但是对于方法来说,内部执行环境随着调用者变化,就可以产生各自的结果,并不需要每个实例都拥有自己的方法。如果把匿名函数拿到更外层的环境,虽然实例不再拥有独立的方法,但是在编写过程中,大权限的函数会变多,且如果没有很好地封装特性(被提取到外面的方法往往拥有更大的访问权限)。

至于构造函数是如何解决类型区分的问题,是通过实例上可以访问的constructor属性实现的,这个属性是在new的过程中挂上去的,具体可以继续了解原型对象。

这里要额外说一下,构造函数也是函数,也可以普通调用,并且有以下这样的迷惑行为:

let o = new Object();

Person.call(o);

console.log(o.name);
console.log(o.constructor)

看上去像不像工厂,你品,你细品。

原型模式

虽然原型模式应该被当做创建对象的一种模式,但是由于其实在是太重要了,就单独详细的介绍,和后面的继承也有密切的关系。

[[Prototype]]

这是JS世界中,每个对象都有的一个内置指针,理论上它只能讨论却对程序员来说”不可用“,有些环境会让它以__proto__ 的形式“可见”。

每个实例对象(object)都有一个私有属性__proto__ 用于指向这个对象的构造函数的原型对象(prototype)。

下面在介绍这个内置指针相关的关系时,为了好表达,都用__proto__

虽然这个内置指针对程序员来说”不可用“,不能直接读取和赋值,但是可以通过Object.getPrototypeOf来获得某个对象的原型对象,也就是__proto__ 所指向的实际对象。虽然有Object.setPrototypeOf可以修改某个对象的原型对象,但是在文档中会看到红色的提示,主要是提示性能问题,和不同环境实现可能引起问题,推荐使用Object.create在创建时直接变更原型对象。

理解原型对象

不管是__proto__ 还是[[Prototype]] ,都指向传说中的原型对象,原型模式为了解决类型与实例之间的共享问题,而原型对象最直观的作用就是共享空间。

之前也了解了原型对象是挂在函数的prototype属性上的,这是创建函数时根据特定的规则,由语言引擎自动添加的。这个原型对象自动获取一个constructor属性,指向这个函数本身,也就是Person.prototype.constructor===Person。而实例本身将拥有一个特殊的指针,这个指针理论上并没有名字,但是为了具体化,浏览器会通过可见但不可用的__proto__属性来表达,而在语言规范中,用[[Prototype]]来说明有这么一个特殊的指针。这个指针就是指向构造函数的原型对象,也就是person1.__proto__ === Person.prototype。

回到之前的工厂方法与构造方法的对比,new操作符会内在的创建一个对象,并且这个对象会包含一个constructor属性指向构造函数,依次来作为类型划分的标志,但是实际上并不是直接这么做的。为什么这么说呢,是因为如果实例上直接挂了一个属性,值是一个指向构造函数的指针,还是没有彻底解决封装的问题,你会发现和原型模式要解决的问题类似。之所以能得到person1.constructor === Person这个结论,那就还要在了解对象属性的搜索,这对理解原型以及原型链非常有帮助。

对象属性搜索

读取对象的属性,实际上是一个搜索的过程,在对象自身搜索,或者在原型对象上搜索。这个搜索在原型对象上实际是一个递归过程。对象本身可以认为是一个属性集合,所以访问属性会先从这个集合中进行搜索,有就返回,没有就通过内置的指针找对原型,递归这个搜索过程。

假如有以下代码环境:

function Person() {
    
}

function OldPerson() {
    this.name = "hahahah";
    this.sayHi = function() {
        console.log(this.name);
    }
}

Person.prototype.name = "hahaha";

Person.prototype.sayHi = function() {
    console.log("name:", this.name);
}

let person1 = new Person();
let person2 = new OldPerson();

console.log(Object.getOwnPropertyNames(person1));
console.log(Object.getOwnPropertyNames(person2));

不难发现person1本身就像是一个空的属性集合,for-in递归也会发现没有属性;而person2是包含了具体属性的。因此person2.sayHi实际上就是在其本身的属性集合中,找到sayHi这个属性并调用;而person1.sayHi就略有不同,其本身并不包含属性,因此要通过其内部的特殊指针__proto__或者说[[Prototype]]得到Person.prototype,对其进行搜索。

这里要注意的是Person.prototype本身是一个对象,虽然没有看到他的全貌,但是他也有自己的原型对象,因此如果我们检索的属性不在Person.prototype上,那检索过程会继续向上。

不难发现,原型另外一个好处就是可以在实例自身层次上屏蔽原型链上的共享属性。因为实例本身作为对象是一个属性集合,访问某个属性没有结果时,会在原型对象上递归搜索。那么如果在对象本身增加了一个同名的属性,那么搜索过程就会提前停止,且不会影响共享的原型对象,其他实例仍然能够共享到原型链上的值。这一点也被用来在原型链继承时“重写”父类的值,通过搜索屏蔽,还不是直接复写。

由Object继承下来的hasOwnProperty方法,可以用来判断某个属性是否在实例自身的属性集合中。

同时注意in操作符,判断是否包含某属性,是一个完整的访问过程,所以在for-in循环的时候要注意这一点。Object.keys这个新的API,可以返回对象直接包含的属性键。Object.getOwnPropertyNames也可以返回,但是会额外返回不可枚举的constructor属性,而Object.keys比较守规矩,只有那些可枚举的属性。

原型字面量的坑

扩展原型对象都是规规矩矩的一个字段一个字段添加,看上去有些傻,就有了之前的想法,对Date.prototype直接挂一个X对象的操作。在了解了原型对象和属性搜索的工作方式后,其实这样的操作也是可以的,只是要额外的进行一个“恢复”的操作。

funciton Person() {}

Person.prototype = {
    name: "hahaha",
    sayHi() {
        console.log(this.name);
    }
} // 这个对象字面量且叫做X吧

let person1 = new Person();

这个时候重新梳理一下:在person1上搜索name属性,能够正确找到X对象中的name;instanceof也能正常识别。但是到底是哪里少了?原型对象通过constructor属性,让所有实例共享这个“类型标记”,虽然instanceof能够正常工作,但是person1.constructor===Person已经不能正常工作。

如果改成这样呢:

Person.prototype = {
  constructor: Person,
    name: "hahaha",
    sayHi() {
        console.log(this.name);
    }
}

看起来很OK,但是constructor这个属性的特性是不可枚举,因此还是要恢复它的特性。

这种情况还算比较好处理的,但是还要考虑一个问题, 就是JavaScript语言的动态性,导致原型在程序中是可以随时修改的。实例包含一个内在的指针指向原型,如果中途突然把构造函数的原型对象直接替换掉,将会产生不符合预期的错误。也就是说,我们要在实例化对象之前,就定义好原型,且中途最好不要在改变。这里要提醒一下,之前我们在描述指针,构造函数和原型之间的关系,person.__proto__ === Person.prototype,这是一个直接指向关系,再由Person.prototype.contructor===Person指向构造函数,因此实例中的内置指针只与原型相关,完全不会和构造函数直接挂钩。

另外!对于原生对象的原型,不推荐扩展,主要是为了防止命名冲突,考虑到不同的环境具体的实现可能不同,最好还是不要随便扩展。

原型模式的缺点

构造函数的原型对象,作为实例的共享空间,尽可能的为了复用代码考虑,但是复用基本值可以,复用引用类型的值就很尴尬了:

function Person(){}
Person.prototype.things = [];

let person1 = new Person();
let person2 = new Person();

person1.things.push("banana");

console.log(person2.things);

person1.things访问拿到原型上的数组引用,操作这个引用显然没啥问题,虽然它在原型对象中,于是乎其他的实例再去访问这个属性,就拿到了别人操作后的数组,且person1.things===person2.things,这难免会引起疑惑和错误。因此,一般来说引用类型的值,会在构造函数中为实例初始化,毕竟这种值通常是实例严格相关,而不是类型严格相关。

还有其他的解决方案,比如动态原型,既然原型可以动态指定,那么就干脆在构造函数中通过逻辑来控制原型的扩展与否。

构造函数与原型模式的结合

这部分本应该属于构造对象章节,但是依赖原型对象和原型模式,所以延迟到现在再介绍。

回到构造函数模式,遗留了一个问题,就是方法共享的问题。有了原型对象,就可以将要共享的方法封在原型对象中:

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.friends = ["Shelby", "Court"];
}

Person.prototype = {
  constructor: Person, // 细考虑的话用defineProperty修正这个属性的特性
  sayName: function () {
    alert(this.name);
  }
}

动态原型模式这里就简单提一下:

function Person(name, age, job) {
  //属性
  this.name = name;
  this.age = age;
  this.job = job;
  // 方法
  if (typeof this.sayName != "function") {
    Person.prototype.sayName = function () {
      alert(this.name);
    };
  }
}

仅在第一次用Person构造对象时扩展原型对象。

寄生构造函数模式也简单说一下,和工厂模式很相似,只是仍然用new来创建对象,利用的是构造方法return会覆盖new创建的对象并返回,读到这你可能已经发现问题了,new的时候创建的对象会挂constructor来区分类型,那被return覆盖了就没有区分类型的属性了。这种模式主要用在扩展,或者说共享内置对象时使用:

function SpecialArray() {
  //创建数组
  var values = new Array();
  //添加值
  values.push.apply(values, arguments);
  //添加方法
  values.toPipedString = function () {
    return this.join("|");
  };
  //返回数组
  return values;
}

还有一个稳妥构造模式,更像工厂了,核心是构造时使用的参数不会直接体现在”实例“上,而且通过闭包暴露访问权限。具体可以参见《高程》的6.2.7。

继承

原型模式和构造函数提供了非常好的共享代码的体系,继承从面向对象的角度来说,是子类继承父类的“描述细节”,从而有父子继承的关系,从代码层面其实就是为了复用部分代码,在复用中求同存异。所以继承通常更多的作为类型区分考虑,在设计层面具有很强的类型一致性,而行为和细节,不推荐通过继承进行传递,因为继承也具有一定的局限性,不利于维护等等,要注意“多组合,少继承”的原则。

对象的__proto__指向其原型对象,原型对象也是普通对象,也有__proto__指向原型对象的原型对象,以此形成原型链。利用这个特性,可以通过原型来向下共享属性和方法。

person的原型对象是Person.prototype,如果Person.prototype是human,human的原型对象是Human.prototype,在person上完全可以访问到human的属性和方法,可以认为Person继承了Human。

原型链与继承

function SuperType() {
  this.property = true;
}

SuperType.prototype.getSuperValue = function () {
  return this.property;
};

function SubType() {
  this.subproperty = false;
}

//继承了SuperType
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function () {
  return this.subproperty;
};

var instance = new SubType();
alert(instance.getSuperValue());//true

instance如何访问到getSuperValue属性的,试着根据对象查找属性的流程自己分析一下。这种模式就是常见的”子类的原型对象是父类的实例“,在原型链上构建上下层关系完成继承。但是你也要发现,instance.constructor不再是SubType而是SuperType。

插播

这里还是要具体说一下instanceof:

instanceof 运算符用来检测 constructor.prototype 是否存在于参数 object 的原型链上。

结合上面的例子,instance instanceof SubType和instance instanceof SuperType表达式值都是true,我们来看一下过程:

instance.__proto__ === SubType.prototype,所以instance instanceof SubType为true

SubType.prototype = new SuperType()

SubType.prototype.__proto__ === SuperType.prototype,所以instance instanceof SuperType为true

起码要知道instanceof是怎么检测的,否则有时候虽然丢了constructor属性,但是仍能找对类型。

插播结束

传统原型链的基本模式,要注意继承覆盖的问题,因为这种模式核心是SubType.prototype = new SuperType(),所以要注意在建立继承关系后,再扩展子类的原型对象。

还有一个巨大的问题就是父类构造方法中,如果声明了一个值为应用类型的属性,会产生共享引用的问题:

function SuperType() {
  this.colors = ["red", "blue", "green"];
}

function SubType() {
}

//继承了SuperType
SubType.prototype = new SuperType();
var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors);//"red,blue,green,black"
var instance2 = new SubType();
alert(instance2.colors);//"red,blue,green,black"

对比一下其他语言的继承,你会发现这种模式的继承,子类的构造完全不影响父类的细节,也就是说父类是固定的,而继承往往需要更具体的构造父类实例的细节,所以这种传统模式更适用于父类相对静止,不受子类构造影响的继承关系。

借用构造函数

为了解决父类实例中包含引用类型值,被子类实例共享而被错误修改,要让父类构造的引用类型属性下放到子类构造过程中,来保证这样的属性不被共享;同时还要解决不能向父类构造方法传递参数的问题,基本上就得让父类构造过程在子类构造过程中显示的进行,根据这样的思路,有这样的继承方式:

function SuperType() {
  this.colors = ["red", "blue", "green"];
}

function SubType() {
  //继承了SuperType
  SuperType.call(this);
}

var instance1 = new SubType();
instance1.colors.push("black");
alert(instance1.colors);//"red,blue,green,black"
var instance2 = new SubType();
alert(instance2.colors);//"red,blue,green"

子类的构造方法,借用父类构造方法来构造自己(this),这样colors属性就被下放到子类的实例上,不存在错误的共享使用,同时call的时候也能传递参数,根据不同的情况也可以使用apply。但是,这种模式有个明显的问题,一眼就可以看出来,父类型构造法的调用是通过call,也就是普通调用修改上下文的方式,并没有用new,所以子类型返回的实例并不包含父类型构造应有的类型信息。另外,如果父类的方法都定义在父类构造函数中,那么又回到构造函数的缺陷问题,如果父类在prototype上扩展方法和属性,子类构造法通过call的方式也无法继承下来。

组合继承(相对重点)

借用父类构造函数构造自己,同时用父类实例修正自己的原型对象,也就是传统经典继承+借用构造函数:

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function () {
  alert(this.name);
};

function SubType(name, age) {
  //继承属性
  SuperType.call(this, name);
  this.age = age;
}

//继承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function () {
  alert(this.age);
};

缺点也是显而易见的,在子类构造方法中call一次,设置子类prototype又要实例化,也就是说父类的构造方法要执行两次。真正new子类时,子类构造法call了父类构造法来装饰自身,同时覆盖掉原型上共享下来的同名属性。

原型式继承(Object.create)

利用原型链,创建继承关系,子类实际隐藏起来,内置的完成原型链的变更:

function object(o) {
    function F(){}
    F.prototype = o;
    return new F();
}

在ECMAScript 5中标准化为Object.create,通过直接指明想要的原型对象,即可创建有明确原型指向关系的继承。这种方式受语言版本压制,且可能存在性能问题。注意Object.create支持第二个参数,提供一个描述符,处理描述符是消耗性能的。关于Object.create的使用方法,可以参见MDN标准文档

总的来说这种模式你会发现没有看到子类的构造函数,而且在object方法中,传入的参数o想被当做原型,可惜只是一个浅复制,如果o上仍存在引用类型值,还是会存在误操作的问题。

寄生式继承

原型式继承用一个临时的构造函数,改造它的原型对象,从而创建一个有继承关系的对象,这样做由于无法直接定义子类的细节,使得构造出来的对象只具有父类的细节。如果想要丰富子类的细节,就在原型式继承的基础上,在封装一层用于扩展子类的细节:

function createAnother(o) {
  // 原文这里本来是用上面的object,用Object.create好理解一些
    let clone = Object.create(o);
    clone.sayHi = function() {
        console.log("hi");
    }
    return clone;
}

这种方式如果将引用类型值挂在clone上,创建实例将能够在一定程度上避免被滥用的问题,但是原型对象o的问题依然没有解决。

可以试想一下缺陷,在向clone添加sayHi的时候,是不是像最开始介绍构造函数的例子,所以也会存在函数复用不理想的问题。

寄生组合式继承

组合寄生的问题也说到过,父类构造函数,在子类构造函数中call一次,同时在定义子类原型对象时创建父类实例又调用一次。试想:定义子类原型对象时,构造一个父类实例,子类原型拥有了父类实例的属性,从而使得子类实例能够通过对象类型搜索访问到这些属性。然而当真正构造子类时,由于”寄生“,用父类的构造过程构造自身,使得子类实例上直接得到父类实例属性,从而屏蔽了上一步子类原型留下来的属性,简单来说就是一条链上总是有重复的属性。

寄生组合式继承,保留子类构造函数中用父类构造自身的过程,从而保证父类实例属性能够留下来,同时改善子类原型对象的关系,不需要在子类的原型上保留父类实例属性,只保留应该保留的原型链关系,即子类的原型对象,是一个以父类原型对象为原型的对象。关键代码如下:

function inheritPrototype(subType, superType) {
    let prototype = Object.create(superType.prototype);
    prototype.constructor = subType;
    subType.prototype = prototype;
}

假设subType是子类SubType的实例,superType是父类SuperType的实例,本来subType.__proto__指向SubType.prototype,而SubType.prototype是superType,会通过superType.__proto__再指向SuperType.prototype。加粗的过程是需要构造父类的。那么寄生组合的作用,不再需要SubType.prototype=new SuperType(),只是额外注意恢复constructor。

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function () {
  alert(this.name);
};

function SubType(name, age) {
  SuperType.call(this, name);
  this.age = age;
}

inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function () {
  alert(this.age);
};

核心就是简化子类原型对象与父类原型对象之间的联系,通过Object.create或者说原型式继承直接建立和父类原型对象的关系,减少不必要的实例构建。