理解Vue内部渲染机制(避免页面性能瓶颈)

前言

Vue 提升了我们开发效率和页面性能,但是在某些情况下 Vue 页面的性能甚至大大低于直接使用 jQuery。

我之前写过一篇 虚拟列表 处理大量数据,但是项目中会使用一些第三方 ui 库,或者存在不适用虚拟列表的情况。

最近也详细的看了下 Vue 的渲染机制,下面说说咱们开发者怎么避免一些 Vue 页面的性能瓶颈。

开始

我们先定义一个组件,后面一步一步对这个组件进行优化。

// template
<div class="container">
    <div style="margin-bottom: 20px">
      <button @click='search'>查询</button>
    </div>

    <!-- 使用了 ant-design 的组件 -->
    <a-table
      :data-source="tableData"
      :columns="columns"
      :row-key="record => record.id"
      :pagination="false"
    >
      <template
        v-for="key in keys"
        :prop="key"
        :laba="key"
        #[key]="text, row"
      >
        {{ row[key] }}
      </template>
    </a-table>
</div>

// js
export default {
  data: function() {
    return { 
      tableData: [],
      keys: [],
      columns: [],
    }
  },
  methods: {
    search() {
      const { data, keys } = this.getData()
      this.keys = keys
      this.columns = keys.map(key => ({
        title: key,
        key: key,
        dataIndex: key,
        scopedSlots: { customRender: key },
      }))
      this.tableData = data
    },
    // 模拟请求的数据
    getData() {
      const arr = []
      let i = 1000
      while (i--) {
        const item = { id: i, a0: 1, a1: 1, a2: 1, a3: 1, a4: 1,  a5: 1, a6: 1, a7: 1, a8: 1, a9: 1}
        const data = { id: i, b0: item, b1: item, b2: item, b3: item, b4: item,  b5: item, b6: item, b7: item, b8: item, b9: item }

        arr.push(data)
      }

      return { data: arr, keys: Object.keys(arr[0]) }
    }
  }
}

如何渲染大量数据

这个时候我们已经定义了一个组件,只要点击按钮查询,就会渲染大量的数据。

image.png

性能

查询中创建假数据的时间小到可以忽略不计,剩下可以看到 Vue 对每条数据添加响应式总共用了 2.3s,然后渲染用了 1.26s,我们从点击按钮到看到数据总共花了约 3.6s。

image.png

优化

我们上面 js 连续执行时间过长,会导致页面长时间无法交互,而且渲染时间太慢,用户一次交互反馈的时间过长,我们对这两点进行优化。

{
    // 刚才的查询方法
    search() {
        // 省略代码。。。
        
        // 我们把赋值操作换到 setData 方法中
        // this.tableData = data
        this.setData(data)
    },
    setData(data) {
        if (!data.length) return
        
        // 我们把数据切割成十份进行批次渲染
        requestAnimationFrame(async () => {
            const num = 100
            this.tableData.push(...data.slice(0, num))
            this.setData(data.slice(num))
        })
    },
}

效果

可以看到刚才的任务被分割成十份,每一份的间隙都会进行渲染,这个时候我们减少了 js 的连续运行时间,并且加快了渲染时间,利用加长总运行时间换取了渲染时间,用户既能快速得到反馈,而且不会因为过长时间的 js 运行而无法与页面交互。

image.png

效果详细

我们把区域缩小,发现用户得到反馈的时间用了 3694 - 3160 = 500(ms),比起之前的 3.6s 快了 6 倍!

image.png

总结

我们在 Vue 项目中尽量避免一次性渲染大量数据,采用分批渲染效果会更好。

组件化的重要性

我们在 Vue 项目的开发过程中会不断的封装组件,这样不仅利于我们维护,而且在性能上也会有很大的提升,下面还是接着上面的代码,说一说组件化对性能的影响。

我们在 data 里面定义一个 num: 0,然后在刚才的查询按钮旁边放置一个按钮。

<div style="margin-bottom: 20px">
    <button @click='search'>查询</button>
    <!-- 增加的按钮 -->
    <button @click='num++' style="margin-left: 20px">{{ num }}</button>
</div>

image.png

性能

当我们页面中出现了大量数据之后,我们点击刚才新创建的按钮,我们会发现页面很卡顿。

我总共点击了两次,每一次大约都会执行 1.7s js,当我点开运行 js 任务时,发现占用时间的就是 Vue 更新当前组件的方法。

因为在 Vue2.x 之后 Vue 的响应式是以组件为粒度进行更新的,只要修改了当前组件中所使用的数据(咱们修改的是 num),组件就会整个去进行更新,我们的 1.7s 中有大量时间是对表格数据去做 diff 算法的对比。

image.png

优化

我们现在要做的是把表格抽离为一个组件。

// MyTable.vue

// template
<div>
  <!-- 使用了 ant-design 的组件 -->
  <a-table
      :data-source="tableData"
      :columns="columns"
      :row-key="record => record.id"
      :pagination="false"
    >
      <template
        v-for="key in keys"
        :prop="key"
        :laba="key"
        #[key]="text, row"
      >
      {{ row[key] }}
      </template>
    </a-table>
</div>

// js
export default {
  // 需要的数据
  props: ['keys', 'tableData', 'columns']
}
// 父组件内

// template
<!-- 封装的表格组件 -->
<MyTable
  :keys="keys"
  :tableData="tableData"
  :columns="columns"
/>

// js
// 引入封装的组件,并且要注册组件哦。
import MyTable from './MyTable.vue'

效果

我还是点击了两次,你会发现根本没有刚才那么长时间的 js 任务,现在总共才花了 6ms 运行 js,比没封装组件之前快了 573倍(1719 / 3)。

image.png

原因

为什么封装成了组件性能这么高呢,这涉及到 Vue 的更新机制。

Vue 在更新的时候会调用 patch 方法进行虚拟 dom 的对比,path 里面调用了 patchVnode,而每个组件生成的时候会有一个 hook,里面会有一个 prepatch 方法,对比到 MyTable 组件的时候就会进入这个方法。

// 组件的钩子声明
const componentVNodeHooks: {
    init(vnode: any, hydrating: boolean): boolean | null;
    prepatch(oldVnode: any, vnode: any): void;
    insert(vnode: any): void;
    destroy(vnode: any): void;
}
function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
  // 省略代码...
  
    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
        // 对比到组件时会进入这个 prepatch 方法
        i(oldVnode, vnode)
    }
  
  // 省略代码...
}

这个 prepatch 里面调用了 updateChildComponent 方法,这就是我们没封装组件之前,运行时间过长的方法。

prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
}

我们进入这个方法瞅一眼,会发现有个 update props 这一行,咱们再不进套娃函数了,太深了。

这块的作用是什么,就是子组件初始化的时候会把自己的 props 变成响应式的,在下面的代码中会依次对 props 进行赋值,因为是响应式的,所以只要子组件使用到的 props 里面的值发生了变化,就会触发子组件的更新,但是此时因为 props 里面的值没有发生变化,所以子组件不会触发更新。

export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  // 省略代码...

  // update props
  if (propsData && vm.$options.props) {
    toggleObserving(false)
    const props = vm._props
    const propKeys = vm.$options._propKeys || []
    for (let i = 0; i < propKeys.length; i++) {
      const key = propKeys[i]
      const propOptions: any = vm.$options.props // wtf flow?
      props[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    // keep a copy of raw propsData
    vm.$options.propsData = propsData
  }
  
  // 省略代码...
}

总结

这个时候大家应该知道了组件化的重要性,不仅仅是利于我们维护,而且可以大大提升我们项目的性能,所以我们开发过程中还是尽量要组件化。

插槽对组件的影响

插槽也是我们经常使用的,上面说了组件化如何提升性能,还是接着上面的代码,下面就说一说插槽对我们组件化性能的影响。

我们就在刚才的组件里面随便写了点文字,子组件里面写不写默认 slot 都可以。

// 父组件内

// template
<MyTable
  :keys="keys"
  :tableData="tableData"
  :columns="columns"
>
    我就是一段简单的静态文字
</MyTable>

性能

这个时候我再去点击这个数字按钮,发现又突然卡了起来。

image.png

image.png

原因

还是刚才的 updateChildComponent 方法里面,这个时候 needsForceUpdate 的结果是 true,在最下面会触发 MyTable 组件的强制更新,大家可以看 needsForceUpdate 上面的英文注释,大意就是:(在父级更新期间,父级的任何静态插槽子级可能已更改。动态作用域插槽也可能已更改。在这种情况下,需要强制更新以确保正确性)

export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  // 省略代码...

  // Any static slot children from the parent may have changed during parent's update. Dynamic scoped slots may also have changed. In such cases, a forced update is necessary to ensure correctness.
  const needsForceUpdate = !!(
    renderChildren ||               // has new static slots
    vm.$options._renderChildren ||  // has old static slots
    hasDynamicScopedSlot
  )
  
  // resolve slots + force update if has children
  if (needsForceUpdate) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context)
    vm.$forceUpdate()
  }
  
  // 省略代码...
}

动态插槽

从上面咱们可以看到静态插槽的情况,子组件会强制更新保证正确性,上面也说了动态插槽也会导致强制更新,我们来看一下。

// 父组件内

// template
<MyTable
  :keys="keys"
  :tableData="tableData"
  :columns="columns"
>
    <!-- 记得在 data 里面定义 myName -->
    <template #[myName]>
        我就是一段简单的静态文字
    </template>
</MyTable>

性能

这个时候继续点这个数字按钮,发现果然还是卡,和上面静态插槽的效果一致。

image.png

image.png

总结

上面的静态插槽和动态插槽一样,为了保证正确性,都会强制更新,大家也可以看下咱们最开始没抽离组件的代码,那个表格就是因为使用了动态插槽,所以会被强制更新。

作用域插槽

对于作用域插槽,是比较特殊的,需要单独来说一说,下面先看下不同插槽的编译结果。

编译对比

大家可以发现,静态插槽内容的生成是在父组件内完成,而作用域插槽里面的内容是封装在一个 fn 的函数里面,这个不同之处就是作用域插槽的内容不会在父组件中生成,而是等待子组件去调用 fn 函数,在子组件里面生成内容,这也就意味着只有当作用域里插槽面所使用的数据发生变化时,子组件才会被通知更新。

静态插槽:

// template
<MyTable
    :keys="keys"
    :tableData="tableData"
    :columns="columns"
>
  我就是一段简单的静态文字
</MyTable>


// 编译后代码
function render() {
  with(this) {
    return _c('MyTable', {
      attrs: {
        "keys": keys,
        "tableData": tableData,
        "columns": columns
      }
    }, [_v("\n      我就是一段简单的静态文字\n ")])
  }
}

作用域插槽:

// template
<MyTable
    :keys="keys"
    :tableData="tableData"
    :columns="columns"
>
    <template #default>
      我就是一段简单的静态文字
    </template>
</MyTable>


// 编译后代码
function render() {
  with(this) {
    return _c('MyTable', {
      attrs: {
        "keys": keys,
        "tableData": tableData,
        "columns": columns
      },
      scopedSlots: _u([{
        key: "default",
        fn: function () {
          return [_v("\n      我就是一段简单的静态文字\n    ")]
        },
        proxy: true
      }])
    })
  }
}

性能

这个时候我们去把动态插槽变成作用域插槽,试试效果。

// 父组件内

// template
<MyTable
  :keys="keys"
  :tableData="tableData"
  :columns="columns"
>
    <template #default>
        我就是一段简单的静态文字
    </template>
</MyTable>

去点击按钮!

image.png

效果

我点击了两次,发现 js 运行总时间只有 4 ms,效果杠杠的。

image.png

最后

说了这么多,大家应该对 Vue 的组件化有了一定的认识了吧,如果文中有错误,欢迎大家指正!!!