nextTick回调中获取的dom高度不正确了

604 阅读4分钟

前言

框架:vue2和element-ui

目的:el-table中的内容想要做自滚动和循环滚动展示

实现:给el-table设置了height属性,在页面的mounted里面给el-table的data属性绑定的数据赋值后,使用this.$nextTick(() => {/** 在这里面获取el-table__body-wrapperel-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应该是等价的两种写法,为什么会出现不一样的现象?

带着这两个疑惑,进行了如下的探索

引用

本文的部分知识来自几遍文章的参考,文章如下

你真的理解$nextTick么

从一道让我失眠的 Promise 面试题开始,深入分析 Promise 实现细节

Vue中$nextTick源码解析

Vue番外篇 -- vue.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组件几个比较重要的地方:

  1. 先是对height的watch监听,代码如下,setHeight即是上述的setHeight,可以看到,在watch被初始化时,table的dom并未被创建,所以第一次执行setHeight后,nextTick调用了,并将在nextTick的回调中再一次执行setHeight
...
watch: {
  height: {
    immediate: true,
    handler(value) {
      this.layout.setHeight(value);
    }
  },
}
...
  1. 接着是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 {};
}
  1. 最后再看组件的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 V8 源码分析(一)

下面抓住一个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

  1. table组件的watch初始化时,执行setHeight,table.$el为空,callbacks.push(cb-1)
  2. ...push其他的一些cb...
  3. table组件的mounted执行,doLayout方法调用,执行updateElsHeight,此时的$ready为undefined,callbacks.push(cb-n)
  4. 使用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高度
  5. ...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使用过程中出现的某个问题的背后原理的研究,通过打印观察和查看其他文章来验证猜想以及得出合理的结论。如果大家认为文章中的内容有不严谨或错误的地方,欢迎评论指正,感谢大家!

创作不易,如要转载,请注明出处。