buxuku / react-handwritten

手写实现React
0 stars 1 forks source link

5.setState异步批量更新 #5

Open buxuku opened 3 years ago

buxuku commented 3 years ago

在上一步简单地使用到了setState这个API,但使用起来用点粗暴,一调用setState就更新组件了.而React一开始就告诉了我们,setState它的执行不保证同步还是异步的.因为它会尽可能将多次setState合并到一次来执行,以提高渲染的性能.

按照官方的说法: 在事件处理函数内部的setState是异步的.

怎么理解呢,也就是在我们绑定的事件里面,在该事件处理可控制的范围内.它是异步的,那什么是可控制范围之外了呢?比如遇到了setTimeout,Promise等,它就会变成同步了.

来一个比较坑的组件,想想它会在控制台输出什么?

class Hello extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 1,
        }
    }

    handleAdd = async () => {
        this.setState({
            count: this.state.count + 1,
        });
        this.setState({
            count: this.state.count + 1,
        });
        console.log('1:', this.state);
        setTimeout(() => {
            this.setState({
                count: this.state.count + 1,
            })
            console.log('2:', this.state);
            this.setState({
                count: this.state.count + 1,
            })
            console.log('3:', this.state);
        }, 0)
        this.setState({
            count: this.state.count + 1,
        })
        this.setState((state) => ({
            count: state.count + 1,
        }));
        console.log('4:', this.state);
        await new Promise(resolve => {
            setTimeout(() => {  // 注意如果不加setTimout又会是什么效果
                this.setState({
                    count: this.state.count + 1
                });
                console.log('5:', this.state);
                resolve();
            }, 0);
        })
        this.setState({
            count: this.state.count + 1,
        })
        this.setState({
            count: this.state.count + 1,
        })
        console.log('6:', this.state);
    }

    render() {
        console.log('render', this.state.count);
        return (
            <div>
                <p>number: {this.state.count}</p>
                <button onClick={this.handleAdd}>+</button>
            </div>
        )
    }
}

对于这个组件的输出,不管你的信心怎样,咱们先放一边,回来思考一下要实现setState的异步更新,并且是批量更新.既然要批量,那我们就需要维护一个队列,同时需要有一个标识,标识如果是处理异步批量更新状态的话,那就把setState放入这个队列中.当达到需要更新的时机时,就把队列里面的数据进行合并一次性完成更新操作.

那么我们在调用setState这个API的时候,就不能直接去修改父类里面的那个state了.为了保持父类API的纯净,我们抽离一个Updater类来维护这个队列.就把它叫作更新器吧.

新建src/react/Updater.js文件,通过pendingState来保存要更新的队列,componentInstance挂载的是组件的实例,后面方便通过它来调用组件上的一些方法.

/**
 * 组件更新器,用来维护更新队列
 */
export class Updater {
    constructor(componentInstance) {
        this.componentInstance = componentInstance;
        this.pendingState = []; // 需要更新state的队列
    }
    addState(partialState) {
        this.pendingState.push(partialState);
    }
}

修改src/react/Component.js文件

import {compareTwoVdoms} from '../react-dom';
+import {Updater} from "./Updater";

/**
 * React.Component父类
 */
export class Component {
    static isReactComponent = {};

    constructor(props) {
        this.props = props;
        this.state = {};
+        this.updater = new Updater(this)
    }

    setState(partialState) {
-        this.state = {...this.state, ...partialState};
-        this.forceUpdate()
+        this.updater.addState(partialState)
    }

    forceUpdate() {
        const oldVdom = this.oldVdom;
        const newVdom = this.render();
        compareTwoVdoms(oldVdom, newVdom)
        this.oldVdom = newVdom; // 将更新后的虚拟DOM更新到原来的oldVdom上面
    }
}

接下来,那如何来维护这个标识呢?事件!对,React告诉了我们,在事件处理函数中,前面我们正好也实现了事件的处理,那就可以在事件执行的开始,把这个标识设置为true,事件处理完了,再进行批量更新,并把这个标识恢复成false即可.

src/react/Updater.js中新增一个updateTracker对象,用来维护这个标识,另外也要维护一个Updater的实例队列,因为在一次事件过程当中,可能会触发导致多个组件的更新,而每个组件是对应一个Updater实例的.也就是说,Updater实例维护对应组件的更新队列,而updateTracker需要维护多个组件的更新器(即Updater).同时对外暴露一个batchUpdate的方法,来批量调用每个组件的更新方法.

/**
 * 更新标识,标识是否处于批量更新状态中
 * @type {{batchUpdate(): void, isBatchingUpdate: boolean, updaters: *[]}}
 */
export let updateTracker = {
    isBatchingUpdate: false,
    updaters: [],
    batchUpdate() {
        for (let updater of updateTracker.updaters) {
            // TODO update component
        }
        updateTracker.isBatchingUpdate = false;
        updateTracker.updaters.length = 0;
    }
};

修改src/react-dom/event.js文件,导入updateTracker

+import { updateTracker } from '../react/Updater';

并修改dispatchEvent方法,在执行之前设置标识为true,执行结束之后,调用批量更新来进行更新操作.

function dispatchEvent(event){
+    updateTracker.isBatchingUpdate = true;
    let { target, type } = event;
    let eventType = `on${type}`;
    let syntheticEvent = createSyntheticEvent(event);
    // 模拟事件冒泡,一直向父级元素进行冒泡
    while(target){
        let { store } = target;
        let handler = store && store[eventType];
        handler && handler.call(target, syntheticEvent);
        target=target.parentNode;
    }
+    updateTracker.batchUpdate();
}

接下来在Updater里面的addState就可以通过这个标识符来做判断了,如果是批量更新,则把当前Updater实例放入updateTracker中,否则就直接同步执行更新操作.Updater实例只需要放入一次,所以在Updater里面也增加一个标识来判断一下.

修改src/react/Updater.js文件

export class Updater {
    constructor(componentInstance) {
        this.componentInstance = componentInstance;
        this.pendingState = []; // 需要更新state的队列
+        this.batchTracking = false; // 标识当前实例是否已经添加进了updateTracer队列中
    }

    addState(partialState) {
        this.pendingState.push(partialState);
+        if (!updateTracker.isBatchingUpdate) { //如果不是批量更新,则直接更新组件
+            this.updateComponent()
+        } else if (!this.batchTracking) { // 如果还没有添加进updateTracker队列中,刚添加进去
+            updateTracker.updaters.push(this);
+            this.batchTracking = true;
+        }
    }
}

继续在Updater里面实现上一步需要的updateComponent方法,通过合成最新的state,赋值给组件,并调用组件上面的foreUpdate即可.所以同时写一个getState方法,执行Updater队列pendingState里面的数据.这里面的队列有可能是一个新值对象,也有可能是一个函数,就像开篇里面的一个写法.

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

所以在Updater这个类里面增加两个方法如下:

    updateComponent() {
        const {componentInstance, pendingState} = this;
        if (pendingState.length) {
            componentInstance.state = this.getState();
            componentInstance.forceUpdate();
        }
        this.batchTracking = false;
    }

    getState() {
        let {componentInstance, pendingState} = this;
        let {state} = componentInstance; // 获取老的state
        pendingState.forEach(item => {
            if (typeof item === 'function') { // setState第一个参数有可能是传入的一个函数,入参是上一步最新的State
                item = item(state);
            }
            state = {...state, ...item};
        })
        pendingState.length = 0; // 清空队列
        return state;
    }

并在updateTrackerbatchUpdate方法中调用这个updateComponent方法

export let updateTracker = {
    isBatchingUpdate: false,
    updaters: [],
    batchUpdate() {
        for (let updater of updateTracker.updaters) {
-            // TODO update component        
+            updater.updateComponent();
        }
        updateTracker.isBatchingUpdate = false;
        updateTracker.updaters.length = 0;
    }
};

src/index.js文件中使用开篇我们写的那个组件,尝试一下是否已经实现了异步批量更新呢?以及尝试一下导入官方的reactreact-dom来验证一下输出结果是否一致.

hCKx5T

通过输出我们也可以得知,在遇到setTimeout这些调用浏览器的API时,事件执行会继续执行后面的逻辑,直到结束或者遇到await异步等待(执行完同步方法,参考开篇里面的await里面去掉setTimeout),这个时候批量更新操作完成,后续的setTimeout方法里面的,Promise异步方法里面,以及await后面的setState都会变成同步执行了.