16 react 演示

2017-09-13 14:25:28 mafan121 阅读数 6287

React生命周期详解

React严格定义了组件的生命周期会经历如下三个过程:

  • 装载过程(Mount),组件第一次在DOM树渲染的过程。
  • 更新过程(Update),当组件被重新渲染的过程。
  • 卸载过程(Unmount),组件重DOM树中删除的过程。

执行这3个过程的调用函数就是声明周期函数。

装载过程

该过程会依次调用如下函数:

  • constructor():ES6类的构造函数(为了初始化state或绑定this
  • getInitialState():ES5中初始化state
  • getDefaultProps():ES5中初始化props。在ES6中使用defaultProps()方法。
  • componentWillMount():在组件被挂载前调用。只执行一次。
  • render():渲染组件,必须实现该方法。
  • componentDidMount():在组件装载后调用。这时已经生成了真实的DOM节点。只执行一次。

更新过程

当组件的props或者state改变时就会触发组件的更新过程。

更新过程会依次执行如下函数:

  • componentWillReceiveProps(nextProps):当父组件的render()方法执行后就会触发该方法。初始化时不调用。
  • shouldComponentUpdate(nextProps,nextState):当props改变或state改变时调用,初始化时不掉用,返回booleantrue表示继续执行render方法,fasle表示放弃本次渲染。
  • componentWillUpdate(nextProps,nextState):当shouldComponentUpdate返回true时调用,初始化不调用。
  • render():渲染组件。
  • componentDidUpdate(prevProps,prevState,snapshot):渲染完成后调用,初始化不调。prevPropsprevState是指组件更新前的propsstate

卸载过程

componentWillUnmount():将组件从DOM树移出,防止内存溢出。

在这里插入图片描述

实例演示

组件调用:

import React from 'react'
import App from './components/App.js'
import {render} from 'mirrorx'


// 启动 app,render 方法是加强版的 ReactDOM.render
render(<App/>, document.getElementById('root'))

组件定义:

import React from 'react'
import mirror, { actions, connect, render } from 'mirrorx'

// 声明 Redux state, reducer 和 action,
// 所有的 action 都会以相同名称赋值到全局的 actions 对象上
mirror.model({
    name: 'app',
    initialState: 0,
    reducers: {
        increment(state) { return state + 1 },
        decrement(state) { return state - 1 }
    },
    effects: {
        async incrementAsync() {
            await new Promise((resolve, reject) => {
                setTimeout(() => {
                    resolve()
                }, 1000)
            })
            actions.app.increment()
        }
    }
})

class App extends React.Component {

    constructor(props){
        super(props)
        console.log("---初始化组件---")
    }

    componentWillMount(){
        console.log("---组件挂载前---")
    }

    componentDidMount(){
        console.log("---组件挂载后---")
    }

    componentWillReceiveProps(nextProps){
        console.log("---父组件重新渲染---")
    }

    shouldComponentUpdate(nextProps,nextState){
        console.log("---组件接受到重绘状态---")
        if(this.props != nextProps || this.state != nextState)
        return true
    }
    
	componentWillUpdate(nextProps,nextState){
	    console.log("---组件即将重绘---")
	}

    componentDidUpdate(prevProps,prevState){
	    console.log("---组件重绘完成---")
	}


    render() {
        console.log("---组件渲染---")
        const props = this.props 
        return (
            <div>
                <h1>{props.count}</h1>
                {/* 调用 actions 上的方法来 dispatch action */}
                <button onClick={() => actions.app.decrement()} style={{ margin: "5px" }}>-</button>
                <button onClick={() => actions.app.increment()} style={{ margin: "5px" }}>+</button>
                {/* dispatch async action */}
                <button onClick={() => actions.app.incrementAsync()} style={{ margin: "5px" }}>+ Async</button>
            </div>
        )
    }
}

export default connect((state) => {
    return {
        count: state.app
    }
})(App)

页面效果

这里写图片描述

当页面刷新时,组件进行初始化价值,打印信息如下:

—初始化组件—
—组件挂载前—
—组件渲染—
—组件挂载后—

当点击+号时,组件进行更新操作,打印信息如下:

—父组件重新渲染—
—组件接受到重绘状态—
—组件即将重绘—
—组件渲染—
—组件重绘完成—

React 16.0版本修改

React 16.0之后的版本生命周期有所修改。

react 16版本提出了异步渲染的概念,对之前的生命周期有较大的影响。

componentDidCatch(error,info):该生命周期是React 16.0版本提出的,用于捕获render阶段出现的错误,并不影响之前的生命周期。

static getDerivedStateFromProps(props, state):render()之前调用,它应该返回一个对象来更新状态,或者返回null来不更新任何内容。组件创建和更新时均将调用。在React 16.3提出,16.4版本修复,属于新增生命周期。

getSnapshotBeforeUpdate(prevProps, prevState):在render之后componentDidUpdate之前调用,可以读取dom结构,但无法操作dom元素,其返回的任何值都将传递给componentDidUpdate

在React 17版本中将废弃componentWillMountcomponentWillReceivePropscomponentWillUpdate这3个方法,但也提供了带UNSAFE_componentWillMountUNSAFE_componentWillReceivePropsUNSAFE_componentWillUpdate作为兼容考虑,只是不提倡使用。

在这里插入图片描述

2019-04-14 22:06:23 itwangyang520 阅读数 161

今天,本章节会介绍一些 React 的基础知识和基本用法。已经入门 React 基础的同学,可以简单看看这篇文档内容。React 零基础的同学还建议去慕课网学习React入门基础

另外,下面讲到的代码将全部使用 es6 语法,教程中我会介绍一些用到的 es6 语法,但是不会从头讲解了,推荐阅读es6入门

hello world

以下是一个最简单的demo,将一个最简单的组件渲染到页面上。

import React from 'react'
import { render } from 'react-dom'

// 定义组件
class Hello extends React.Component {
    render() {
        // return 里面写jsx语法
        return (
            <p>hello world</p>
        )
    }
}

// 渲染组件到页面
render(
    <Hello/>,
    document.getElementById('root')
)

深入一下,这里import React from 'react'引用的是什么?

这里的'react'对应的就是./package.json文件中dependencies中的'react',即在该目录下用npm install安装的 react 。npm 安装的 react 的物理文件是存放在 ./node_modules/react中的,因此引用的东西肯定就在这个文件夹里面。

打开./node_modules/react/package.json找到"main": "react.js",,这里的main即指定了入口文件,即./node_modules/react/react.js这个文件。那么,问题的答案自然就出来了。

jsx 语法

React 里面写模板要使用 jsx 语法,它其实和 html 很相似但是又有那么几点不一样。下面简单介绍一下 jsx 语法的一些特点:

使用一个父节点包裹

jsx 中不能一次性返回零散的多个节点,如果有多个请包涵在一个节点中。例如,

// 三个 <p> 外面必须再包裹一层 <div>
return (
  <div>
    <p>段落1</p>
    <p>段落2</p>
    <p>段落3</p>
  </div>
)

再例如:

// { } 中返回的两个 <p> 也要用 <div> 包裹
return (
  <div>
    <p>段落1</p>
    {
      true 
      ? <p>true</p>
      : <div>
        <p>false 1</p>
        <p>false 2</p>
      </div>
    }
  </div>
)

注释

jsx 中用{/* */}的注释形式

        return (
            // jsx 外面的注释
            <div>
                {/* jsx 里面的注释 */}
                <p>hello world</p>
            </div>
        )

样式

对应 html 的两种形式,jsx 的样式可以这样写:
css样式:<p className="class1">hello world</p>,注意这里是className,而 html 中是class
内联样式:<p style={{display: 'block', fontSize: '20px'}}>hello world</p>,注意这里的{{...}},还有fontSize的驼峰式写法

事件

拿 click 事件为例,要在标签上绑定 click 事件,可以这样写

class Hello extends React.Component {
    render() {
        return (
            <p onClick={this.clickHandler.bind(this)}>hello world</p>
        )
    }

    clickHandler(e) {
        // e 即js中的事件对象,例如 e.preventDefault()
        // 函数执行时 this 即组件本身,因为上面的 .bind(this)
        console.log(Date.now())
    }
}

注意,onClick是驼峰式写法,以及.bind(this)的作用

循环

在 jsx 中使用循环,一般会用到Array.prototype.map(来自ES5标准)

class Hello extends React.Component {
    render() {
        const arr = ['a', 'b', 'c']
        return (
            <div>
                {arr.map((item, index) => {
                    return <p key={index}>this is {item}</p>
                })}
            </div>
        )
    }
}

注意,arr.map是包裹在{}中的,key={index}有助于React的渲染优化,jsx中的{}可放一个可执行的 js 程序或者变量

判断

jsx中使用判断一般会用到三元表达式(表达式也是放在{}中的),例如:

return (
  <div>
    <p>段落1</p>
    {
      true 
      ? <p>true</p>
      : <p>false</p>
      </div>
    }
  </div>
)

也可以这样使用:

<p style={{display: true ? 'block' ? 'none'}}>hello world</p>

代码分离

之前的demo代码都是在一个文件中,实际开发中不可能是这样子的,因此这里就先把组件的代码给拆分开。我们将使用 es6 的模块管理规范。

page 层

创建./app/containers/Hello/index.jsx文件,将之前创建组件代码复制进去

import React from 'react'

class Hello extends React.Component {
    render() {
        return (
             <p>hello world</p>
        )
    }
}

export default Hello

然后./app/index.jsx中代码就可以这样写。

import Hello from './containers/Hello';

render(
    <Hello/>,
    document.getElementById('root')
)

注意,代码import Hello from './containers/Hello';这里可以写成./containers/Hello/index.jsx也可以写成./containers/Hello/index

subpage 层

如果Hello组件再稍微复杂一点,那么把代码都放一块也会变得复杂,接下来我们再拆分。

创建./app/containers/Hello/subpage目录,然后在其下创建三个文件Carousel.jsx Recommend.jsx List.jsx,分别写入相应的代码(看代码文件即可),然后./app/containers/Hello/index.js中即可这样写

import Carousel from './subpage/Carousel'
import Recommend from './subpage/Recommend'
import List from './subpage/List'

class Hello extends React.Component {
    render() {
        return (
            <div>
                <p>hello world</p>
                <hr/>
                <Carousel/>
                <Recommend/>
                <List/>
            </div>
        )
    }
}

注意,这里import.jsx后缀省略了。

component 层

以上介绍的是页面和复杂页面的拆分,但那都是页面层级的,即page层。这里复杂页面拆分为subpage其实没啥特别的,就是把复杂页面的代码拆分一下,会更加符合开放封闭原则。而且,只有复杂页面才有必要去拆分,简单页面根本没必要拆分。因此,无论是page还是subpage它都是页面级别的。

页面的特点是其独特性,一个页面就需要创建一个文件(如果两个页面可以共用一个文件,这是设计不合理,得治)。而页面其中的内容,就不一定是这样子了。例如,现在的APP每个页面最上面都会有个 header ,即可以显示标题,可以返回。每个页面都有,样子差不多,难道我们要为每个页面都做一个?——当然不是。

创建./app/components/Header/index.jsx文件,简单写入一个组件的代码(见源码文件),然后在./app/containers/index.jsx中引用

import Header from '../../components/Header'

class Hello extends React.Component {
    render() {
        return (
            <div>
                <Header/>
                {/* 省略其他内容 */}
            </div>
        )
    }
}

Hello 页面会用到 Header,以后的其他页面也会用到 Header ,我们把多个页面都可能用到的功能,封装到一个组件中,代码放在./app/components下。

数据传递 & 数据变化

props

接着刚才 Header 的话题往下说,每个页面都会使用 Header ,但是 Header 上显示的标题每个页面肯定是不一样的。我们需要这样解决:页面中引用Header时,这样写 <Header title="Hello页面"/>,即给 Header 组件设置一个 title 属性。而在 Header 组件中可以这样取到

    render() {
        return (
             <p>{this.props.title}</p>
        )
    }

在 React 中,父组件给子组件传递数据时,就是以上方式,通过给子组件设置 props 的方式,子组件取得 props 中的值即可完成数据传递。被传递数据的格式可以是任何 js 可识别的数据结构,上面demo是一个字符串。React 中,props 一般只作为父组件给子组件传递数据用,不要试图去修改自己的 props ,除非你想自找麻烦

props && state

上面提到了 props 不能被自身修改,如果组件内部自身的属性发生变化,该怎么办?—— React 为我们提供给了 state,先看一个demo:

class Hello extends React.Component {
    constructor(props, context) {
        super(props, context);
        this.state = {
            // 显示当前时间
            now: Date.now()
        }
    }
    render() {
        return (
            <div>
                <p>hello world {this.state.now}</p>
            </div>
        )
    }
}

还有一点非常重要,React 会实时监听每个组件的 props 和 state 的值,一旦有变化,会立刻更新组件,将结果重新渲染到页面上,下面demo演示了state的变化,props也是一样的

class Hello extends React.Component {
    constructor(props, context) {
        super(props, context);
        this.state = {
            // 显示当前时间
            now: Date.now()
        }
    }
    render() {
        return (
            <div>
                <p onClick={this.clickHandler.bind(this)}>hello world {this.state.now}</p>
            </div>
        )
    }
    clickHandler() {
        // 设置 state 的值的时候,一定要用 this.setState ,不能直接赋值修改
        this.setState({
            now: Date.now()
        })
    }
}

智能组件 & 木偶组件

这是用 React 做系统设计时的两个非常重要的概念。虽然在 React 中,所有的单位都叫做“组件”,但是通过以上例子,我们还是将它们分别放在了./app/containers./app/components两个文件夹中。为何要分开呢?

  • 智能组件 在日常开发中,我们也简称“页面”。为何说它“智能”,因为它只会做一些很聪明的事儿,脏活累活都不干。它只对数据负责,只需要获取了数据、定义好数据操作的相关函数,然后将这些数据、函数直接传递给具体实现的组件即可。
  • 木偶组件 这里“木偶”一词用的特别形象,它总是被人拿线牵着。它从智能组件(或页面)那里接受到数据、函数,然后就开始做一些展示工作,它的工作就是把拿到的数据展示给用户,函数操作开放给用户。至于数据内容是什么,函数操作是什么,它不关心。

以上两个如果不是理解的很深刻,待把课程学完再回头看一下这两句话,相信会理解的。

生命周期

React 详细的生命周期可参见这里(这里面很详细的-一定要仔细看),也可查阅本文档一开始的视频教程。这里我们重点介绍这个项目开发中常用的几个生命周期函数(hook),相信你在接下来的 React 开发中,也会常用这些。

以下声明周期,也没必要每个都写demo来解释,先简单了解一下,后面会根据实际的例子来解释,这样会更加易懂。

  • getInitialState

初始化组件 state 数据,但是在 es6 的语法中,我们可以使用以下书写方式代替

class Hello extends React.Component {
    constructor(props, context) {
        super(props, context);
        // 初始化组件 state 数据
        this.state = {
            now: Date.now()
        }
    }
}
  • render

最常用的hook,返回组件要渲染的模板。

  • comopentDidMount

组件第一次加载时渲染完成的事件,一般在此获取网络数据。实际开始项目开发时,会经常用到。

  • shouldComponentUpdate

主要用于性能优化,React 的性能优化也是一个很重要的话题,后面一并讲解。

  • componentDidUpdate

组件更新了之后触发的事件,一般用于清空并更新数据。实际开始项目开发时,会经常用到。

  • componentWillUnmount

组件在销毁之前触发的事件,一般用户存储一些特殊信息,以及清理setTimeout事件等。


 

2019-08-04 22:29:30 qq_22833925 阅读数 442

文章首发于个人博客

这是我 Deep In React 系列的第二篇文章,如果还没有读过的强烈建议你先读第一篇:详谈 React Fiber 架构(1)

前言

我相信在看这篇文章的读者一般都已经了解过 React 16 以前的 Diff 算法了,这个算法也算是 React 跨时代或者说最有影响力的一点了,使 React 在保持了可维护性的基础上性能大大的提高,但 Diff 过程不仅不是免费的,而且对性能影响很大,有时候更新页面的时候往往 Diff 所花的时间 js 运行时间比 Rendering 和 Painting 花费更多的时间,所以我一直传达的观念是 React 或者说框架的意义是为了提高代码的可维护性,而不是为了提高性能的,现在所做的提升性能的操作,只是在可维护性的基础上对性能的优化。具体可以参考我公众号以前发的这两篇文章:

如果你对标题不满意,请把文章看完,至少也得把文章最后的结论好好看下

在上一篇将 React Fiber 架构中,已经说到过,React 现在将整体的数据结构从树改为了链表结构。所以相应的 Diff 算法也得改变,以为以前的 Diff 算法就是基于树的。

老的 Diff 算法提出了三个策略来保证整体界面构建的性能,具体是:

  1. Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
  2. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
  3. 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

基于以上三个前提策略,React 分别对 tree diff、component diff 以及 element diff 进行算法优化。

具体老的算法可以见这篇文章:React 源码剖析系列 - 不可思议的 react diff

说实话,老的 Diff 算法还是挺复杂的,你仅仅看上面这篇文章估计一时半会都不能理解,更别说看源码了。对于 React 16 的 Diff 算法(我觉得都不能把它称作算法,最多叫个 Diff 策略)其实还是蛮简单的,React 16 是整个调度流程感觉比较难,我在前面将 Fiber 的文章已经简单的梳理过了,后面也会慢慢的逐个攻破。

接下来就开始正式的讲解 React 16 的 Diff 策略吧!

Diff 简介

做 Diff 的目的就是为了复用节点。

链表的每一个节点是 Fiber,而不是在 16 之前的虚拟DOM 节点。

我这里说的虚拟 DOM 节点是指 React.createElement 方法所产生的节点。虚拟 DOM tree 只维护了组件状态以及组件与 DOM 树的关系,Fiber Node 承载的东西比 虚拟 DOM 节点多很多。

Diff 就是新旧节点的对比,在上一篇中也说道了,这里面的 Diff 主要是构建 currentInWorkProgress 的过程,同时得到 Effect List,给下一个阶段 commit 做准备。

React16 的 diff 策略采用从链表头部开始比较的算法,是层次遍历,算法是建立在一个节点的插入、删除、移动等操作都是在节点树的同一层级中进行的。

对于 Diff, 新老节点的对比,我们以新节点为标准,然后来构建整个 currentInWorkProgress,对于新的 children 会有四种情况。

  • TextNode(包含字符串和数字)
  • 单个 React Element(通过该节点是否有 $$typeof 区分)
  • 数组
  • 可迭代的 children,跟数组的处理方式差不多

那么我们就来一步一步的看这四种类型是如何进行 diff 的。

前置知识介绍

这篇文章主要是从 React 的源码的逻辑出发介绍的,所以介绍之前了解下只怎么进入到这个 diff 函数的,react 的 diff 算法是从 reconcileChildren 开始的

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderExpirationTime: ExpirationTime,
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderExpirationTime,
    );
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderExpirationTime,
    );
  }
}

reconcileChildren 只是一个入口函数,如果首次渲染,current 空 null,就通过 mountChildFibers 创建子节点的 Fiber 实例。如果不是首次渲染,就调用 reconcileChildFibers去做 diff,然后得出 effect list。

接下来再看看 mountChildFibers 和 reconcileChildFibers 有什么区别:

export const reconcileChildFibers = ChildReconciler(true);
export const mountChildFibers = ChildReconciler(false);

他们都是通过 ChildReconciler 函数来的,只是传递的参数不同而已。这个参数叫shouldTrackSideEffects,他的作用是判断是否要增加一些effectTag,主要是用来优化初次渲染的,因为初次渲染没有更新操作。

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  expirationTime: ExpirationTime,
): Fiber | null {
  // 主要的 Diff 逻辑
}

reconcileChildFibers 就是 Diff 部分的主体代码,这个函数超级长,是一个包装函数,下面所有的 diff 代码都在这里面,详细的源码注释可以见这里

参数介绍

  • returnFiber 是即将 Diff 的这层的父节点。
  • currentFirstChild是当前层的第一个 Fiber 节点。
  • newChild 是即将更新的 vdom 节点(可能是 TextNode、可能是 ReactElement,可能是数组),不是 Fiber 节点
  • expirationTime 是过期时间,这个参数是跟调度有关系的,本系列还没讲解,当然跟 Diff 也没有关系。

再次提醒,reconcileChildFibers 是 reconcile(diff) 的一层。

前置知识介绍完毕,就开始详细介绍每一种节点是如何进行 Diff 的。

Diff TextNode

首先看 TextNode,因为它是最简单的,担心直接看到难的,然后就打击你的信心。

看下面两个小 demo:

// demo1:当前 ui 对应的节点的 jsx
return (
  <div>
  // ...
      <div>
          <xxx></xxx>
          <xxx></xxx>
      </div>
  //...
    </div>
)

// demo2:更新成功后的节点对应的 jsx

return (
  <div>
  // ...
      <div>
          前端桃园
      </div>
  //...
    </div>
)

对应的单链表结构图:

image-20190714223931338

对于 diff TextNode 会有两种情况。

  1. currentFirstNode 是 TextNode
  2. currentFirstNode 不是 TextNode

currentFirstNode 是当前该层的第一个节点,reconcileChildFibers 传进来的参数。

为什么要分两种情况呢?原因就是为了复用节点

第一种情况。xxx 是一个 TextNode,那么就代表这这个节点可以复用,有复用的节点,对性能优化很有帮助。既然新的 child 只有一个 TextNode,那么复用节点之后,就把剩下的 aaa 节点就可以删掉了,那么 div 的 child 就可以添加到 workInProgress 中去了。

源码如下:

if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
      // We already have an existing node so let's just update it and delete
      // the rest.
      deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
      const existing = useFiber(currentFirstChild, textContent, expirationTime);
      existing.return = returnFiber;
      return existing;
}

在源码里 useFiber 就是复用节点的方法,deleteRemainingChildren 就是删除剩余节点的方法,这里是从 currentFirstChild.sibling 开始删除的。

第二种情况。xxx 不是一个 TextNode,那么就代表这个节点不能复用,所以就从 currentFirstChild开始删掉剩余的节点,对应到上面的图中就是删除掉 xxx 节点和 aaa 节点。

对于源码如下:

deleteRemainingChildren(returnFiber, currentFirstChild);
const created = createFiberFromText(
    textContent,
    returnFiber.mode,
    expirationTime,
);
created.return = returnFiber;

其中 createFiberFromText 就是根据 textContent 来创建节点的方法。

注意:删除节点不会真的从链表里面把节点删除,只是打一个 delete 的 tag,当 commit 的时候才会真正的去删除。

Diff React Element

有了上面 TextNode 的 Diff 经验,那么来理解 React Element 的 Diff 就比较简单了,因为他们的思路是一致的:先找有没有可以复用的节点,如果没有就另外创建一个。

那么就有一个问题,如何判断这个节点是否可以复用呢?

有两个点:1. key 相同。 2. 节点的类型相同。

如果以上两点相同,就代表这个节点只是变化了内容,不需要创建新的节点,可以复用的。

对应的源码如下:

if (child.key === key) {
  if (
    child.tag === Fragment
    ? element.type === REACT_FRAGMENT_TYPE
    : child.elementType === element.type
  ) {
    // 为什么要删除老的节点的兄弟节点?
    // 因为当前节点是只有一个节点,而老的如果是有兄弟节点是要删除的,是多于的。删掉了之后就可以复用老的节点了
    deleteRemainingChildren(returnFiber, child.sibling);
    // 复用当前节点
    const existing = useFiber(
      child,
      element.type === REACT_FRAGMENT_TYPE
      ? element.props.children
      : element.props,
      expirationTime,
    );
    existing.ref = coerceRef(returnFiber, child, element);
    existing.return = returnFiber;
    return existing;
}

相信这些代码都很好理解了,除了判断条件跟前面 TextNode 的判断条件不一样,其余的基本都一样,只是 React Element 多了一个跟新 ref 的过程。

同样,如果节点的类型不相同,就将节点从当前节点开始把剩余的都删除。

deleteRemainingChildren(returnFiber, child);

到这里,可能你们就会觉得接下来应该就是讲解当没有可以复用的节点的时候是如果创建节点的。

不过可惜你们猜错了。因为 Facebook 的工程师很厉害,另外还做了一个工作来优化,来找到复用的节点。

我们现在来看这种情况:

image-20190714232052778

这种情况就是有可能更新的时候删除了一个节点,但是另外的节点还留着。

那么在对比 xxx 节点和 AAA 节点的时候,它们的节点类型是不一样,按照我们上面的逻辑,还是应该把 xxx 和 AAA 节点删除,然后创建一个 AAA 节点。

但是你看,明明 xxx 的 slibling 有一个 AAA 节点可以复用,但是被删了,多浪费呀。所以还有另外有一个策略来找 xxx 的所有兄弟节点中有没有可以复用的节点。

这种策略就是从 div 下面的所有子节点去找有没有可以复用的节点,而不是像 TextNode 一样,只是找第一个 child 是否可以复用,如果当前节点的 key 不同,就代表肯定不是同一个节点,所以把当前节点删除,然后再去找当前节点的兄弟节点,直到找到 key 相同,并且节点的类型相同,否则就删除所有的子节点。

你有木有这样的问题:为什么 TextNode 不采用这样的循环策略来找可以复用的节点呢?这个问题留给你思考,欢迎在评论区留下你的答案。

对应的源码逻辑如下:

// 找到 key 相同的节点,就会复用当前节点
while (child !== null) {
  if (child.key === key) {
    if (
      child.tag === Fragment
      ? element.type === REACT_FRAGMENT_TYPE
      : child.elementType === element.type
    ) {
      // 复用节点逻辑,省略该部分代码,和上面复用节点的代码相同
      // code ...
      return existing;
    } else {
      deleteRemainingChildren(returnFiber, child);
      break;
    }
  } else {
    // 如果没有可以复用的节点,就把这个节点删除
    deleteChild(returnFiber, child);
  }
  child = child.sibling;
}

在上面这段代码我们需要注意的是,当 key 相同,React 会认为是同一个节点,所以当 key 相同,节点类型不同的时候,React 会认为你已经把这个节点重新覆盖了,所以就不会再去找剩余的节点是否可以复用。只有在 key 不同的时候,才会去找兄弟节点是否可以复用。

接下来才是我们前面说的,如果没有找到可以复用的节点,然后就重新创建节点,源码如下:

// 前面的循环已经把该删除的已经删除了,接下来就开始创建新的节点了
if (element.type === REACT_FRAGMENT_TYPE) {
  const created = createFiberFromFragment(
    element.props.children,
    returnFiber.mode,
    expirationTime,
    element.key,
  );
  created.return = returnFiber;
  return created;
} else {
  const created = createFiberFromElement(
    element,
    returnFiber.mode,
    expirationTime,
  );
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

对于 Fragment 节点和一般的 Element 节点创建的方式不同,因为 Fragment 本来就是一个无意义的节点,他真正需要创建 Fiber 的是它的 children,而不是它自己,所以 createFiberFromFragment 传递的不是 element ,而是 element.props.children

Diff Array

Diff Array 算是 Diff 中最难的一部分了,比较的复杂,因为做了很多的优化,不过请你放心,认真看完我的讲解,最难的也会很容易理解,废话不多说,开始吧!

因为 Fiber 树是单链表结构,没有子节点数组这样的数据结构,也就没有可以供两端同时比较的尾部游标。所以React的这个算法是一个简化的两端比较法,只从头部开始比较。

前面已经说了,Diff 的目的就是为了复用,对于 Array 就不能像之前的节点那样,仅仅对比一下元素的 key 或者 元素类型就行,因为数组里面是好多个元素。你可以在头脑里思考两分钟如何进行复用节点,再看 React 是怎么做的,然后对比一下孰优孰劣。

1. 相同位置(index)进行比较

相同位置进行对比,这个是比较容易想到的一种方式,还是举个例子加深一下印象。

image-20190721212259855

这已经是一个非常简单的例子了,div 的 child 是一个数组,有 AAA、BBB 然后还有其他的兄弟节点,在做 diff 的时候就可以从新旧的数组中按照索引一一对比,如果可以复用,就把这个节点从老的链表里面删除,不能复用的话再进行其他的复用策略。

那如果判断节点是否可以复用呢?有了前面的 ReactElement 和 TextNode 复用的经验,这个也类似,因为是一一对比嘛,相当于是一个节点一个节点的对比。

不过对于 newChild 可能会有很多种类型,简单的看下源码是如何进行判断的。

 const key = oldFiber !== null ? oldFiber.key : null;

前面的经验可得,判断是否可以复用,常常会根据 key 是否相同来决定,所以首先获取了老节点的 key 是否存在。如果不存在老节点很可能是 TextNode 或者是 Fragment。

接下来再看 newChild 为不同类型的时候是如何进行处理的。

当 newChild 是 TextNode 的时候

if (typeof newChild === 'string' || typeof newChild === 'number') {
  // 对于新的节点如果是 string 或者 number,那么都是没有 key 的,
  // 所有如果老的节点有 key 的话,就不能复用,直接返回 null。
  // 老的节点 key 为 null 的话,代表老的节点是文本节点,就可以复用
  if (key !== null) {
    return null;
  }

  return updateTextNode(
    returnFiber,
    oldFiber,
    '' + newChild,
    expirationTime,
  );
}

如果 key 不为 null,那么就代表老节点不是 TextNode,而新节点又是 TextNode,所以返回 null,不能复用,反之则可以复用,调用 updateTextNode 方法。

注意,updateTextNode 里面包含了首次渲染的时候的逻辑,首次渲染的时候回插入一个 TextNode,而不是复用。

当 newChild 是 Object 的时候

newChild 是 Object 的时候基本上走的就是 ReactElement 的逻辑了,判断 key 和 元素的类型是否相等来判断是否可以复用。

if (typeof newChild === 'object' && newChild !== null) {
  // 有 $$typeof 代表就是 ReactElement
  switch (newChild.$$typeof) {
    case REACT_ELEMENT_TYPE: {
                // ReactElement 的逻辑 
    }
    case REACT_PORTAL_TYPE: {
                // 调用 updatePortal
    }
  }

  if (isArray(newChild) || getIteratorFn(newChild)) {
    if (key !== null) {
      return null;
    }

    return updateFragment(
      returnFiber,
      oldFiber,
      newChild,
      expirationTime,
      null,
    );
  }
}

首先判断是否是对象,用的是 typeof newChild === 'object' && newChild !== null ,注意要加 !== null,因为 typeof null 也是 object。

然后通过 $$typeof 判断是 REACTELEMENTTYPE 还是 REACTPORTALTYPE,分别调用不同的复用逻辑,然后由于数组也是 Object ,所以这个 if 里面也有数组的复用逻辑。

我相信到这里应该对于应该对于如何相同位置的节点如何对比有清晰的认识了。另外还有问题,那就是如何循环一个一个对比呢?

这里要注意,新的节点的 children 是虚拟 DOM,所以这个 children 是一个数组,而对于之前提到的老的节点树是链表。

那么循环一个一个对比,就是遍历数组的过程。

let newIdx = 0 // 新数组的索引
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 遍历老的节点
  nextOldFiber = oldFiber.sibling; 
  // 返回复用节点的函数,newFiber 就是复用的节点。
  // 如果为空,就代表同位置对比已经不能复用了,循环结束。
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx],
    expirationTime,
  );
  
  if (newFiber === null) {
    break;
  }
  
  // 其他 code,比如删除复用的节点
}

这并不是源码的全部源码,我只是把思路给贴出来了。

这是第一次遍历新数组,通过调用 updateSlot 来对比新老元素,前面介绍的如何对比新老节点的代码都是在这个函数里。这个循环会把所以的从前面开始能复用的节点,都复用到。比如上面我们画的图,如果两个链表里面的 ???节点,不相同,那么 newFiber 为 null,这个循环就会跳出。

跳出来了,就会有两种情况。

  • 新节点已经遍历完毕
  • 老节点已经遍历完毕

2. 新节点已经遍历完毕

如果新节点已经遍历完毕的话,也就是没有要更新的了,这种情况一般就是从原来的数组里面删除了元素,那么直接把剩下的老节点删除了就行了。还是拿上面的图的例子举例,老的链表里???还有很多节点,而新的链表???已经没有节点了,所以老的链表???不管是有多少节点,都不能复用了,所以没用了,直接删除。

if (newIdx === newChildren.length) {
  // 新的 children 长度已经够了,所以把剩下的删除掉
  deleteRemainingChildren(returnFiber, oldFiber);
  return resultingFirstChild;
}

注意这里是直接 return 了哦,没有继续往下执行了。

3. 老节点已经遍历完毕

如果老的节点在第一次循环的时候就被复用完了,新的节点还有,很有可能就是新增了节点的情况。那么这个时候只需要根据把剩余新的节点直接创建 Fiber 就行了。

if (oldFiber === null) {
  // 如果老的节点已经被复用完了,对剩下的新节点进行操作
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(
      returnFiber,
      newChildren[newIdx],
      expirationTime,
    );
  }
  return resultingFirstChild;
}

oldFiber === null 就是用来判断老的 Fiber 节点变量完了的代码,Fiber 链表是一个单向链表,所以为 null 的时候代表已经结束了。所以就直接把剩余的 newChild 通过循环创建 Fiber。

到这里,目前简单的对数组进行增、删节点的对比还是比较简单,接下来就是移动的情况是如何进行复用的呢?

4. 移动的情况如何进行节点复用

对于移动的情况,首先要思考,怎么能判断数组是否发生过移动操作呢?

如果给你两个数组,你是否能判断出来数组是否发生过移动。

答案是:老的数组和新的数组里面都有这个元素,而且位置不相同。

从两个数组中找到相同元素(是指可复用的节点),方法有很多种,来看看 React 是如何高效的找出来的。

把所有老数组元素按 key 或者是 index 放 Map 里,然后遍历新数组,根据新数组的 key 或者 index 快速找到老数组里面是否有可复用的。

function mapRemainingChildren(
 returnFiber: Fiber,
 currentFirstChild: Fiber,
): Map<string | number, Fiber> {
  const existingChildren: Map<string | number, Fiber> = new Map();

  let existingChild = currentFirstChild; // currentFirstChild 是老数组链表的第一个元素
  while (existingChild !== null) {
  // 看到这里可能会疑惑怎么在 Map 里面的key 是 fiber 的key 还是 fiber 的 index 呢?
  // 我觉得是根据数据类型,fiber 的key 是字符串,而 index 是数字,这样就能区分了
  // 所以这里是用的 map,而不是对象,如果是对象的key 就不能区分 字符串类型和数字类型了。
    if (existingChild.key !== null) {
      existingChildren.set(existingChild.key, existingChild);
    } else {
      existingChildren.set(existingChild.index, existingChild);
    }
    existingChild = existingChild.sibling;
    }
    return existingChildren;
}

这个 mapRemainingChildren 就是将老数组存放到 Map 里面。元素有 key 就 Map 的键就存 key,没有 key 就存 index,key 一定是字符串,index 一定是 number,所以取的时候是能区分的,所以这里用的是 Map,而不是对象,如果是对象,属性是字符串,就没办法区别是 key 还是 index 了。

现在有了这个 Map,剩下的就是循环新数组,找到 Map 里面可以复用的节点,如果找不到就创建,这个逻辑基本上跟 updateSlot 的复用逻辑很像,一个是从老数组链表中获取节点对比,一个是从 Map 里获取节点对比。

// 如果前面的算法有复用,那么 newIdx 就不从 0 开始
for (; newIdx < newChildren.length; newIdx++) {
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    expirationTime,
  );
 // 省略删除 existingChildren 中的元素和添加 Placement 副作用的情况
}

到这里新数组遍历完毕,也就是同一层的 Diff 过程完毕,接下来进行总结一下。

效果演示

以下效果动态演示来自于文章:React Diff 源码分析,我觉得这个演示非常的形象,有助于理解。

这里渲染一个可输入的数组。1

当第一种情况,新数组遍历完了,老数组剩余直接删除(12345→1234 删除 5):

img

新数组没完,老数组完了(1234→1234567 插入 567):

img

移动的情况,即之前就存在这个元素,后续只是顺序改变(123 → 4321 插入4,移动2 1):

img

最后删除没有涉及的元素。

总结

对于数组的 diff 策略,相对比较复杂,最后来梳理一下这个策略,其实还是很简单,只是看源码的时候比较难懂。

我们可以把整个过程分为三个阶段:

  1. 第一遍历新数组,新老数组相同 index 进行对比,通过 updateSlot方法找到可以复用的节点,直到找到不可以复用的节点就退出循环。
  2. 第一遍历完之后,删除剩余的老节点,追加剩余的新节点的过程。如果是新节点已遍历完成,就将剩余的老节点批量删除;如果是老节点遍历完成仍有新节点剩余,则将新节点直接插入。
  3. 把所有老数组元素按 key 或 index 放 Map 里,然后遍历新数组,插入老数组的元素,这是移动的情况。

后记

刚开始阅读源码的过程是非常的痛苦的,但是当你一遍一遍的把作者想要表达的理解了,为什么要这么写 理解了,会感到作者的设计是如此的精妙绝伦,每一个变量,每一行代码感觉都是精心设计过的,然后感受到自己与大牛的差距,激发自己的动力。

更多的对于 React 原理相关,源码相关的内容,请关注我的 github:Deep In React 或者 个人博客:桃园

我是桃翁,一个爱思考的前端er,想了解关于更多的前端相关的,请关注我的公号:「前端桃园」

2018-12-12 09:55:09 nnxxyy1111 阅读数 1216

自2017年9月React正式发布了16以来,已经有一年多的时间。相信各项目也都已经完成了升级工作,相信这个过程应该是轻松愉快的。当然,前提是你的项目之前升级过15.6或者之后的任意版本,去除掉了各种警告。那么你有关注过16为我们带来了什么吗?它有哪些让人欣喜的变化?这篇文章主要摘取了一些影响我们未来开发或者令人欣喜的新特性新做以总结。

  • Fiber
  • 生命周期函数
  • Context
  • Refs
  • Fragment
  • React Strict Mode

Fiber

介绍Fiber的文章很多,这里只用5张图片简单回忆一下,以方便我们理解生命周期函数变更的原因。

  • Stack reconciler——React在进行组件渲染时,从setState开始直到渲染完成的整个过程都是同步的,整个过程可以类比为函数的递归调用,从最外层父节点开始遍历找到所有的变化节点,然后对所有diff node遍历完成之后才能计算出真是的dom信息,传递给renderer,开始渲染。所以,当React需要渲染一个庞大的vDom时,这个过程就会很长,而在这期间,主线程是一直被占用的,就会出现卡顿,影响用户交互的现象。

  • Fiber reconciler

    可以简单粗暴的理解为异步,渲染工作会被拆分成若干个fiber对象,fiber对象中不仅存储着对应元素的基本信息,还要保存一些用于调度的信息。每个fiber执行完后,都会查看是否还继续拥有主线程时间片,如果有,继续下一个fiber,如果没有,则先处理其它优先级更高的事物。

    在这个异步的过程中,生命周期函数分别在两个不同的阶段执行——render和commit

    当重新获得主线程时间片时,之前被中断的任务并不会继续之前的工作,而是重新开始,也就是说,render阶段的生命周期函数可能会被多次执行。所以,对于某些只期望执行一次,或者需要在两个生命周期函数的操作中执行对称操作的情况而言,要考虑这种case,确保不会让整个App crash掉。

    新的生命周期图示

生命周期函数

对于大多数开发人员来说,生命周期函数的变更应该是React16带来的最大影响(虽然它在React17中才会真正发生)。由于采用了新的Fiber架构,导致部分原有函数存在着风险,同时也引入了适配Fiber的新的函数。

新增

  • static getDerivedStateFromProps(nextProps, prevState)
  • getSnapshotBeforeUpdate(prevProps, prevState)
  • componentDidCatch(error, info)
static getDerivedStateFromProps(nextProps, prevState)

该函数根据传入的新的props来更新state。它在render阶段执行,所以每次更新都会触发,为了保证它的纯粹性,React将其设置为了静态方法——无法访问实例,无法通过ref访问DOM对象。

值得注意的是,开发者仍然可能会通过props的操作带来副作用,此时应该把props的操作移到componentDidUpdate 中,以减少触发次数。

但在使用时要非常小心,因为它不像 componentWillReceiveProps 一样,只在父组件重新渲染时才触发,本身调用 setState 也会触发。官方提供了 3 条 checklist, 这里搬运一下:

  1. 如果改变 props 的同时,有副作用的产生(如异步请求数据,动画效果),这时应该使用 componentDidUpdate
  2. 如果想要根据 props 计算属性,应该考虑将结果 memoization 化
  3. 如果想要根据 props 变化来重置某些状态,应该考虑使用受控组件
    配合 componentDidUpdate 周期函数,getDerivedStateFromProps 是为了替代 componentWillReceiveProps 而出现的。它将原本 componentWillReceiveProps 功能进行划分 —— 更新 state 和 操作/调用props,很大程度避免了职责不清而导致过多的渲染,从而影响应该性能。

推荐大家阅读React官网上的一篇文章:You Probably Don’t Need Derived State

  componentWillReceiveProps(nextProps) {
    const { drawerIsOpening } = this.props;
    if (
      drawerIsOpening != nextProps.drawerIsOpening &&
      false == nextProps.drawerIsOpening
    ) {
      this.drawer && this.drawer._close();
      this.props.getSkuSetInfoList(); 
    }

    if (this.state.nameIsDuplicate != nextProps.checkSkuUnitNameDuplicate) {
      this.setState({
        nameIsDuplicate: nextProps.checkSkuUnitNameDuplicate,
      });
    }
  }

这段代码做了两件事

  • 如果抽屉状态从打开变成了关闭,则要发送一个请求来更新商品集合列表
  • 如果props传入的checkSkuUnitNameDuplicate与当前组建中的相应state不同,则更新state

在新的架构下应该被拆分为

  static getDerivedStateFromProps(nextProps, prevState){
    if (prevState.nameIsDuplicate != nextProps.checkSkuUnitNameDuplicate) {
      return {
        nameIsDuplicate: nextProps.checkSkuUnitNameDuplicate,
      };
    }
    return null;
  }
  
  componentDidUpdate(prevProps) {
    const { drawerIsOpening } = this.props;
    if (
      drawerIsOpening != prevProps.drawerIsOpening &&
      false == drawerIsOpening
    ) {
      this.drawer && this.drawer._close();
      this.props.getSkuSetInfoList(); //创建完成后投放单元查询商品集合
    }
  }
getSnapshotBeforeUpdate(prevProps, prevState)

其在组件更新之前获取一个 snapshot —— 可以将计算得的值或从 DOM 得到的信息传递到 componentDidUpdate(prevProps, prevState, snapshot) 周期函数的第三个参数,常常用于 scroll 位置的定位。摘自官方的示例:

    class ScrollingList extends React.Component {
      constructor(props) {
        super(props)
        // 取得dom 节点
        this.listRef = React.createRef()
      }
    
      getSnapshotBeforeUpdate(prevProps, prevState) {
        // 根据新添加的元素来计算得到所需要滚动的位置
        if (prevProps.list.length < this.props.list.length) {
          const list = this.listRef.current
          return list.scrollHeight - list.scrollTop
        }
        return null
      }
    
      componentDidUpdate(prevProps, prevState, snapshot) {
        // 根据 snapshot 计算得到偏移量,得到最终滚动位置
        if (snapshot !== null) {
          const list = this.listRef.current
          list.scrollTop = list.scrollHeight - snapshot
        }
      }
    
      render() {
        return <div ref={this.listRef}>{/* ...contents... */}</div>
      }
    }
componentDidCatch(error, info)

让开发者可以自主处理错误信息,诸如展示,上报错误到BetterJS等,用户可以创建自己的Error Boundary 来捕获错误。例:

  componentDidCatch(error, info) {
    this.setState({ hasError: true });
    if (window.BJ_REPORT) {
      BJ_REPORT.report(error, info);
    }
  }

标记为不安全

  • componentWillMount(nextProps, nextState)
  • componentWillReceiveProps(nextProps)
  • componentWillUpdate(nextProps, nextState)

这些生命周期方法经常被误解和微妙地滥用;此外,我们预计它们的潜在误用可能会在异步呈现中出现更多问题。因此,我们将在即将发布的版本中为这些生命周期添加一个“UNSAFE_”前缀。(在这里,“不安全”并不是指安全性,而是指使用这些生命周期的代码更有可能在React的未来版本中出现bug,尤其是在启用了异步呈现之后。)

这段话是React的官方解释,结合上一节说的Fiber的运行机制不难看出,这三个全部是在render阶段执行,所以都存在着多次执行的可能。

  • componentWillMount

    1. 通常被用来获取首屏数据,或者订阅事件。开发者为了快速得到数据,将首屏请求放在这里,但实际上在执行componentWillMount时,第一次渲染已经开始,所以,把首屏数据请求放在这里与否都不能解决无异步数据的问题。废弃后,官方建议将首屏数据请求放在constructor或componentDidMount中

    2. 此外事件订阅也被常在 componentWillMount 用到,并在 componentWillUnmount 中取消掉相应的事件订阅。但事实上 React 并不能够保证在 componentWillMount 被调用后,同一组件的 componentWillUnmount 也一定会被调用。另一方面,在未来 React 开启异步渲染模式后,在componentWillMount被调用之后,组件的渲染也很有可能会被其他的事务所打断,导致 componentWillUnmount 不会被调用。而 componentDidMount 就不存在这个问题,在 componentDidMount 被调用后,componentWillUnmount 一定会随后被调用到,并根据具体代码清除掉组件中存在的事件订阅。对此的升级方案是把 componentWillMount 改为 componentDidMount 即可

  • componentWillReceiveProps、componentWillUpdate

    被废弃的原因前面已经说过,替代方案是使用 getDerivedStateFromPropscomponentDidUpdate

Context

这是一个令人欣喜的改变,对于这个Api可能大家并不陌生,通过将数据附着在context上,可以方便的在各组件之间进行传递,在 React 16.3 之前,Context API 一直被官方置为不推荐使用(don’t use context),究其原因是因为老的 Context API 作为一个实验性的产品,破坏了 React 的分形结构。同时在使用的过程中,如果在穿透组件的过程中,某个组件的 shouldComponentUpdate 返回了 false, 则 Context API 就不能穿透了。其带来的不确定性也就导致被不推荐使用。随着 React 16.3 的发布,全新 Context API 成了一等 API,可以很容易穿透组件而无副作用。

代码演示

Refs

React.createRef

新版本的React对获取元素ref进行了较大的变更,在React16中,获取ref需要使用createRef

  componentDidMount() {
    const el = this.refs.myRef
  }

  render() {
    return <div ref="myRef" />
  }
···
···
// React 16+
  constructor(props) {
    super(props)
    this.myRef = React.createRef()
  }

  render() {
    return <div ref={this.myRef} />
  }

React.forwardRef

如果需要获得子组件的ref,就需要使用这个React.forwardRef,示例代码

//子组件 TextInput.jsx
const TextInput = props => {
  return <input ref={props.forwardRef} {...props} />;
};
export default TextInput;
//父组件
import Input from './TextInput';

const TextInput = React.forwardRef((props, ref) => <Input type="text" placeholder="Hello forwardRef" forwardRef={ref}/>);
const inputRef = React.createRef();

class App extends Component {
  constructor(props) {
    super(props);
    this.myRef = React.createRef();
  }

  handleSubmit = () => {
    alert(inputRef.current.value);
  };
  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <TextInput ref={inputRef} />
        <button type="submit">Submit</button>
      </form>
    );
  }
}

Fragment

这是React提供的一个新组件,它可以在不渲染任何wrapper的情况下渲染多个组件。

render() {
  return (
    <React.Fragment>
      <ChildA />
      <ChildB />
      <ChildC />
    </React.Fragment>
  )
}

React Strict Mode

StrictMode是一个工具,用于强调应用程序中的潜在问题。像Fragment一样,StrictMode不会呈现任何可见的UI。它会为后代启动额外的检查和警告。

import React from 'react';

function ExampleApplication() {
  return (
    <div>
      <Header />
      <React.StrictMode>
        <div>
          <ComponentOne />
          <ComponentTwo />
        </div>
      </React.StrictMode>
      <Footer />
    </div>
  );
}

StrictMode目前可以帮助我们

  • 识别不安全的生命周期组件
  • 警告对遗留字符串ref API的使用
  • 警告对已经废弃的方法findDOMNode的使用
  • 探测某些产生副作用的方法
  • 检测是否使用了老的Context API
2020-04-29 16:45:21 qq_43623970 阅读数 31

我相信在看这篇文章的读者一般都已经了解过 React 16 以前的 Diff 算法了,这个算法也算是 React 跨时代或者说最有影响力的一点了,使 React 在保持了可维护性的基础上性能大大的提高,但 Diff 过程不仅不是免费的,而且对性能影响很大,有时候更新页面的时候往往 Diff 所花的时间 js 运行时间比 Rendering 和 Painting 花费更多的时间,所以我一直传达的观念是 React 或者说框架的意义是为了提高代码的可维护性,而不是为了提高性能的,现在所做的提升性能的操作,只是在可维护性的基础上对性能的优化。具体可以参考我公众号以前发的这两篇文章:

如果你对标题不满意,请把文章看完,至少也得把文章最后的结论好好看下

在上一篇将 React Fiber 架构中,已经说到过,React 现在将整体的数据结构从树改为了链表结构。所以相应的 Diff 算法也得改变,以为以前的 Diff 算法就是基于树的。

老的 Diff 算法提出了三个策略来保证整体界面构建的性能,具体是:

  1. Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
  2. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
  3. 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

基于以上三个前提策略,React 分别对 tree diff、component diff 以及 element diff 进行算法优化。

具体老的算法可以见这篇文章:React 源码剖析系列 - 不可思议的 react diff

说实话,老的 Diff 算法还是挺复杂的,你仅仅看上面这篇文章估计一时半会都不能理解,更别说看源码了。对于 React 16 的 Diff 算法(我觉得都不能把它称作算法,最多叫个 Diff 策略)其实还是蛮简单的,React 16 是整个调度流程感觉比较难,我在前面将 Fiber 的文章已经简单的梳理过了,后面也会慢慢的逐个攻破。

接下来就开始正式的讲解 React 16 的 Diff 策略吧!

Diff 简介

做 Diff 的目的就是为了复用节点。

链表的每一个节点是 Fiber,而不是在 16 之前的虚拟DOM 节点。

我这里说的虚拟 DOM 节点是指 React.createElement 方法所产生的节点。虚拟 DOM tree 只维护了组件状态以及组件与 DOM 树的关系,Fiber Node 承载的东西比 虚拟 DOM 节点多很多。

Diff 就是新旧节点的对比,在上一篇中也说道了,这里面的 Diff 主要是构建 currentInWorkProgress 的过程,同时得到 Effect List,给下一个阶段 commit 做准备。

React16 的 diff 策略采用从链表头部开始比较的算法,是层次遍历,算法是建立在一个节点的插入、删除、移动等操作都是在节点树的同一层级中进行的。

对于 Diff, 新老节点的对比,我们以新节点为标准,然后来构建整个 currentInWorkProgress,对于新的 children 会有四种情况。

  • TextNode(包含字符串和数字)
  • 单个 React Element(通过该节点是否有 $$typeof 区分)
  • 数组
  • 可迭代的 children,跟数组的处理方式差不多

那么我们就来一步一步的看这四种类型是如何进行 diff 的。

前置知识介绍

这篇文章主要是从 React 的源码的逻辑出发介绍的,所以介绍之前了解下只怎么进入到这个 diff 函数的,react 的 diff 算法是从 reconcileChildren 开始的

export function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderExpirationTime: ExpirationTime,
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderExpirationTime,
    );
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderExpirationTime,
    );
  }
}

reconcileChildren 只是一个入口函数,如果首次渲染,current 空 null,就通过mountChildFibers 创建子节点的 Fiber 实例。如果不是首次渲染,就调用reconcileChildFibers去做 diff,然后得出 effect list。

接下来再看看 mountChildFibers 和 reconcileChildFibers 有什么区别:

export const reconcileChildFibers = ChildReconciler(true);export const mountChildFibers = ChildReconciler(false);

他们都是通过 ChildReconciler 函数来的,只是传递的参数不同而已。这个参数叫shouldTrackSideEffects,他的作用是判断是否要增加一些effectTag,主要是用来优化初次渲染的,因为初次渲染没有更新操作。

function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  expirationTime: ExpirationTime,
): Fiber | null {
  // 主要的 Diff 逻辑
}

reconcileChildFibers 就是 Diff 部分的主体代码,这个函数超级长,是一个包装函数,下面所有的 diff 代码都在这里面,详细的源码注释可以见这里。

参数介绍

  • returnFiber 是即将 Diff 的这层的父节点。
  • currentFirstChild是当前层的第一个 Fiber 节点。
  • newChild 是即将更新的 vdom 节点(可能是 TextNode、可能是 ReactElement,可能是数组),不是 Fiber 节点
  • expirationTime 是过期时间,这个参数是跟调度有关系的,本系列还没讲解,当然跟 Diff 也没有关系。

再次提醒,reconcileChildFibers 是 reconcile(diff) 的一层。

前置知识介绍完毕,就开始详细介绍每一种节点是如何进行 Diff 的。

Diff TextNode

首先看 TextNode,因为它是最简单的,担心直接看到难的,然后就打击你的信心。

看下面两个小 demo:

// demo1:当前 ui 对应的节点的 jsx
return (
  <div>
  // ...
      <div>
          <xxx></xxx>
          <xxx></xxx>
      </div>
  //...
    </div>
)

// demo2:更新成功后的节点对应的 jsx

return (
  <div>
  // ...
      <div>
          前端桃园
      </div>
  //...
    </div>
)

对应的单链表结构图:

image-20190714223931338

对于 diff TextNode 会有两种情况。

  1. currentFirstNode 是 TextNode
  2. currentFirstNode 不是 TextNode

currentFirstNode 是当前该层的第一个节点,reconcileChildFibers 传进来的参数。

**为什么要分两种情况呢?**原因就是为了复用节点

第一种情况。xxx 是一个 TextNode,那么就代表这这个节点可以复用,有复用的节点,对性能优化很有帮助。既然新的 child 只有一个 TextNode,那么复用节点之后,就把剩下的 aaa 节点就可以删掉了,那么 div 的 child 就可以添加到 workInProgress 中去了。

源码如下:

if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
      // We already have an existing node so let's just update it and delete
      // the rest.
      deleteRemainingChildren(returnFiber, currentFirstChild.sibling);
      const existing = useFiber(currentFirstChild, textContent, expirationTime);
      existing.return = returnFiber;
      return existing;
}

在源码里 useFiber 就是复用节点的方法,deleteRemainingChildren 就是删除剩余节点的方法,这里是从 currentFirstChild.sibling 开始删除的。

**第二种情况。**xxx 不是一个 TextNode,那么就代表这个节点不能复用,所以就从currentFirstChild开始删掉剩余的节点,对应到上面的图中就是删除掉 xxx 节点和 aaa 节点。

对于源码如下:

deleteRemainingChildren(returnFiber, currentFirstChild);
const created = createFiberFromText(
    textContent,
    returnFiber.mode,
    expirationTime,
);
created.return = returnFiber;

其中 createFiberFromText 就是根据 textContent 来创建节点的方法。

注意:删除节点不会真的从链表里面把节点删除,只是打一个 delete 的 tag,当 commit 的时候才会真正的去删除。

更多前端学习内容文章干货请关注我的知乎专栏(不断更新),在这里,我会把所有重要的文章放在这个目录里面,供大家阅读,希望能对大家有用

阿里名厂标准web前端高级工程师教程目录大全,从基础到进阶,看完保证您的薪资上升一个台阶

在这里我给大家准备了很多的学习资料,希望对大家的学习之路有用

其实你与阿里工程师的差距只差这些东西

Diff React Element

有了上面 TextNode 的 Diff 经验,那么来理解 React Element 的 Diff 就比较简单了,因为他们的思路是一致的:先找有没有可以复用的节点,如果没有就另外创建一个。

那么就有一个问题,如何判断这个节点是否可以复用呢?

有两个点:1. key 相同。2. 节点的类型相同。

如果以上两点相同,就代表这个节点只是变化了内容,不需要创建新的节点,可以复用的。

对应的源码如下:

if (child.key === key) {
  if (
    child.tag === Fragment
    ? element.type === REACT_FRAGMENT_TYPE
    : child.elementType === element.type
  ) {
    // 为什么要删除老的节点的兄弟节点?
    // 因为当前节点是只有一个节点,而老的如果是有兄弟节点是要删除的,是多于的。删掉了之后就可以复用老的节点了
    deleteRemainingChildren(returnFiber, child.sibling);
    // 复用当前节点
    const existing = useFiber(
      child,
      element.type === REACT_FRAGMENT_TYPE
      ? element.props.children
      : element.props,
      expirationTime,
    );
    existing.ref = coerceRef(returnFiber, child, element);
    existing.return = returnFiber;
    return existing;
}

相信这些代码都很好理解了,除了判断条件跟前面 TextNode 的判断条件不一样,其余的基本都一样,只是 React Element 多了一个跟新 ref 的过程。

同样,如果节点的类型不相同,就将节点从当前节点开始把剩余的都删除。

deleteRemainingChildren(returnFiber, child);

到这里,可能你们就会觉得接下来应该就是讲解当没有可以复用的节点的时候是如果创建节点的。

不过可惜你们猜错了。因为 Facebook 的工程师很厉害,另外还做了一个工作来优化,来找到复用的节点。

我们现在来看这种情况:

image-20190714232052778

这种情况就是有可能更新的时候删除了一个节点,但是另外的节点还留着。

那么在对比 xxx 节点和 AAA 节点的时候,它们的节点类型是不一样,按照我们上面的逻辑,还是应该把 xxx 和 AAA 节点删除,然后创建一个 AAA 节点。

但是你看,明明 xxx 的 slibling 有一个 AAA 节点可以复用,但是被删了,多浪费呀。所以还有另外有一个策略来找 xxx 的所有兄弟节点中有没有可以复用的节点。

这种策略就是从 div 下面的所有子节点去找有没有可以复用的节点,而不是像 TextNode 一样,只是找第一个 child 是否可以复用,如果当前节点的 key 不同,就代表肯定不是同一个节点,所以把当前节点删除,然后再去找当前节点的兄弟节点,直到找到 key 相同,并且节点的类型相同,否则就删除所有的子节点。

你有木有这样的问题:为什么 TextNode 不采用这样的循环策略来找可以复用的节点呢?这个问题留给你思考,欢迎在评论区留下你的答案。

对应的源码逻辑如下:

// 找到 key 相同的节点,就会复用当前节点
while (child !== null) {
  if (child.key === key) {
    if (
      child.tag === Fragment
      ? element.type === REACT_FRAGMENT_TYPE
      : child.elementType === element.type
    ) {
      // 复用节点逻辑,省略该部分代码,和上面复用节点的代码相同
      // code ...
      return existing;
    } else {
      deleteRemainingChildren(returnFiber, child);
      break;
    }
  } else {
    // 如果没有可以复用的节点,就把这个节点删除
    deleteChild(returnFiber, child);
  }
  child = child.sibling;
}

在上面这段代码我们需要注意的是,当 key 相同,React 会认为是同一个节点,所以当 key 相同,节点类型不同的时候,React 会认为你已经把这个节点重新覆盖了,所以就不会再去找剩余的节点是否可以复用。只有在 key 不同的时候,才会去找兄弟节点是否可以复用。

接下来才是我们前面说的,如果没有找到可以复用的节点,然后就重新创建节点,源码如下:

// 前面的循环已经把该删除的已经删除了,接下来就开始创建新的节点了
if (element.type === REACT_FRAGMENT_TYPE) {
  const created = createFiberFromFragment(
    element.props.children,
    returnFiber.mode,
    expirationTime,
    element.key,
  );
  created.return = returnFiber;
  return created;
} else {
  const created = createFiberFromElement(
    element,
    returnFiber.mode,
    expirationTime,
  );
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;
  return created;
}

对于 Fragment 节点和一般的 Element 节点创建的方式不同,因为 Fragment 本来就是一个无意义的节点,他真正需要创建 Fiber 的是它的 children,而不是它自己,所以createFiberFromFragment 传递的不是 element,而是element.props.children

Diff Array

Diff Array 算是 Diff 中最难的一部分了,比较的复杂,因为做了很多的优化,不过请你放心,认真看完我的讲解,最难的也会很容易理解,废话不多说,开始吧!

因为 Fiber 树是单链表结构,没有子节点数组这样的数据结构,也就没有可以供两端同时比较的尾部游标。所以React的这个算法是一个简化的两端比较法,只从头部开始比较。

前面已经说了,Diff 的目的就是为了复用,对于 Array 就不能像之前的节点那样,仅仅对比一下元素的 key 或者 元素类型就行,因为数组里面是好多个元素。你可以在头脑里思考两分钟如何进行复用节点,再看 React 是怎么做的,然后对比一下孰优孰劣。

1. 相同位置(index)进行比较

相同位置进行对比,这个是比较容易想到的一种方式,还是举个例子加深一下印象。

image-20190721212259855

这已经是一个非常简单的例子了,div 的 child 是一个数组,有 AAA、BBB 然后还有其他的兄弟节点,在做 diff 的时候就可以从新旧的数组中按照索引一一对比,如果可以复用,就把这个节点从老的链表里面删除,不能复用的话再进行其他的复用策略。

那如果判断节点是否可以复用呢?有了前面的 ReactElement 和 TextNode 复用的经验,这个也类似,因为是一一对比嘛,相当于是一个节点一个节点的对比。

不过对于 newChild 可能会有很多种类型,简单的看下源码是如何进行判断的。

 const key = oldFiber !== null ? oldFiber.key : null;

前面的经验可得,判断是否可以复用,常常会根据 key 是否相同来决定,所以首先获取了老节点的 key 是否存在。如果不存在老节点很可能是 TextNode 或者是 Fragment。

接下来再看 newChild 为不同类型的时候是如何进行处理的。

当 newChild 是 TextNode 的时候

if (typeof newChild === 'string' || typeof newChild === 'number') {
  // 对于新的节点如果是 string 或者 number,那么都是没有 key 的,
  // 所有如果老的节点有 key 的话,就不能复用,直接返回 null。
  // 老的节点 key 为 null 的话,代表老的节点是文本节点,就可以复用
  if (key !== null) {
    return null;
  }

  return updateTextNode(
    returnFiber,
    oldFiber,
    '' + newChild,
    expirationTime,
  );
}

如果 key 不为 null,那么就代表老节点不是 TextNode,而新节点又是 TextNode,所以返回 null,不能复用,反之则可以复用,调用 updateTextNode 方法。

注意,updateTextNode 里面包含了首次渲染的时候的逻辑,首次渲染的时候回插入一个 TextNode,而不是复用。

当 newChild 是 Object 的时候

newChild 是 Object 的时候基本上走的就是 ReactElement 的逻辑了,判断 key 和 元素的类型是否相等来判断是否可以复用。

if (typeof newChild === 'object' && newChild !== null) {
  // 有 `$$typeof` 代表就是 ReactElement
  switch (newChild.$$typeof) {
    case REACT_ELEMENT_TYPE: {
                // ReactElement 的逻辑 
    }
    case REACT_PORTAL_TYPE: {
                // 调用 updatePortal
    }
  }

  if (isArray(newChild) || getIteratorFn(newChild)) {
    if (key !== null) {
      return null;
    }

    return updateFragment(
      returnFiber,
      oldFiber,
      newChild,
      expirationTime,
      null,
    );
  }
}

首先判断是否是对象,用的是 typeof newChild === 'object' && newChild !== null ,注意要加 !== null,因为 typeof null 也是 object。

然后通过 $$typeof 判断是 REACT_ELEMENT_TYPE 还是 REACT_PORTAL_TYPE,分别调用不同的复用逻辑,然后由于数组也是 Object ,所以这个 if 里面也有数组的复用逻辑。

我相信到这里应该对于应该对于如何相同位置的节点如何对比有清晰的认识了。另外还有问题,那就是如何循环一个一个对比呢?

这里要注意,新的节点的 children 是虚拟 DOM,所以这个 children 是一个数组,而对于之前提到的老的节点树是链表。

那么循环一个一个对比,就是遍历数组的过程。

let newIdx = 0 // 新数组的索引
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 遍历老的节点
  nextOldFiber = oldFiber.sibling; 
  // 返回复用节点的函数,newFiber 就是复用的节点。
  // 如果为空,就代表同位置对比已经不能复用了,循环结束。
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx],
    expirationTime,
  );

  if (newFiber === null) {
    break;
  }

  // 其他 code,比如删除复用的节点
}

这并不是源码的全部源码,我只是把思路给贴出来了。

这是第一次遍历新数组,通过调用 updateSlot 来对比新老元素,前面介绍的如何对比新老节点的代码都是在这个函数里。这个循环会把所以的从前面开始能复用的节点,都复用到。比如上面我们画的图,如果两个链表里面的 **???**节点,不相同,那么 newFiber 为 null,这个循环就会跳出。

跳出来了,就会有两种情况。

  • 新节点已经遍历完毕
  • 老节点已经遍历完毕

2. 新节点已经遍历完毕

如果新节点已经遍历完毕的话,也就是没有要更新的了,这种情况一般就是从原来的数组里面删除了元素,那么直接把剩下的老节点删除了就行了。还是拿上面的图的例子举例,老的链表里**???还有很多节点,而新的链表???已经没有节点了,所以老的链表???**不管是有多少节点,都不能复用了,所以没用了,直接删除。

if (newIdx === newChildren.length) {  // 新的 children 长度已经够了,所以把剩下的删除掉  deleteRemainingChildren(returnFiber, oldFiber);  return resultingFirstChild;}

注意这里是直接 return 了哦,没有继续往下执行了。

3. 老节点已经遍历完毕

如果老的节点在第一次循环的时候就被复用完了,新的节点还有,很有可能就是新增了节点的情况。那么这个时候只需要根据把剩余新的节点直接创建 Fiber 就行了。

if (oldFiber === null) {
  // 如果老的节点已经被复用完了,对剩下的新节点进行操作
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = createChild(
      returnFiber,
      newChildren[newIdx],
      expirationTime,
    );
  }
  return resultingFirstChild;
}

oldFiber === null 就是用来判断老的 Fiber 节点变量完了的代码,Fiber 链表是一个单向链表,所以为 null 的时候代表已经结束了。所以就直接把剩余的 newChild 通过循环创建 Fiber。

到这里,目前简单的对数组进行增、删节点的对比还是比较简单,接下来就是移动的情况是如何进行复用的呢?

4. 移动的情况如何进行节点复用

对于移动的情况,首先要思考,怎么能判断数组是否发生过移动操作呢?

如果给你两个数组,你是否能判断出来数组是否发生过移动。

答案是:老的数组和新的数组里面都有这个元素,而且位置不相同。

从两个数组中找到相同元素(是指可复用的节点),方法有很多种,来看看 React 是如何高效的找出来的。

把所有老数组元素按 key 或者是 index 放 Map 里,然后遍历新数组,根据新数组的 key 或者 index 快速找到老数组里面是否有可复用的。

function mapRemainingChildren(
 returnFiber: Fiber,
 currentFirstChild: Fiber,
): Map<string | number, Fiber> {
  const existingChildren: Map<string | number, Fiber> = new Map();

  let existingChild = currentFirstChild; // currentFirstChild 是老数组链表的第一个元素
  while (existingChild !== null) {
  // 看到这里可能会疑惑怎么在 Map 里面的key 是 fiber 的key 还是 fiber 的 index 呢?
  // 我觉得是根据数据类型,fiber 的key 是字符串,而 index 是数字,这样就能区分了
  // 所以这里是用的 map,而不是对象,如果是对象的key 就不能区分 字符串类型和数字类型了。
    if (existingChild.key !== null) {
      existingChildren.set(existingChild.key, existingChild);
    } else {
      existingChildren.set(existingChild.index, existingChild);
    }
    existingChild = existingChild.sibling;
    }
    return existingChildren;
}

这个 mapRemainingChildren 就是将老数组存放到 Map 里面。元素有 key 就 Map 的键就存 key,没有 key 就存 index,key 一定是字符串,index 一定是 number,所以取的时候是能区分的,所以这里用的是 Map,而不是对象,如果是对象,属性是字符串,就没办法区别是 key 还是 index 了。

现在有了这个 Map,剩下的就是循环新数组,找到 Map 里面可以复用的节点,如果找不到就创建,这个逻辑基本上跟 updateSlot 的复用逻辑很像,一个是从老数组链表中获取节点对比,一个是从 Map 里获取节点对比。

// 如果前面的算法有复用,那么 newIdx 就不从 0 开始
for (; newIdx < newChildren.length; newIdx++) {
  const newFiber = updateFromMap(
    existingChildren,
    returnFiber,
    newIdx,
    newChildren[newIdx],
    expirationTime,
  );
 // 省略删除 existingChildren 中的元素和添加 Placement 副作用的情况
}

到这里新数组遍历完毕,也就是同一层的 Diff 过程完毕,接下来进行总结一下。

效果演示

以下效果动态演示来自于文章:React Diff 源码分析,我觉得这个演示非常的形象,有助于理解。

这里渲染一个可输入的数组。

11

当第一种情况,新数组遍历完了,老数组剩余直接删除(12345→1234 删除 5):

img

新数组没完,老数组完了(1234→1234567 插入 567):

img

移动的情况,即之前就存在这个元素,后续只是顺序改变(123 → 4321 插入4,移动2 1):

img

最后删除没有涉及的元素。

总结

对于数组的 diff 策略,相对比较复杂,最后来梳理一下这个策略,其实还是很简单,只是看源码的时候比较难懂。

我们可以把整个过程分为三个阶段:

  1. 第一遍历新数组,新老数组相同 index 进行对比,通过 updateSlot方法找到可以复用的节点,直到找到不可以复用的节点就退出循环。
  2. 第一遍历完之后,删除剩余的老节点,追加剩余的新节点的过程。如果是新节点已遍历完成,就将剩余的老节点批量删除;如果是老节点遍历完成仍有新节点剩余,则将新节点直接插入。
  3. 把所有老数组元素按 key 或 index 放 Map 里,然后遍历新数组,插入老数组的元素,这是移动的情况。