0%

由el-tabs使用谈Vue渲染机制

背景

el-tabs是elementUI(基于Vue)的一个组件,实现的是标签页的功能。具体使用方法就不赘述了。文档在这里

问题

有这样一个使用场景:用户点击一个标签页后,需要判断是否可以进行切换,如果检查通过,那么就不做干预;否则,需要把标签页重置回用户操作前的。

尝试

通过前面的文档可以知道,这个组件绑定了一个数值,这个数值指向的是当前标签页的名字(每个标签页都有的一个名字)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<el-tabs v-model="activeName" @tab-click="handleClick">
<el-tab-pane label="用户管理" name="first">用户管理</el-tab-pane>
<el-tab-pane label="配置管理" name="second">配置管理</el-tab-pane>
<el-tab-pane label="角色管理" name="third">角色管理</el-tab-pane>
<el-tab-pane label="定时任务补偿" name="fourth">定时任务补偿</el-tab-pane>
</el-tabs>
</template>
<script>
export default {
data() {
return {
activeName: 'second'
};
},
methods: {
handleClick(tab, event) {
if(tab.name=='second'){// 切换目标是第二个标签页
if(true){// 这里模拟数据检查需要干预
this.activeName = "first";// 强制切换到第一个标签页面(没有效果)
}
}
}
}
};
</script>

以上,activeName是当前显示的标签名称,它的值从firstsecond以及third中取得。通常我们切换标签页只需要修改activeName的值就好了。就像上面代码中的this.activeName = "first"。但是实际却没有任何效果。

问题追踪

上面的代码在执行完毕后,通过调试工具发现activeName的值在强制赋值已经改变了。强制赋值后它的值是预期的,但标签页内容没有更新。另外还发现,此时无法再通过编程方式切换到第一个标签(this.activeName = "first"失效)。

问题分析

这个组件基于vue编写,修改绑定的标签名称就可以达到切换标签页的功能,实现逻辑应该也是监听到数据变化再做渲染。以上情况出现的原因可能是更改值(为second)之后,DOM尚未来得及刷新,此时又做了一次赋值,架构认为已经新值已经刷新完毕。故出现了数据和显示不一致的情况。Vue官方文档

可能你还没有注意到,Vue 异步执行 DOM 更新。只要观察到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MessageChannel,如果执行环境不支持,会采用 setTimeout(fn, 0) 代替。

解决

经过以上分析,强制赋值应该在前次赋值而且DOM已经刷新完毕之后进行。可以使用$nextTick,以下是代码:

1
2
3
this.$nextTick(()=> {
this.activeName = "first";
});

原理是:

将回调延迟到下次 DOM 更新循环之后执行。在修改数据之后立即使用它,然后等待 DOM 更新。它跟全局方法 Vue.nextTick 一样,不同的是回调的 this 自动绑定到调用它的实例上。

深入

阅读elementUI源码后(GitHub地址),tabs.vue抛出tab-click事件的方法是这样的:

1
2
3
4
5
handleTabClick(tab, tabName, event) {
if (tab.disabled) return;
this.setCurrentName(tabName);
this.$emit('tab-click', tab, event);
},

在抛出事件之前似乎setCurrentName是做了赋值操作,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
setCurrentName(value) {
const changeCurrentName = () => {
this.currentName = value;
this.$emit('input', value);
};
if (this.currentName !== value && this.beforeLeave) {
const before = this.beforeLeave(value, this.currentName);
if (before && before.then) {
before.then(() => {
changeCurrentName();

this.$refs.nav && this.$refs.nav.removeFocus();
});
} else if (before !== false) {
changeCurrentName();
}
} else {
changeCurrentName();
}
}

需要注意这里的beforeLeave,根据以上代码可见:可以在赋值前执行一个回调方法,我们上面的需求可以通过这个设置回调的方式实现,这种方式避免了无谓的渲染,是最好的实现方式。
这里currentName只是简单的属性,但其上加了侦听:

1
2
3
4
5
6
7
if (this.$refs.nav) {
this.$nextTick(() => {
this.$refs.nav.$nextTick(_ => {
this.$refs.nav.scrollToActiveTab();
});
});
}

这里很明显是在下一个渲染周期才会调用组件进行更新,再进一步查看scrollToActiveTab方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scrollToActiveTab() {
if (!this.scrollable) return;
const nav = this.$refs.nav;
const activeTab = this.$el.querySelector('.is-active');
if (!activeTab) return;
const navScroll = this.$refs.navScroll;
const activeTabBounding = activeTab.getBoundingClientRect();
const navScrollBounding = navScroll.getBoundingClientRect();
const maxOffset = nav.offsetWidth - navScrollBounding.width;
const currentOffset = this.navOffset;
let newOffset = currentOffset;

if (activeTabBounding.left < navScrollBounding.left) {
newOffset = currentOffset - (navScrollBounding.left - activeTabBounding.left);
}
if (activeTabBounding.right > navScrollBounding.right) {
newOffset = currentOffset + activeTabBounding.right - navScrollBounding.right;
}

newOffset = Math.max(newOffset, 0);
this.navOffset = Math.min(newOffset, maxOffset);
}

可以发现,以上代码并没有更新标签页的内容,大概是试图更新标签项的显示。

那么到底是如何影响切换显示内容呢?

经过进一步阅读查找,在tab-nav.vue的render方法中我们找到了如下代码:

1
2
3
4
5
6
7
8
class={{
'el-tabs__item': true,
[`is-${ this.rootTabs.tabPosition }`]: true,
'is-active': pane.active,
'is-disabled': pane.disabled,
'is-closable': closable,
'is-focus': this.isFocus
}}

这是渲染标签内容显示的片段,is-active的值取决于pane.active,根据上下文可以获知pane是tab-pane.vue中定义的组件,在其计算属性中这样定义:

1
2
3
4
5
6
7
active() {
const active = this.$parent.currentName === (this.name || this.index);
if (active) {
this.loaded = true;
}
return active;
}

这里做了值判断,当前标签页是父中的选中标签页,就返回真。

总结

通过这个需求实践,巩固了对vue渲染机制的理解。阅读elementUI源代码,是从代码消费者到代码生产者转变的一个重要途径。

这里有以上例子的简要示例。