Vivia Preview

Theme Toggle

从element-plus大表格编辑优化看vue响应式与渲染的关系

本文探讨了Element Plus大表格的性能优化, 分析了Vue渲染机制, 并提出手动更新数据和局部更新等解决方案, 提升性能.

lastModified: 2024-09-05

本文因为示例原因可能会非常卡顿, 请尽可能在电脑端查看.

背景

在大概一年前还是啥时候的, 我司的前端项目里, 有个存在数千单元格的大表格. 虽然一开始的时候想要使用弹窗式的方式对数据进行修改, 但是甲方觉得不行, 所以就还是按照一般的方式做了出来.

下面的是大概的表格代码:

vue

<template>
  <el-table :data="data">
    <el-table-column type="index"></el-table-column>
    <el-table-column prop="prop1" label="字段1" v-slot="{ row }">
      <el-input v-model="row.prop1" />
    </el-table-column>
    <el-table-column prop="prop2" label="字段2"></el-table-column>
    <el-table-column prop="prop3" label="字段3"></el-table-column>
    <el-table-column prop="prop4" label="字段4"></el-table-column>
    <el-table-column prop="prop5" label="字段5"></el-table-column>
    <el-table-column prop="prop6" label="字段6"></el-table-column>
  </el-table>
</template>

<script setup>
import { ElTable, ElTableColumn, ElInput } from 'element-plus';

const data = ref(
  Array.from({ length: 1000 }).map((_, i) => {
    return {
      prop1: i + '-1',
      prop2: i + '-2',
      prop3: i + '-3',
      prop4: i + '-4',
      prop5: i + '-5',
      prop6: i + '-6',
    };
  }),
);
</script>

随便找个输入框输入两个字, 会发现卡顿的非常明显, 而当时的情况比这更加糟糕.

因为当时时间比较紧张暂时就还没有处理, 再过了一段时间后, 因为需求大致都完成了, 客户开始反应这个问题后, 我们开始针对这个问题进行优化.

解决过程

卡顿的原因

卡顿的原因非常明显, vue虽然会通过diff减少DOM的更新, 但是无论是创建vnode还是diff的过程肯定都是存在消耗的. 这种大量vnode的组件, 就算在各类优化之下, 也不可避免的会存在性能问题.

当table的任意数据发生变动的时候, 整个table都会重新经过:

  1. 调用组件的render函数
  2. 创建table及所有子元素的vnode
  3. 对创建的vnode进行diff
  4. 更新DOM

解决方案: 手动更新数据

解决方案还是很简单的, 当时出于对vue的了解还不是很深, 所以采取了一个简单粗暴的办法: 不对整个数据进行响应式, 手动更新数据.

App.vue

vue

<template>
  <el-table :data="tableRows" row-key="prop6">
  <el-table :data="data" row-key="prop6">
    <el-table-column type="index"></el-table-column>
    <el-table-column prop="prop1" label="字段1" v-slot="{ $index }">
      <el-input v-model="row.prop1" />
      <v-input :row="$index" :data="data" prop="prop1" />
    </el-table-column>
    <el-table-column prop="prop2" label="字段2"></el-table-column>
    <el-table-column prop="prop3" label="字段3"></el-table-column>
    <el-table-column prop="prop4" label="字段4"></el-table-column>
    <el-table-column prop="prop5" label="字段5"></el-table-column>
    <el-table-column prop="prop6" label="字段6"></el-table-column>
  </el-table>
</template>

<script setup>
import { ElTable, ElTableColumn, ElInput } from 'element-plus';
import VInput from './v-input.vue';

const data = Array.from({ length: 1000 }).map((_, i) => {
  return {
    prop1: i + '-1',
    prop2: i + '-2',
    prop3: i + '-3',
    prop4: i + '-4',
    prop5: i + '-5',
    prop6: i + '-6',
  };
});
const tableRows = ref(
  data.map((value) => {
    return {
      ...value,
    };
  }),
);
</script>
v-input.vue

vue

<template>
  <el-input v-model="value" />
</template>

<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
  row: Number,
  prop: String,
  data: Array,
});

const _value = ref(props.data[props.row][props.prop]);


const value = computed({
  get() {
    return _value.value;
  },
  set(val) {
    _value.value = val;
    props.data[props.row][props.prop] = val;
  },
});
</script>

这种方式通过手动更新数据, 并且将input组件抽离出来, 让组件有自己的渲染状态, 避免更新时影响整个table. 虽然确实输入没有了卡顿的现象, 但是这种方式有非常大的问题, 数据失去了响应式的能力, 数据变化的通知就需要手动去实现, 增加了代码的复杂度.

后续优化

上面的方式是当时的应对方式, 然后那个项目就又重写了, 这个问题就不再存在了. 时隔一年两年的, 我又在一些面试题中见到了这个问题, 这回对vue也有了更深入的了解, 能够找到更优的方案了.

vue的渲染方式和触发条件

众所周知, vue模板语法最终会被编译成渲染函数. 那么是如何收集渲染函数的依赖并且准确的只触发依赖了响应值的渲染函数呢?

runtime-core/src/renderer.ts

typescript

// runtime-core/src/renderer.ts:1271
const componentUpdateFn = () => {
  if (!instance.isMounted) {
    // ....此处省略部分代码

    const subTree = (instance.subTree = renderComponentRoot(instance));
    patch(null, subTree, container, anchor, instance, parentSuspense, namespace);
    initialVNode.el = subTree.el;
    //  ....此处省略部分代码
  } else {
    //  ....此处省略部分代码

    // render
    const nextTree = renderComponentRoot(instance);
    const prevTree = instance.subTree;
    instance.subTree = nextTree;

    patch(
      prevTree,
      nextTree,
      // parent may have changed if it's in a teleport
      hostParentNode(prevTree.el!)!,
      // anchor may have changed if it's in a fragment
      getNextHostNode(prevTree),
      instance,
      parentSuspense,
      namespace,
    );
    //  ....此处省略部分代码
  }
};

以上代码是在vue中执行挂载和更新时执行的方法, 其中renderComponentRoot就是执行render并且接收其返回的vnode, 在patch函数中进行diff决定是否更新.

runtime-core/src/renderer.ts

typescript

// runtime-core/src/renderer.ts:1536
// create reactive effect for rendering
const effect = (instance.effect = new ReactiveEffect(
  componentUpdateFn,
  NOOP,
  () => queueJob(update),
  instance.scope, // track it in component's effect scope
));

const update: SchedulerJob = (instance.update = () => {
  if (effect.dirty) {
    effect.run();
  }
});

这是vue执行组件挂载和更新的入口部分, 是一个副作用类, 是用来被依赖项收集并且执行的方法.

其中run的实现:

reactivity/src/effect.ts

typescript

// reactivity/src/effect.ts:28
class ReactiveEffect<T = any> {
  // ...省略部分代码

  constructor(
    public fn: () => T,
    public trigger: () => void,
    public scheduler?: EffectScheduler,
    scope?: EffectScope,
  ) {
    recordEffectScope(this, scope);
  }

  // ...省略部分代码
  run() {
    this._dirtyLevel = DirtyLevels.NotDirty;
    if (!this.active) {
      return this.fn();
    }
    let lastShouldTrack = shouldTrack;
    let lastEffect = activeEffect;
    try {
      shouldTrack = true;
      activeEffect = this;
      this._runnings++;
      preCleanupEffect(this);
      return this.fn();
    } finally {
      postCleanupEffect(this);
      this._runnings--;
      activeEffect = lastEffect;
      shouldTrack = lastShouldTrack;
    }
  }

  // ...省略部分代码
}

将当前activeEffect设置为当前effect, 然后执行副作用函数fn, 这样fn在执行过程中就能被依赖项收集到了. 至于为什么能够收集到, 那就是响应式原理的部分了, 这里暂且不谈(在刚刚发布的3.5版本中, 响应式的过程发生了较大改变, 目前尚未了解).

到这里的结论是一个组件的渲染函数执行时机在于其执行过程中的依赖值变动, 因此不想让渲染函数也就是render执行, 就不能在执行过程中依赖对应的响应式数据.

所以优化大表格数据变动的性能问题, 就是不能让整个表格的render函数执行, 而是只让变动的单元格内的组件执行render.

而在element-plus中, 表格会深度监听data, 所以在data中任意数据发生变动的时候, 会导致整个表格的render函数执行.

element-plus的table组件监听data的部分:

components/table/src/table/style-helper.ts

typescript

// package/components/table/src/table/style-helper.ts

// ...省略若干代码
function useStyle<T>(props: TableProps<T>, layout: TableLayout<T>, store: Store<T>, table: Table<T>) {
  // ...省略若干代码
  watch(
    () => props.data,
    (data) => {
      table.store.commit('setData', data);
    },
    {
      immediate: true,
      deep: true,
    },
  );
  // ...省略若干代码
}

export default useStyle;

其在element-plus的table组件中的调用链路:

  1. packages/components/table/src/table.vue:275
  2. -> packages/components/table/src/table/style-helper.ts:68
  3. -> packages/components/table/src/store/index.ts:45

因此针对element-plus的情况就需要分离传入表格的data和需要修改的数据, 使得修改的数据不会使表格组件重新渲染.

element-plus的优化方案

上面已经有了分离数据和阻止当前组件渲染函数执行的结论, 所以就很好办了:

App.vue

vue

<template>
  <el-table :data="tableRows" row-key="prop6">
    <el-table-column type="index"></el-table-column>
    <el-table-column prop="prop1" label="字段1" v-slot="{ $index }">
      <v-input :row="$index" :data="data" prop="prop1" /> 
    </el-table-column>
    <el-table-column prop="prop2" label="字段2"></el-table-column>
    <el-table-column prop="prop3" label="字段3"></el-table-column>
    <el-table-column prop="prop4" label="字段4"></el-table-column>
    <el-table-column prop="prop5" label="字段5"></el-table-column>
    <el-table-column prop="prop6" label="字段6"></el-table-column>
  </el-table>
</template>

<script setup>
import { ElTable, ElTableColumn, ElInput } from 'element-plus';
import { ref } from 'vue';
import VInput from './v-input.vue';

const origin = Array.from({ length: 1000 }).map((_, i) => {
  return {
    prop1: i + '-1',
    prop2: i + '-2',
    prop3: i + '-3',
    prop4: i + '-4',
    prop5: i + '-5',
    prop6: i + '-6',
  };
});
const data = ref(origin);
const tableRows = ref(
  origin.map((value) => {
    return {
      ...value,
    };
  }),
);
</script>
v-input.vue

vue

<template>
  <el-input v-model="value" />
</template>

<script setup>
import { computed } from 'vue';

const props = defineProps({
  row: Number,
  data: Object,
  prop: String,
});

const value = computed({
  get() {
    const { data, row, prop } = props;
    return data[row][prop];
  },
  set(val) {
    const { data, row, prop } = props;
    data[row][prop] = val;
  },
});
</script>

这里不直接将值绑定到v-model上, 而是将列和行的值传入组件, 这些值不会发生变化, 也不容易触发prop的更新. 然后再组件内部取出依赖的值, 绑定在v-model上, 这样就能将渲染从父级组件的整体渲染转移到独立的组件渲染. 由于table的data没有发生变化, 所以table也就不会发生重新渲染.

新的要求

上面的情况基本解决了之前的需求, 然后有位大佬提出了一个面试题, 新增了一个要求:

  • 点击一个单元格, 能够修改数据, 并且其他的单元格如果是编辑状态应该关闭.

其实这个要求还是非常简单的, 上面已经实现了单个单元格的数据修改和封装, 那么只需要在组件内新增一个状态就好了. 这里的问题是, 独立组件的变化如何通知其他的组件并且不能在父级的渲染函数中执行, 避免父级重新渲染.

因为不能在父级设置一个具有响应式, 会触发渲染函数的值, 这里我想到了两个方式:

  • 通过provide向下注入父级一个方法, 让子级调用
  • 通过props传递一个引用不会变动, 也没有响应式的发布订阅的一个实现

provide可以跳过渲染函数直接传递到子组件实例, 而使用一个非响应式的对象, 也不会触发父级组件渲染. 这里就都实现一遍吧

provide版

App.vue

vue

<template>
  <el-table :data="tableRows" row-key="prop6">
    <el-table-column type="index"></el-table-column>
    <el-table-column prop="prop1" label="字段1" v-slot="{ $index }">
      <v-input :data="data" :row="$index" prop="prop1" />
    </el-table-column>
    <el-table-column prop="prop2" label="字段2"></el-table-column>
    <el-table-column prop="prop3" label="字段3"></el-table-column>
    <el-table-column prop="prop4" label="字段4"></el-table-column>
    <el-table-column prop="prop5" label="字段5"></el-table-column>
    <el-table-column prop="prop6" label="字段6"></el-table-column>
  </el-table>
</template>

<script setup>
import { ElTable, ElTableColumn, ElInput } from 'element-plus';
import { ref } from 'vue';
import { provideData } from './context.js';
import VInput from './v-input.vue';

const currentRowAndProp = ref( 
  row: null,
  prop: null,
});
provideData({
  currentRowAndProp,
});

const origin = Array.from({ length: 1000 }).map((_, i) => {
  return {
    prop1: i + '-1',
    prop2: i + '-2',
    prop3: i + '-3',
    prop4: i + '-4',
    prop5: i + '-5',
    prop6: i + '-6',
  };
});
const data = ref(origin);
const tableRows = ref(
  origin.map((value) => {
    return {
      ...value,
    };
  }),
);
</script>
v-input.vue

vue

<template>
  <el-input v-model="value" v-if="isUpdate" @blur="onBlur" />
  <div v-else @click="onClick">{{ value }}</div>
</template>

<script setup>
import { computed, watch, ref } from 'vue';
import { injectData } from './context.js';

const props = defineProps({
  row: Number,
  data: Object,
  prop: String,
});

const isUpdate = ref(false);
const { currentRowAndProp } = injectData();
const onClick = () => {
  currentRowAndProp.value = { row: props.row, prop: props.prop };
  isUpdate.value = true;
};
const onBlur = () => {
  currentRowAndProp.value = {
    row: null,
    prop: null,
  };
  isUpdate.value = false;
};
watch( 
  () => ({ ...currentRowAndProp.value }),
  ({ row, prop }) => {
    if (!isUpdate.value) return;
    const isCurrent = row === props.row && prop === props.prop;
    isUpdate.value = isCurrent;
  },
  {
    immediate: true,
  },
);

const value = computed({
  get() {
    const { data, row, prop } = props;
    return data[row][prop];
  },
  set(val) {
    const { data, row, prop } = props;
    data[row][prop] = val;
  },
});
</script>
context.vue

javascript

import { inject, provide } from 'vue';

export const injectKey = Symbol('injectKey');

export const provideData = (data) => {
  provide(injectKey, data);
};

export const injectData = () => {
  return inject(injectKey);
};

非常简单的实现了点击其他单元格关闭编辑状态的功能.

通过发布订阅修改状态

App.vue

vue

<template>
  <el-table :data="tableRows" row-key="prop6">
    <el-table-column type="index"></el-table-column>
    <el-table-column prop="prop1" label="字段1" v-slot="{ $index }">
      <v-input :data="data" :row="$index" prop="prop1" :eventBus="eventBus" />
    </el-table-column>
    <el-table-column prop="prop2" label="字段2"></el-table-column>
    <el-table-column prop="prop3" label="字段3"></el-table-column>
    <el-table-column prop="prop4" label="字段4"></el-table-column>
    <el-table-column prop="prop5" label="字段5"></el-table-column>
    <el-table-column prop="prop6" label="字段6"></el-table-column>
  </el-table>
</template>

<script setup>
import { ElTable, ElTableColumn, ElInput } from 'element-plus';
import { ref, onBeforeUnmount } from 'vue';
import VInput from './v-input.vue';

class EventBus {
  constructor() {
    this.subs = {};
  }
  on(event, fn) {
    const subs = this.subs[event] || (this.subs[event] = new Set());
    subs.add(fn);
  }
  off(event, fn) {
    const subs = this.subs[event];
    if (!subs) return;
    this.subs[event] = subs.filter((cb) => cb !== fn);
  }
  emit(event, ...args) {
    const subs = this.subs[event];
    if (!subs) return;
    for (const cb of subs) {
      cb(...args);
    }
  }
  clear() {
    this.subs = {};
  }
}

const eventBus = new EventBus();

onBeforeUnmount(() => {
  eventBus.clear();
});

const origin = Array.from({ length: 10 }).map((_, i) => {
  return {
    prop1: i + '-1',
    prop2: i + '-2',
    prop3: i + '-3',
    prop4: i + '-4',
    prop5: i + '-5',
    prop6: i + '-6',
  };
});
const data = ref(origin);
const tableRows = ref(
  origin.map((value) => {
    return {
      ...value,
    };
  }),
);
</script>
v-input.vue

vue

<template>
  <el-input v-model="value" v-if="isUpdate" @blur="onBlur" />
  <div v-else @click="onClick">{{ value }}</div>
</template>

<script setup>
import { computed, watch, ref } from 'vue';

const props = defineProps({
  row: Number,
  data: Object,
  prop: String,
  eventBus: Object,
});

const eventBus = props.eventBus;

const isUpdate = ref(false);
const onClick = () => {
  isUpdate.value = true;
  eventBus.emit('activeChange', { row: props.row, prop: props.prop });
};
const onBlur = () => {
  isUpdate.value = false;
};

const onActiveChange = ({ row, prop }) => {
  isUpdate.value = row === props.row && prop === props.prop;
};
eventBus.on('activeChange', onActiveChange);

const value = computed({
  get() {
    const { data, row, prop } = props;
    return data[row][prop];
  },
  set(val) {
    const { data, row, prop } = props;
    data[row][prop] = val;
  },
});
</script>

以上两种实现方式都实现了点击其他单元格关闭编辑状态的功能, 并且状态的变更没有卡顿感.

总结

通过这个过程, 能够了解关于vue和渲染函数之间的基本关系, 以及对于大数量表格编辑的优化方式.

渲染函数本质也是一个副作用函数, 当依赖发生变化的时候会执行, 和watch/effect之类的副作用函数相同. 尽管vue通过diff避免了对DOM的大量直接的修改, 但是创建vnode和diff在大量数据的情况下还是会带来性能上的问题.

同时, 示例中的实现在初次渲染时也表现得比较卡顿, 在实际业务中可以通过间断性的渲染来解决, 这里就不再赘述了.

通过封装较小粒度得组件, 能够充分利用vue的组件设计, 达到最小化的更新渲染. 这在日常业务中也相对比较常见, 对于大型表单来说也需要结合实际针对性的优化.

这个实践为未来开发时遇到相同类型的性能问题提供了参考价值, 也让我更加了解vue的一些实现过程和细节上的问题.

© 9999 Vivia Name

Powered by Nextjs & Theme Vivia

主题完全模仿 Vivia 主题, 有些许差异, 及使用nextjs乱写

Theme Toggle