背景
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是当前显示的标签名称,它的值从first、second以及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源代码,是从代码消费者到代码生产者转变的一个重要途径。
这里有以上例子的简要示例。