34

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

目前最流行的两大前端框架,React和Vue,都不约而同的借助Virtual DOM技术提高页面的渲染效率。那么,什么是Virtual DOM?它是通过什么方式去提升页面渲染效率的呢?本系列文章会详细讲解Virtual DOM的创建过程,并实现一个简单的Diff算法来更新页面。本文的内容脱离于任何的前端框架,只讲最纯粹的Virtual DOM。敲单词太累了,下文Virtual DOM一律用VD表示。

这是VD系列文章的第六篇,以下是本系列其它文章的传送门:
你不知道的Virtual DOM(一):Virtual Dom介绍
你不知道的Virtual DOM(二):Virtual Dom的更新
你不知道的Virtual DOM(三):Virtual Dom更新优化
你不知道的Virtual DOM(四):key的作用
你不知道的Virtual DOM(五):自定义组件
你不知道的Virtual DOM(六):事件处理&异步更新

今天,我们继续在之前项目的基础上扩展功能。在上一篇文章中,介绍了自定义组件的渲染和更新的实现方法。为了验证setState是否生效,还定义了一个setTimeout方法,5秒后更新state。在现实的项目中,state的改变往往是通过事件触发的,如点击事件、键盘事件和滚动事件等。下面,我们就将事件处理加入到项目当中。

二、实现事件处理

事件的绑定一般是定义在元素或者组件的属性当中,之前对属性的初始化和更新没有考虑支持事件,只是简单的赋值操作。

// 属性赋值
function setProps(element, props) {
     // 属性赋值
    element[ATTR_KEY] = props;

    for (let key in props) {
        element.setAttribute(key, props[key]);
    }
}

// 比较props的变化
function diffProps(newVDom, element) {
    let newProps = {...element[ATTR_KEY]};
    const allProps = {...newProps, ...newVDom.props};

    // 获取新旧所有属性名后,再逐一判断新旧属性值
    Object.keys(allProps).forEach((key) => {
        const oldValue = newProps[key];
        const newValue = newVDom.props[key];

        // 删除属性
        if (newValue == undefined) {
            element.removeAttribute(key);
            delete newProps[key];
        } 
        // 更新属性
        else if (oldValue == undefined || oldValue !== newValue) {
            element.setAttribute(key, newValue);
            newProps[key] = newValue;
        }
    }
)

    // 属性重新赋值
    element[ATTR_KEY] = newProps;
}

setProps是在创建元素的时候调用的,而diffProps则是在diff过程中调用的。如果需要支持事件绑定,我们需要多做一个判断。如果属性名称是on开头的话,比如onClick,我们就要在当前元素上注册或删除一个事件处理。

// 属性赋值
function setProps(element, props) {
     // 属性赋值
    element[ATTR_KEY] = props;

    for (let key in props) {
        // on开头的属性当作事件处理
        if (key.substring(0, 2) == 'on') {
            const evtName = key.substring(2).toLowerCase();
            element.addEventListener(evtName, evtProxy);
            (element._evtListeners || (element._evtListeners = {}))[evtName] = props[key];
        } else {
            element.setAttribute(key, props[key]);
        }
    }
}

function evtProxy(evt) {
    this._evtListeners[evt.type](evt);
}

// 比较props的变化
function diffProps(newVDom, element) {
    let newProps = {...element[ATTR_KEY]};
    const allProps = {...newProps, ...newVDom.props};

    // 获取新旧所有属性名后,再逐一判断新旧属性值
    Object.keys(allProps).forEach((key) => {
        const oldValue = newProps[key];
        const newValue = newVDom.props[key];

        // on开头的属性当作事件处理
        if (key.substring(0, 2) == 'on') {
            const evtName = key.substring(2).toLowerCase();
            if (newValue) {
                element.addEventListener(evtName, evtProxy);
            } else {
                element.removeEventListener(evtName, evtProxy);
            }
            (element._evtListeners || (element._evtListeners = {}))[evtName] = newValue;
        } else {
            // 删除属性
            if (newValue == undefined) {
                element.removeAttribute(key);
                delete newProps[key];
            } 
            // 更新属性
            else if (oldValue == undefined || oldValue !== newValue) {
                element.setAttribute(key, newValue);
                newProps[key] = newValue;
            }
        }
    }
)

    // 属性重新赋值
    element[ATTR_KEY] = newProps;
}

所有的事件处理函数都存到dom元素的_evtListeners当中,当事件触发的时候,将事件传给里面对应的方法处理。这样做的好处是如果以后要对浏览器传入的事件evt做进一步的封装,就可以在evtProxy函数里面处理。

接下来,我们在自定义组件里面新增一个onClick事件,在点击的时候改变state里面的值。

class MyComp extends Component {
    constructor(props) {
        super(props);
        this.state = {
            name: 'Tina',
            count: 1
        }
    }

    elmClick() {
        this.setState({name: `Jack${this.state.count}`, count: this.state.count + 1 });
    }

    render() {
        return(
            <div id="myComp" onClick={this.elmClick.bind(this)}>
                <div>This is My Component! {this.props.count}</div>
                <div>name: {this.state.name}</div>
            </div>
        )
    }
}

项目运行的效果是每当我点一下MyComp组件的区域,里面的name就会随之马上更新。

clipboard.png

三、setState异步更新

用过React的朋友都知道,为了减少不必要的渲染,提高性能,React并不是在我们每次setState的时候都进行渲染,而是将一个同步操作里面的多个setState进行合并后再渲染,给人异步渲染的感觉。看过源码的都应该知道,React是通过事务的方式来合并多个setState操作的,本质来说还是同步的。如果想对其作更深入的学习,推荐看这篇文章

为了达到合并操作,减少渲染的效果,最简单的方式就是异步渲染,下面我们来看看如何实现。在上一个版本里,setState是这么定义的:

class Component {
    ...
        
    setState(newState) {
        this.state = {...this.state, ...newState};
        const vdom = this.render();
        diff(this.dom, vdom, this.parent);
    }

    ...
};

state更新后直接就进行diff操作,进而更新页面。如果我们onClick里面的代码改成这样:

elmClick() {
        this.setState({name: `Jack${this.state.count}`, count: this.state.count + 1 });
        this.setState({name: `Jack${this.state.count}`, count: this.state.count + 1 });
    }

页面会渲染2次。如果我们把它改造成下面的样子:

// 等待渲染的组件数组
let pendingRenderComponents = [];

class Component {
    ...
        
    setState(newState) {
        this.state = {...this.state, ...newState};
        enqueueRender(this);
    }

    ...
};

function enqueueRender(component) {
    // 如果push后数组长度为1,则将异步刷新任务加入到事件循环当中
    if (pendingRenderComponents.push(component) == 1) {
        if (typeof Promise=='function') {
            Promise.resolve().then(renderComponent);
        } else {
            setTimeout(renderComponent, 0);
        }
    }
}

function renderComponent() {
    // 组件去重
    const uniquePendingRenderComponents = [...new Set(pendingRenderComponents)];

    // 渲染组件
    uniquePendingRenderComponents.forEach(component => {
        const vdom = component.render();
        diff(component.dom, vdom, component.parent);
    });

    // 清空待渲染列表
    pendingRenderComponents = [];
}

当第一次setState成功后,并不会马上进行渲染,而是将组件存入待渲染组件列表当中。如果列表是空的,则存入组件后将异步刷新任务加入到事件循环当中。当运行环境支持Promise时,通过微任务运行,否则通过宏任务运行。微任务的运行时间是当前事件循环的末尾,而宏任务的运行时间是下一个事件循环。所以优先使用微任务。

紧接着进行第二次setState操作,同样的,将组件存入待渲染组件列表当中。此时,主线程的任务执行完了,开始执行异步任务。

当异步刷新任务启动时,将待渲染列表去重后对里面的组件进行渲染。等渲染完成后再清空待渲染列表。此时,渲染出来的是2次setState合并后的结果,并且只会进行一次diff操作,渲染一次。

四、总结

本文基于上一个版本的代码,加入了事件处理功能,同时通过异步刷新的方法提高了渲染效率。

这是VD系列的最后一篇文章。本系列从什么是Virtual Dom这个问题出发,讲解了VD的数据结构、比较方式和更新流程,并在此基础上进行功能扩展和性能优化,支持key元素复用、自定义组件,dom事件绑定和setState异步更新。总共三百多行代码,实现了mvvm库的核心功能。

有关VD,如果还有什么想了解的,欢迎留言,有问必答。

P.S.: 想看完整代码见这里,如果有必要建一个仓库的话请留言给我:代码


Dickens
5.5k 声望424 粉丝