你可以通过这个CodeSandbox 完整示例来实际观察下性能损耗。点击箭头展开或折叠某个节点,在 Chrome DevTools 的控制台(不要使用 CodeSandbox 的控制台,不准确)中输出如下。
first rendering: 406.068115234375ms expanded change: 231.623779296875ms
在笔者的低功耗笔记本下,初次渲染耗时 400+ms,展开或折叠节点 200+ms。下面我们来优化 Tree 组件的运行性能。
若你的设备性能强劲,可修改生成的节点数量,如 this.getRandomData(4, 10) 生成 10000 个节点。
使用 Chrome Performance 查找性能瓶颈
Chrome 的 Performance 面板可以录制一段时间内的 js 执行细节及时间。使用 Chrome 开发者工具分析页面性能的步骤如下。
打开 Chrome 开发者工具,切换到 Performance 面板 点击 Record 开始录制 刷新页面或展开某个节点 点击 Stop 停止录制
console.time 输出的值也会显示在 Performance 中,帮助我们调试。更多关于 Performance 的内容可以看。
优化运行时性能
条件渲染
我们往下翻阅 Performance 分析结果,发现大部分耗时都在 render 函数上,并且下面还有很多其他函数的调用。
在遍历节点时,对于节点的可见性我们使用的是 v-show 指令,不可见的节点也会渲染出来,然后通过样式使其不可见。因此尝试使用 v-if 指令来进行条件渲染。
<li v-for="node in nodes" v-if="status[node.key].visible" :key="node.key" :style="{ 'padding-left': `${node.level * 16}px` }" > ... </li>
v-if 在 render 函数中表现为一个三目表达式:
visible ? h('li') : this._e() // this._e() 生成一个注释节点
即 v-if 只是减少每次遍历的时间,并不能减少遍历的次数。且中明确指出不要把 v-if 和 v-for 同时用在同一个元素上,因为这可能会导致不必要的渲染。
我们可以更换为在一个可见节点的计算属性上进行遍历:
<li v-for="node in visibleNodes" :key="node.key" :style="{ 'padding-left': `${node.level * 16}px` }" > ... </li> <script> export { // ... computed: { visibleNodes() { return this.nodes.filter(node => this.status[node.key].visible); }, }, // ... } </script>
优化后的性能耗时如下:
first rendering: 194.7890625ms expanded change: 204.01904296875ms
你可以通过改进后的示例 (Demo2) 来观察组件的性能损耗,相比优化前有很大的提升。
双向绑定
在前面的示例中,我们使用 .sync 对 expanded-keys 进行了“双向绑定”,其实际上是 prop 和自定义事件的语法糖。这种方式能很方便地让 Tree 的父组件同步展开状态的更新。
但是,使用 Tree 组件时,不传 expanded-keys ,会导致节点无法展开或折叠,即使你不关心展开或折叠的操作。这里把 expanded-keys 作为外界的副作用了。
<!-- 无法展开 / 折叠节点 --> <tree :data="data"></tree>
这里还存在一些性能问题,展开或折叠某一节点时,触发父组件的副作用更新 expanded-keys 。Tree 组件的 status 依赖了 expanded-keys ,会调用 this.getStatus 方法获取新的 status 。即使只是单个节点的状态改变,也会导致重新计算所有节点的状态。
我们考虑将 status 作为一个 Tree 组件的内部状态,展开或折叠某个节点时,直接对 status 进行修改。同时定义默认的展开节点 default-expanded-keys 。 status 只在初始化时依赖 default-expanded-keys 。
export default { props: { data: Array, // 默认展开节点 defaultExpandedKeys: { type: Array, default: () => [], }, }, data() { return { status: null, // status 为局部状态 }; }, computed: { nodes() { return this.getNodes(this.data); }, }, watch: { nodes: { // nodes 改变时重新计算 status handler() { this.status = this.getStatus(this.nodes); }, // 初始化 status immediate: true, }, // defaultExpandedKeys 改变时重新计算 status defaultExpandedKeys() { this.status = this.getStatus(this.nodes); }, }, methods: { getNodes(data, level = 0, parent = null) { // ... }, getStatus(nodes) { // ... }, // 展开或折叠节点时直接修改 status,并通知父组件 changeExpanded(key) { console.time('expanded change'); const node = this.nodes.find(n => n.key === key); // 找到该节点 const newExpanded = !this.status[key].expanded; // 新的展开状态 // 递归该节点的后代节点,更新 status const updateVisible = (n, visible) => { n.children.forEach((child) => { this.status[child.key].visible = visible && this.status[n.key].expanded; if (child.children) updateVisible(child, visible); }); }; this.status[key].expanded = newExpanded; updateVisible(node, newExpanded); // 触发节点展开状态改变事件 this.$emit('expanded-change', node, newExpanded, this.nodes.filter(n => this.status[n.key].expanded)); this.$nextTick(() => { console.timeEnd('expanded change'); }); }, }, beforeCreate() { console.time('first rendering'); }, mounted() { console.timeEnd('first rendering'); }, };
使用 Tree 组件时,即使不传 default-expanded-keys ,节点也能正常地展开或收起。