前言
框架:vue2和element-ui
目的:el-table中的内容想要做自滚动和循环滚动展示
实现:给el-table设置了height属性,在页面的mounted里面给el-table的data属性绑定的数据赋值后,使用this.$nextTick(() => {/** 在这里面获取el-table__body-wrapper和el-table__body两个容器的高度 */})
结果:外容器el-table__body-wrapper和内容器el-table__body的clientHeight高度一致,无法滚动,按理来说,在nextTick去获取dom尺寸的操作不会有问题
解决方法:
// 1. 在nextTick的回调里再嵌套一层nextTick
this.$nextTick(() => {
this.$nextTick(() => {
// 在这里获取el-table__body-wrapper的高度正常了
})
})
// 2.使用setTimeout(fn, 0)
setTimeout(() => {
// 在这里获取el-table__body-wrapper的高度也正常
})
// 3.我同事在vue官网发现了一种写法,await this.$nextTick()
await this.$nextTick()
// 在这里获取el-table__body-wrapper的高度也正常
综上的几种方法都能解决问题,那么是不是说明element内部对用户传入的height的处理放在了nextTick里?而且还有一处疑惑的地方,nextTick回调和await nextTick应该是等价的两种写法,为什么会出现不一样的现象?
带着这两个疑惑,进行了如下的探索
引用
本文的部分知识来自几遍文章的参考,文章如下
element内部是如何处理height的
下面的探索过程是我分别找到vue和element-ui的源码,在源码里面加入了一些打印信息,然后再打包dist和lib,来替换vue项目中npm安装包node_modules里面的dist和lib。
从打印信息可以看出:
element内部table-layout.js里有两个关键的函数:setHeight和updateElsHeight
setHeight:可以看到当el是undefined时,不会往下执行,而是使用了nextTick,并将在nextTick的回调中再一次执行setHeight
setHeight(value, prop = 'height') {
if (Vue.prototype.$isServer) return;
const el = this.table.$el;
value = parseHeight(value);
this.height = value;
if (!el && (value || value === 0)) return Vue.nextTick(() => this.setHeight(value, prop));
if (typeof value === 'number') {
el.style[prop] = value + 'px';
this.updateElsHeight();
} else if (typeof value === 'string') {
el.style[prop] = value;
this.updateElsHeight();
}
}
updateElsHeight:这个函数做的一个比较重要的事情是根据用户是否传了height,给bodyHeight赋值,这个bodyHeight就是决定el-table__body-wrapperclientHeight的css高度,在函数执行之初,对$ready进行了判断,$ready是table组件里赋值的一个标志,在组件的mounted生命周期里将$ready置为了true
updateElsHeight() {
if (!this.table.$ready) return Vue.nextTick(() => this.updateElsHeight());
const { headerWrapper, appendWrapper, footerWrapper } = this.table.$refs;
this.appendHeight = appendWrapper ? appendWrapper.offsetHeight : 0;
if (this.showHeader && !headerWrapper) return;
// fix issue (https://github.com/ElemeFE/element/pull/16956)
const headerTrElm = headerWrapper ? headerWrapper.querySelector('.el-table__header tr') : null;
const noneHeader = this.headerDisplayNone(headerTrElm);
const headerHeight = this.headerHeight = !this.showHeader ? 0 : headerWrapper.offsetHeight;
if (this.showHeader && !noneHeader && headerWrapper.offsetWidth > 0 && (this.table.columns || []).length > 0 && headerHeight < 2) {
return Vue.nextTick(() => this.updateElsHeight());
}
const tableHeight = this.tableHeight = this.table.$el.clientHeight;
const footerHeight = this.footerHeight = footerWrapper ? footerWrapper.offsetHeight : 0;
if (this.height !== null) {
this.bodyHeight = tableHeight - headerHeight - footerHeight + (footerWrapper ? 1 : 0);
}
this.fixedBodyHeight = this.scrollX ? (this.bodyHeight - this.gutterWidth) : this.bodyHeight;
const noData = !(this.store.states.data && this.store.states.data.length);
this.viewportHeight = this.scrollX ? tableHeight - (noData ? 0 : this.gutterWidth) : tableHeight;
this.updateScrollY();
this.notifyObservers('scrollable');
}
我们来看一下table组件几个比较重要的地方:
- 先是对height的watch监听,代码如下,setHeight即是上述的setHeight,可以看到,在watch被初始化时,table的dom并未被创建,所以第一次执行setHeight后,nextTick调用了,并将在nextTick的回调中再一次执行setHeight
...
watch: {
height: {
immediate: true,
handler(value) {
this.layout.setHeight(value);
}
},
}
...
- 接着是updateElsHeight产生的bodyHeight被用在了什么地方
模板代码
<div
class="el-table__body-wrapper"
ref="bodyWrapper"
:class="[layout.scrollX ? `is-scrolling-${scrollPosition}` : 'is-scrolling-none']"
:style="[bodyHeight]">
<table-body
:context="context"
:store="store"
:stripe="stripe"
:row-class-name="rowClassName"
:row-style="rowStyle"
:highlight="highlightCurrentRow"
:style="{
width: bodyWidth
}">
</table-body>
<div
v-if="!data || data.length === 0"
class="el-table__empty-block"
ref="emptyBlock"
:style="emptyBlockStyle">
<span class="el-table__empty-text" >
<slot name="empty">{{ emptyText || t('el.table.emptyText') }}</slot>
</span>
</div>
<div
v-if="$slots.append"
class="el-table__append-wrapper"
ref="appendWrapper">
<slot name="append"></slot>
</div>
</div>
computed代码
可以看到,当用户指定height后,组件会根据updateElsHeight的处理,得到layout.bodyHeight,最终成为了el-table__body-wrapper元素的样式高度
bodyHeight() {
const { headerHeight = 0, bodyHeight, footerHeight = 0} = this.layout;
if (this.height) {
return {
height: bodyHeight ? bodyHeight + 'px' : ''
};
} else if (this.maxHeight) {
const maxHeight = parseHeight(this.maxHeight);
if (typeof maxHeight === 'number') {
return {
'max-height': (maxHeight - footerHeight - (this.showHeader ? headerHeight : 0)) + 'px'
};
}
}
return {};
}
- 最后再看组件的mounted生命钩子做了什么
在函数的最后一行,将$ready置为了true,这是控制上述updateElsHeight函数能继续往下走的重要标志
注意mounted里的doLayout方法,doLayout里对updateElsHeight进行了一次调用,而此时的$ready还不为true,这意味着,nextTick调用了,并将在nextTick的回调中再次执行updateElsHeight
mounted() {
this.bindEvents();
this.store.updateColumns();
this.doLayout();
this.resizeState = {
width: this.$el.offsetWidth,
height: this.$el.offsetHeight
};
// init filters
this.store.states.columns.forEach(column => {
if (column.filteredValue && column.filteredValue.length) {
this.store.commit('filterChange', {
column,
values: column.filteredValue,
silent: true
});
}
});
this.$ready = true;
},
methods: {
doLayout() {
// 这里的this.shouldUpdateHeight即是用户传给el-table的height
if (this.shouldUpdateHeight) {
this.layout.updateElsHeight();
}
this.layout.updateColumnsWidth();
},
}
nextTick源码分析
上述我们对el-table几个地方进行了初步的了解,但对于nextTick是如何运转的还是很模糊,下面我们来看看nextTick的源码
在阅读nextTick源码前,我们先了解几个概念:事件循环、宏任务、微任务,下面的表述参照于你真的理解$nextTick么
- 执行一个宏任务(首次执行的主代码块或者任务队列中的回调函数)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有任务(依次执行)
- JS引擎线程挂起,GUI线程执行渲染
- GUI线程渲染完毕后挂起,JS引擎线程执行任务队列中的下一个宏任务
需要注意的是,新创建的微任务会立即进入微任务队列排队执行,不需要等待下一次轮回。
nextTick源码对应的vue版本:2.7.16
/* globals MutationObserver */
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'
export let isUsingMicroTask = false
const callbacks: Array<Function> = []
let pending = false
function flushCallbacks() {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).
let timerFunc
// The nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
// In problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (
!isIE &&
typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
// (#6466 MutationObserver is unreliable in IE11)
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
export function nextTick(): Promise<void>
export function nextTick<T>(this: T, cb: (this: T, ...args: any[]) => any): void
export function nextTick<T>(cb: (this: T, ...args: any[]) => any, ctx: T): void
/**
* @internal
*/
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e: any) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
timerFunc()
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
简单总结一下:
nextTick里面维护了callbacks回调数组,当程序开始执行nextTick创建的某次微任务时,callbacks的cb会被依次执行,cb执行过程中可能会产生新的微任务,这就得益于pending的控制。
timerFunc:初始化由当前的环境决定,当浏览器不支持Promise等微任务时,会被降级为宏任务。
nextTick:$nextTick和nextTick区别就是nextTick多了一个context参数,用来指定上下文。但两个的本质是一样的,$nextTick是实例方法,nextTick是类的静态方法;实例方法的上下文被绑定为调用实例的this。当nextTick不传cb时,会返回一个promise。
pending:相当于一个锁,用于控制是否产生一次新的微任务,当外部有地方使用了nextTick时,就将pending置为true,在某次事件循环执行同步代码的过程中,所调用的nextTick,只要此时的pending为true,都被认做是同一次微任务,并将它们的cb依次放进callbacks中。
当同步代码执行完,开始执行微任务了。flushCallbacks被执行,pending置为false,callbacks被拷贝一份用来执行cb,并将callbacks置为空数组。cb的代码执行过程中,如果使用了nextTick,那么此时的pending会再次设为true,接着产生一次新的微任务,新的cb会被放在已经重新置为空数组的callbacks中。
Promise执行机制
关于promise,本人对其了解并不深入,感兴趣的同学可阅读下面的文章
从一道让我失眠的 Promise 面试题开始,深入分析 Promise 实现细节
promise.then 中 return Promise.resolve 后,发生了什么?-推演解答和源码解答
下面抓住一个promise实现中一个关键的点,来讨论一下为什么nextTick里面返回promise跟在回调里面执行的,会有不一样的结果。如下的代码摘自从一道让我失眠的 Promise 面试题开始,深入分析 Promise 实现细节
// MyPromise.js
then(onFulfilled, onRejected) {
// 为了链式调用这里直接创建一个 MyPromise,并在后面 return 出去
const promise2 = new MyPromise((resolve, reject) => {
// 判断状态
if (this.status === FULFILLED) {
// 创建一个微任务等待 promise2 完成初始化
queueMicrotask(() => {
try {
// 获取成功回调函数的执行结果
const x = onFulfilled(this.value);
// 传入 resolvePromise 集中处理
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error)
}
})
} else if (this.status === REJECTED) {
// ==== 新增 ====
// 创建一个微任务等待 promise2 完成初始化
queueMicrotask(() => {
try {
// 调用失败回调,并且把原因返回
const x = onRejected(this.reason);
// 传入 resolvePromise 集中处理
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error)
}
})
} else if (this.status === PENDING) {
// 等待
// 因为不知道后面状态的变化情况,所以将成功回调和失败回调存储起来
// 等到执行成功失败函数的时候再传递
this.onFulfilledCallbacks.push(() => {
// ==== 新增 ====
queueMicrotask(() => {
try {
// 获取成功回调函数的执行结果
const x = onFulfilled(this.value);
// 传入 resolvePromise 集中处理
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error)
}
})
});
this.onRejectedCallbacks.push(() => {
// ==== 新增 ====
queueMicrotask(() => {
try {
// 调用失败回调,并且把原因返回
const x = onRejected(this.reason);
// 传入 resolvePromise 集中处理
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error)
}
})
});
}
})
return promise2;
}
关键点在于
...
this.onFulfilledCallbacks.push(() => {
// ==== 新增 ====
queueMicrotask(() => {
try {
// 获取成功回调函数的执行结果
const x = onFulfilled(this.value);
// 传入 resolvePromise 集中处理
resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error)
}
})
});
...
我们知道await func(); console.log('1'); 其实相当于 func().then(() => { console.log(1) }),当then方法被调用后,如果promise的状态还是pending,promise就将完成的回调先放进onFulfilledCallbacks,当promise的resolve方法被调用时,再把onFulfilledCallbacks里的回调一个个拿出来执行。基于这样的原理,再结合promise的then中产生了一次微任务的实现(上述代码中的queueMicrotask),就不难推测出,nextTick返回了promise,大概率就造成了它比dom更新滞后了一次微任务,所以才能获取到正确的dom高度。
揭秘
有了上述理论知识的铺垫,相信大家对于这次的探索也有了自己的结论。
下面的页面代码中,有四种不同的写法,后3种写法是能正确获取dom高度的。
<template>
<div class="container">
<el-table
:data="list"
border
highlight-current-row
height="300px"
>
<el-table-column
label="测试"
prop="test"
align="center"
></el-table-column>
</el-table>
</div>
</template>
<script>
export default {
name: "Test",
data() {
return {
list: []
}
},
mounted() {
this.handleRoll()
},
methods: {
async handleRoll() {
this.list = new Array(50).fill({ test: 111 })
// 写法1
this.$nextTick(() => {
const tbBodyWrapper = document.querySelector(".container .el-table__body-wrapper")
const tbBody = document.querySelector(".container .el-table__body")
console.log('两个元素的高度', tbBodyWrapper.clientHeight, tbBody.clientHeight) // 两个元素的高度 2394 2394
})
// 写法2
await this.$nextTick()
const tbBodyWrapper = document.querySelector(".container .el-table__body-wrapper")
const tbBody = document.querySelector(".container .el-table__body")
console.log('两个元素的高度', tbBodyWrapper.clientHeight, tbBody.clientHeight) // 两个元素的高度 251 2394
// 写法3
this.$nextTick(() => {
this.$nextTick(() => {
const tbBodyWrapper = document.querySelector(".container .el-table__body-wrapper")
const tbBody = document.querySelector(".container .el-table__body")
console.log('两个元素的高度', tbBodyWrapper.clientHeight, tbBody.clientHeight) // 两个元素的高度 251 2394
})
})
// 写法4
setTimeout(() => {
const tbBodyWrapper = document.querySelector(".container .el-table__body-wrapper")
const tbBody = document.querySelector(".container .el-table__body")
console.log('两个元素的高度', tbBodyWrapper.clientHeight, tbBody.clientHeight) // 两个元素的高度 251 2394
})
}
}
}
</script>
假设当前执行的同步代码中,nextTick所产生的一次微任务A在收集callbacks
- table组件的watch初始化时,执行setHeight,table.$el为空,callbacks.push(cb-1)
- ...push其他的一些cb...
- table组件的mounted执行,doLayout方法调用,执行updateElsHeight,此时的
$ready为undefined,callbacks.push(cb-n) - 使用el-table的页面执行了mounted,
- 如果是写法1,设获取dom元素高度的cb为cb-n+1,callbacks.push(cb-n+1)
- 如果是写法2,传入的cb为空,由nextTick源码可知,callbacks.push(执行promise的resolve的cb)
- 如果是写法3,设回调为cb-n+3,callbacks.push(cb-n+3)
- 如果是写法4,由于setTimeout是宏任务,在所有微任务执行完后再执行的,所以一定能获取到正确的dom高度
- ...push其他的一些cb...
同步代码执行完了,开始取微任务A的callbacks中的cb执行(注意当微任务A开始执行了,cb执行过程中如果使用nextTick,就属于新的微任务了)
当执行cb-1时,再回看一下setHeight函数的代码,它是调用了updateElsHeight,这时的bodyHeight改变了,Vue发现响应式数据发生了变化,由于Vue是异步执行dom的更新,可以看一下这篇文章(Vue番外篇 -- vue.nextTick()浅析),Vue执行了一次nextTick,推进的cb是flushSchedulerQueue,即是产生了一次新的微任务B,callbacks:[cb-0(flushSchedulerQueue)](假如还有其他cb,那么其实在这里就能拿到正确是dom高度了:callbacks:[cb-0(flushSchedulerQueue), cb-1(能获取dom高度)])
执行微任务A的其他的cb...
如果是写法1:执行cb-n+1,这时就获取dom高度就不会正确了,因为起码得在微任务B执行了cb(flushSchedulerQueue)更新了dom后,才能拿到正确的高度
如果是写法2:执行promise的resolve,调用promise的onFulfilledCallbacks,这时因为FulfilledCallbacks的回调里,把获取dom高度的代码放在了下一次微任务里,也就是在微任务B后的微任务C,在微任务C中自然是能获取到正确的dom高度
如果是写法3:执行cb-n+3,发现又是一次nextTick,那么此时的cb就会放到微任务B的callbacks中,自然当微任务B的cb-0执行完后,它往后的cb也能获取到正确的dom高度了
总结
本文是对el-table使用过程中出现的某个问题的背后原理的研究,通过打印观察和查看其他文章来验证猜想以及得出合理的结论。如果大家认为文章中的内容有不严谨或错误的地方,欢迎评论指正,感谢大家!
创作不易,如要转载,请注明出处。