欢迎关注我的公众号睿Talk
,获取我最新的文章:
一、前言
目前最流行的两大前端框架,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(
This is My Component! {this.props.count}
name: {this.state.name}
)
}
}
项目运行的效果是每当我点一下MyComp组件的区域,里面的name就会随之马上更新。
三、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.: 想看完整代码见这里,如果有必要建一个仓库的话请留言给我:代码