theme: devui-blue
highlight: ocean
从一个 issue
看 el-table
的实现原理,想过复杂,但没想过这么复杂…反手就是一个源码解析!go!
相信用过 el-table
的伙伴有很多,毕竟搞ToB业务 table
必不可少,但是真正翻看过源码的应该还是少数,所以没看过源码的你就真的对其内部实现一点点兴趣都没有吗?笔者一开始就是因为怀揣着好奇心,所以才走上了源码这条不归路…现在回想起来,那是一个月黑风高的夜晚…
起因
故事的起因是这样的,在这个月黑风高的夜晚里,我手贱地点开了 Element Plus 的 gayhub,在上面翻看着各种内容,无意间,一条非常诡异的 issue
引起了我的注意,于是我就点开了,于是这就一发不可收拾了~
关于这条 issue
是这样的:
- 使用
v-for
生成<el-table-column />
的table
,添加key
属性后无法改变columns
的顺序。
这里笔者简要介绍下问题表象:
如图所示,两个 table
都是通过 v-for
来完成 columns
的生成。
- 其中第一个
table
(黄色箭头)的v-for
遵循使用规范,给循环的项增加了key
属性。 - 其中第二个
table
(蓝色箭头)的循环中并没有添加key
属性。 - 有一个按钮,点击后会互换
columnsData
的 第一、第二条数据
现在笔者点击按钮后界面发生如下变化:
对比这上面两张图,很明显就能看出来点击按钮后,没有绑定 key
属性的 table
成功互换了“两列”的位置,而加上了 key
的 table
“表面上”并没有动静,还是维持了原本的顺序。
感兴趣的朋友可以通过在线调试: Playground 点进去自己玩一下~
好了,就是这么一个问题。不知道读到这里的朋友有没有在开发中遇到这样的问题呢?笔者自问了一下,为什么自己从来没有遇到这个问题呢???看来平时只要代码不规范,就不会有两行泪~此时此刻,我不由自主地掏出了下面这张图,原来图中的道理是真的,信不信由你!
走远了走远了,回到这个问题上来不妨先思考一下,对一个 v-for
列表的列表添加 key
本是最合理不过的事情了,为什么会导致 table
互换列失败呢?基于这一点的疑问,我们就要翻开它的源码了~当然,整个 table
的源码还是相对比较多的,而本文更多的是针对这个 issue ,以找到问题的根源为发起点去解析源码,不能尽善尽全还请多多见谅。
一、初识<el-table>
说起“初识”其实也不算“初识”,毕竟使用了这么久了,用这个组件来完成了很多业务需求,应该算是个最熟悉的陌生人吧!那现在我们就开始尝试更深入的了解这个老朋友吧。
1. 组成结构
虽然是看源码,但是一上来就堆代码就怕吓跑你们,这时候不妨先看看 el-table
完成渲染后是一个怎么样的 dom
结构组成。笔者就上面的 demo 截了个图如下:
如图中圈起来的部分可以发现,整个 el-table
渲染到 dom
上面总体上分为两块:
- 表头部分。接着展开看看:
不难发现,表头部分是由一个完整table标签
+thead标签
等组成。 - 表格(body)部分。展开看看:
跟上述表头的差不多,也是一个完整table标签
+tbody标签
等组成的。
看完它们的表现形式后,我们真正的打开 table
的源码(位于:packages/components/table/src/table.vue
),看看整体的组成是不是如我们所见的一样布局。由于整个 table 的模板代码已经去到差不多150行了,笔者删减了一些属性代码:
<!-- el-table 模板代码 -->
<div ref="tableWrapper">
<div>
<div ref="hiddenColumns" class="hidden-columns">
<!-- 注意!!!发现一个默认插槽 -->
<slot />
</div>
<div v-if="showHeader && tableLayout === 'fixed'" ref="headerWrapper">
<!-- 出现第一个 table 标签 -->
<table ref="tableHeader">
<!-- 出现第一个 table-header 组件 -->
<table-header ref="tableHeaderRef" />
</table>
</div>
<div ref="bodyWrapper">
<el-scrollbar>
<!-- 出现第二个 table 标签 -->
<table ref="tableBody" >
<!-- 出现第二个 table-header 组件 -->
<table-header
v-if="showHeader && tableLayout === 'auto'"
ref="tableHeaderRef"
/>
<!-- 出现第一个 table-body 组件 -->
<table-body />
</table>
...
</el-scrollbar>
</div>
<div v-if="showSummary" v-show="!isEmpty" ref="footerWrapper">
<!-- table-footer 组件 -->
<table-footer />
</div>
... ...
</div>
尽管上述代码中,笔者删除了很多如 style
、 props
等属性,但是对 v-show
、 v-if
等决定组件显示隐藏的代码进行保留。这里笔者整理了一下对于排查 issue 需要多加注意的代码点:
- 默认插槽
<slot />
。回顾一下el-table
的用法,你就知道为什么要注意默认插槽。<el-table> <el-table-column>这下你知道为什么默认插槽是需要我们注意的了吧</el-table-column> </el-table>
- 第一个
table
标签。注意啦,这可是原生标签。从上述代码中可以看到,该 table 的显示条件是showHeader && tableLayout === 'fixed'
。这个我们一会说。 table-header
组件。这个 vue组件 出现在table
标签的包裹内容中,盲猜它最终渲染出来的结果就是<thead>
、<tr>
、<th>
这几个原生标签!!!- 第二个
table
标签。这个table
标签即包裹了table-header组件
、还包裹了table-body组件
!这阵势一看来头就不小。(注意这里的table-header组件
也是有一个显示条件的)
好,总共就上面四点需要我们更加关注的,至于其他的嘛,说实话,笔者自己都没怎么看~话说回来,看源码的时候,最怕就是有一些长得几乎一模一样的代码块了,它们会影响到我们对主干代码的理解、会干扰我们的调试过程,所以首先需要我们排除异己!没错,说的就是上述中出现了两次的 table-header组件
!当然你也会说 table
标签也出现了两次,但是其第一次出现就仅仅是包裹了个 table-header组件
而已,所以我们从table-header组件
入手就可以了。
最最最简单的方式,当然就是通过它的文档,看看两个条件的显示关系啦。这里笔者先罗列出它们的显示条件,方便大家看出区别:
- 第一个
table-header
:v-if="showHeader && tableLayout === 'fixed'"
- 第二个
table-header
:v-if="showHeader && tableLayout === 'auto'"
翻查文档:
showHeader
默认为true
:
tableLayout
默认为'fixed'
:
根据文档的默认值表明,我们调试 demo 中的 table-header
为第一个,所以我们需要核心关注的源码大体结构是:
<div>
<slot /> <!-- el-table-column 插入的地方 -->
<table>
<table-header /> <!-- 表头 thead -->
</table>
<table>
<table-body /> <!-- 表格 tbody -->
</table>
</div>
2. 了解 column、header、body 组件
大概了解了 el-table组件
的模板组成后,我们需要到具体每个其用到的vue
组件内部,看看他们的内部组成是怎么样的,这样以方便我们对后续整个 table
的渲染流程有更好的理解。
注意,这里主要还是看结构为主,毕竟源码都是比较复杂的,会有很多分支处理细节,如果上来就直接闷头看源码很容易迷失~笔者习惯先从大体结构上看,然后调试主干代码,再根据自己的需要进入不同的分支代码调试,以此让自己对源码进行理解~
首先看table-column
。这个算是这三个里头我们最熟悉的了,要杀熟!!!(源码位于 packages/components/table/src/table-column
)。还是上面那句话,看大体!组件库嘛,可以从模板结构看起还是很舒服的。打开源码后发现其居然没有 template
,写的是 render函数
,呵,没关系,难不倒我们~
// table-column 组件
render() {
try {
...
const vnode = h('div', children)
return vnode
} catch {
return h('div', [])
}
}
这么一看,table-column组件
直接返回了一个 div
的 vnode
,好像并有没跟 table
的 dom
组成有啥关联,那是用来干啥的呢?这里我们先保留着疑问接着往下看。
接着看table-header
。其模版代码也是直接写成了 render
的形式,代码比较多,笔者也就只保留我们需要关注的部分啦:
// table-header 组件
return h(
// 哦豁,这不就是熟悉的 thead 标签的由来吗
'thead',
// columnRows.map 会返回 <tr> 的 vnode
columnRows.map((subColumns, rowIndex) =>
h(
'tr',
// subColumns.map 会返回 <th> 的 vnode
subColumns.map((column, cellIndex) => {
return h(
'th',
// 这一层开始木有循环了,看来就是单个表头单元格的结构了
[
h(
'div',
{
// 这个class="cell"熟悉了吧,不就是在dom上看到的放表头内容的地方吗
class: ['cell'],
},
/* --- 下面就是具体表头单元格内容的vnode了,可以不用关注 --- */
[...]
),
]
)
})
)
)
)
来个小总结:
columnRows.map
作用看着是循环生成表头行的,有几项就返回几行<tr>
。为此笔者特地去找了个多行的表头验证想法(如下图所示)。照着样子看!猜想应该正确
subColumns.map
作用看着是循环生成单一单元格的(其中subColumns
是columnRows
循环的每一个单项)。那就是一行有几列就返回几个<th>
。
我们回顾整个 table-header
组件的 render
部分,并在 demo
上选中表头的其中一个单元格,可以发现 render
的结构跟最终的 dom
是完全对得上的。
最后看table-body
。该组件也是只有 render函数
,按照上面看两个组件的方式我们接着看。
// table-body 组件
render() {
const { wrappedRowRender, store } = this
const data = store.states.data.value || []
// tbody 标签的 vnode 出处找到啦
return h('tbody', {}, [
data.reduce((acc: VNode[], row) => {
return acc.concat(wrappedRowRender(row, acc.length))
}, []),
])
}
好家伙,这么点?在当前文件的代码虽然是少了点,不过我们依然可以得到我们想要的信息。根据 render
的写法,可以清晰看到其 children
的位置(第三个参数)是 data.reduce
的返回结果,而其中调用了一个 wrappedRowRender
的方法,并且往里面传入了 row
的参数。于此,笔者总结一下:
data
。data
一看就是整个tbody
的核心数据部分,估计跟表格每个单元格的数据来源有密切关系。而其出处是:store.states
,这意味着这个也是我们需要我们重点关注的。wrappedRowRender
。data
循环后啥事没干就调用它,估摸着跟整个表格内容的单元格渲染是有关系的!
好,到这里我们算是对整个 table
有一个初步的认识了,为了加深对它的认识,我们要马不停蹄的进入到下一个阶段,了解它的工作机制,一探其是如何渲染到dom上的!
二、<el-table>
渲染
这一步,我们将通过更深入的源码阅读、源码调试的方式、探究 <el-table>
最终是如何渲染到 dom
上的。经过初识 el-table
,在此我们只需要聚焦三个问题即可:
- 表头如何生成?
- 表格内容如何生成?
el-table-column
看起来仅仅render
了一个空div
,其作用是什么?
1. thead渲染
其实上文看 table-header组件
的 render函数
时,我们已经发现了整个 thead
的渲染无非就是循环了:columnRows
,再循环了其每一个单项:subColumns
。因此我们首先要搞明白 columnRows
是什么,怎么来的?
根据源码可以知道, columnRows
源自一个名为 useUtils
钩子的返回结果,该 hook 接收当前组件的 props
作为参数。下面是核心源码:
// useUtils
function useUtils<T>(props: TableHeaderProps<T>) {
const columnRows = computed(() => {
// 调用 convertToRows,并传入一个参数
return convertToRows(props.store.states.originColumns.value)
})
return {
columnRows,
}
}
看到这里,我们明确接下来的两个点:
props.store.states.originColumns
是什么?convertToRows
函数做什么?
首先我们得明确 props.store
里面到底装的啥?既然要找 props
,重新回到 table
组件,看看 store
是什么来头。在 table
组件的 setup
中找到了!并且不仅是 table-header组件
、table-body
、table-footer
等组件都有传入这个 store
,看来属实重要啊!
// 拿到组件实例
const table = getCurrentInstance() as Table<Row>
/*
* 接收两个参数:
* table:当前 table组件 的实例
* props:组件 props
* */
const store = createStore<Row>(table, props)
// 把 store 挂在 table 上
table.store = store
而其中,createStore
函数里是通过 useStore
这个钩子函数来创建的 store
,所以我们直接看 useStore
的源码(位于:packages/components/table/src/store/index.ts
):
function useStore<T>() {
const instance = getCurrentInstance() as Table<T>
// 关注 watcher,useStore 返回值的其中之一
const watcher = useWatcher<T>()
const ns = useNamespace('table')
type StoreStates = typeof watcher.states
// store状态变更处理
const mutations = {
setData(states: StoreStates, data: T[]) {
...
},
insertColumn(
states: StoreStates,
column: TableColumnCtx<T>,
parent: TableColumnCtx<T>
) {
...
},
...
}
// 提交状态变更
const commit = function (name: keyof typeof mutations, ...args) {...}
const updateTableScrollY = function () {...}
return {
ns,
...watcher,
mutations,
commit,
updateTableScrollY,
}
}
这么一看下来,useStore
像极了状态管理工具的那一套啊。我们完全可以理解成这是 table
组件维护的一个自己的状态管理。到了这一步,我们似乎已经初步了解到了 store
是什么东西,但是好像跟我们要寻找的 props.store.states.originColumns
好像还差了一点,所以我们把目光转移到
useWatcher
中,看看有没有我们需要找的跟 columns
相关的东西~
一点开 useWatcher
(位于:packages/components/table/src/store/watcher.ts
),好家伙,差不多500行,我完全有理由相信里面有我需要的。直接看相关源码:
function useWatcher<T>() {
// 定义 ref 数据 originColumns
const originColumns: Ref<TableColumnCtx<T>[]> = ref([])
// 方法是唯一对 originColumns.value 赋值的地方
const updateColumns = () => {
...
originColumns.value = []
.concat(fixedColumns.value)
.concat(notFixedColumns) // demo 中值的来源
.concat(rightFixedColumns.value)
...
}
// 导出的对象最终会在上述的 useStore 中原样导出(return { ...watcher })
return {
updateColumns,
states: {
originColumns,
columns,
}
}
}
到这里,我们成功找到了 props.store.states.originColumns
,并且知道其在 updateColumns
这个方法中进行赋值。这时笔者对 demo 进行 debugger
以追寻值的来源,发现值源于 notFixedColumns
,于是顺着找上去,找到了 _column
这个变量,如图所示:
最后一步! _column
值的是怎么来的,笔者在 store
的 mutations
中找到了线索。其中一个为 insertColumn
的 mutations
,里面有对 _columns
进行赋值的操作。
// store 的 mutations 中的一个方法
insertColumn(
states: StoreStates,
column: TableColumnCtx<T>,
parent: TableColumnCtx<T>
) {
// 取到 ref states._columns 的值(可简单理解成 states._columns.value)
const array = unref(states._columns)
let newColumns = []
if (!parent) {
array.push(column) // 插入 column 值
newColumns = array
}
sortColumn(newColumns)
states._columns.value = newColumns // 将值赋给 _columns
...
}
这玩意从哪里开始调用的?笔者顺着调试的调用栈往上翻,好家伙,找到一个我们很熟悉的地方去了:
这不正是差点被我们遗忘掉的 table-columns
组件的 onMounted
钩子里执行的吗???还记得上面看这玩意的 render
只搞了个空 div 吗?差点就以为没这货啥事了,原来它就是主宰了 store
里 columns
来源的神啊!
再回过头去扫一眼 table-column组件
,不难发现整个 column对象
就在其 onBeforeMount
中进行定义(包含了很多的属性);再在 onMounted
阶段 commit
了 insertColumn
的修改函数实现了 store
中 columns
相关的数据新增。
onBeforeMount(() => {
const defaults = {
... // 很多 column 的属性
}
const basicProps = [
'columnKey', ...
]
const sortProps = ['sortMethod', 'sortBy', 'sortOrders']
const selectProps = ['selectable', 'reserveSelection']
const filterProps = [
'filterMethod', ...
]
// 返回一个 将上面的数组字符串都转化成对象key 的对象
let column = getPropsData(basicProps, sortProps, selectProps, filterProps)
// 跟 default 进行属性合并
column = mergeOptions(defaults, column)
// 最后赋值到 columnConfig.value
columnConfig.value = column
})
onMounted(() => {
// commite 'insertColumn' 的 mutation 实现 store 中的 columns 设置
columnIndex > -1 &&
owner.value.store.commit(
'insertColumn',
columnConfig.value, // onBeforeMount 中赋值的
isSubColumn.value ? parent.columnConfig.value : null
)
})
这样一来,我们已经找到了数据的源头,只需要再了解处理 columns
的函数 convertToRows
就能揭秘整个 thead
的渲染流程了,毕竟整个 render函数
都是围 columnRows
展开的!接着往下看👇🏻
接着看 convertToRows
,首先看看源码实现:
const convertToRows = <T>(
originColumns: TableColumnCtx<T>[]
): TableColumnCtx<T>[] => {
let maxLevel = 1 // 代表n级表头,demo中是1
const traverse = (column: TableColumnCtx<T>, parent: TableColumnCtx<T>) => {
// 处理表格列横跨多少的,colSpan,无需重点关注。
...
}
originColumns.forEach((column) => {
column.level = 1
// 表头横跨处理(详细了解可看多级表头的案例),demo 中的 colSpan 都是1
traverse(column, undefined)
})
const rows = [] // n级表格就有 n 行,demo中是1
for (let i = 0; i < maxLevel; i++) {
rows.push([])
}
// 获取所有列(处理嵌套表头),在demo中返回值就是 originColumns 的数组
const allColumns: TableColumnCtx<T>[] = getAllColumns(originColumns)
// 给每一 行 添加 列 ,demo中只有一个row,所以可以很好理解成就是 originColumns 的数组
allColumns.forEach((column) => {
if (!column.children) {
column.rowSpan = maxLevel - column.level + 1
} else {
column.rowSpan = 1
column.children.forEach((col) => (col.isSubColumn = true))
}
rows[column.level - 1].push(column)
})
return rows
}
最后,笔者在 table-header
的渲染函数中打上 debugger
来验证一下,没有意外,就是那个 column对象
!等 render
执行完成后得到 vnode
,再经过 patch
就实现 thead 的渲染啦,就是那套熟悉的 vue
组件化流程!
解析到这,我们就很清楚的知道 thead
的渲染流程了。笔者甚至花了大半的篇幅在讲述寻找数据源的历程,因为那才是整个 table
渲染的核心地方。在这里,笔者画个图回顾一下整个 thead
的渲染流程,了解完细节后再抛开细节看整体,也许会有更加清晰的理解:
2. tbody渲染
先回忆一下上文提到的 table-body
的渲染函数,印象中似乎就没几行代码!
所以整个小节我们的目标很明确,只要破解 wrappedRowRender
这个关键点和找到数据源 data
,就能还原整个 tbody
的渲染流程了!当然这里寻找数据源就没有像寻找 columns
的那样隐晦了,因为我们是显示的往 el-table
中传入一个 data
属性的。
数据源 data
。其实就是我们给 table
传入的 data
。这里笔者不会再展开大篇幅赘述,因为都比较好理解,所以就简单带过一下:
- 早在
table组件
createStore
阶段,就已经通过proxyTableProps
给states.data
赋值了 - 经过
debuger
会发现后续也会有setData
的mutation
给其赋值
so,结论就是,整个数据源 data
就是跟我们传入给 table
的无差的,其中可能 table
自己对其做了一些处理,如做了层 proxy
监测等,这都不影响我们对 data
的理解,反正就是我们传给 table
的 data
。
wrappedRowRender
函数(源码位于 packages/components/table/src/table-body/render-helper.ts
)。首先看看函数实现:
const wrappedRowRender = (row: T, $index: number) => {
const store = props.store
const { isRowExpanded, assertRowKey } = store
const { treeData, lazyTreeNodeMap, childrenColumnName, rowKey } =
store.states
const columns = store.states.columns.value
const hasExpandColumn = columns.some(({ type }) => type === 'expand')
if (hasExpandColumn) {
...
} else if (Object.keys(treeData.value).length) {
...
} else {
// demo 代码会走到这一步
return rowRender(row, $index, undefined)
}
}
总的来说,整个 wrappedRowRender
就是对 常规 、 展开行 、 树形 表格的 vnode
进行返回。demo 代码属于常规表格,所以我们需要关注 rowRender
的函数实现:
const rowRender = (
row: T,
$index: number,
treeRowData?: TreeNode,
expanded = false
) => {
const { tooltipEffect, store } = props
// 在 store 中拿到 columns,columns 其实跟上述 originColumns 是在同一个地方赋值的
const { indent, columns } = store.states
...
// 找到 tbody 的 render 啦
return h(
'tr',
{...},
// 循环每一列
columns.value.map((column, cellIndex) => {
// tdChildren 是需要渲染内容的 vnode(根据 data 和 column 配置生成)
const tdChildren = cellChildren(cellIndex, column, data)
...
return h(
'td',
{...},
[tdChildren]
)
})
)
}
源码看到这里,再加上经历过长篇大论的 thead
渲染的熏陶,相信这里已经对 tbody
的渲染有一定掌握了。其实整个的渲染并不难,就是一个二维数组的循环,然后执行对应的 render
函数生成 vnode
,接下来一就是熟悉的 vue
组件化流程。笔者简单总结 tbody
渲染的两点:
- 循环
data
数据,得到每一“行”的数据 - 在每一项
data
的循环中循环“列”,生成每一列(这里就是单元格了)的 vnode
三、定位问题
这一节将是本文的重点!跟大家一起破案啦!!在简单掌握了 el-table
的渲染原理之后,我们就可以开始分析问题的原因在哪里了。长篇大论了一堆,大家可能都忘记 issue 这件事了~ 回顾一下问题:
v-for
生成的column
绑定上key
后无法更改column
的顺序。
1. 为什么 key
导致 columns
换位失败?
在没有阅读过 table
源码的时候,你可能会猜测是不是 vue
的 diff
出问题啦;还是说数据改变因为 key
没有触发组件更新等等…但是当你了解了 el-table
的基本实现之后你会发现:最终呈现出来的组件成品,压根就没有以 column
为单位的 dom
元素。这么说不清楚,笔者还是接着画图吧~
你以为你 v-for
的 columns
是这样的:
但其实最终是这样的:
所以,根本就不存在以列为单位的元素,只有以 tr
为行,td
为行中每一项的单元格。即使是互换列,也不可能是简单的互换两列,只能是第一行的两两单元格互换,第二行的两两单元格互换、第三行、第四行…说到这里,你可能大概能理解这个 issue 为什么会存在了。
基于此,笔者进行一个猜测:其实当我们改变列数据的顺序时,“真正”的 table-column
的顺序是改变了的,只是表格没有变化。大家可能不知道什么是 “真正”的 table-column
,此时我们回忆一下 column组件
的 render函数
,生成的是一个空 div
的 vnode
。既然是这样,不如做个实验验证一下!
首先找到 dom
的位置。回忆 table
组件的组成结构,默认插槽放在一个类名为 hidden-columns
的 div元素
内(方便标识,笔者搞了个 class
为 ccc-${index}
)。并且为了让大家能看得清变化的效果,笔者直接在浏览器上编辑这2个dom元素,给他们加入内容:
接下来,笔者点击按钮以互换两列顺序,效果如下:
基于这种表象印证了之前的猜想,表格的内容虽然没有改变,但真正由 el-table-column
渲染出来的 div
其实是成功互换位置的,只是因为其是隐藏的我们表面上看不出来而已。
事已至此,我们思考一下为什么 key
导致 table
换位失败?(ps:没办法啊没办法啊,搞个 table
被迫去看 Vue3
源码)
- 首先是组件更新问题。互换列时修改的响应式数据并没有直接在
table-header
、table-body
组件中使用,然后table-column
组件又命中了diff
的逻辑,所以当修改数据触发组件更新时这三个组件都不会重新执行render
,而是复用之前的vnode
去patch
。所以:不重新render
,何来更改顺序后的新vnode
呢? - 其次是
store
数据变更 问题。回忆el-table渲染
章节,整个table
的渲染都依赖store
中的columns
数据。而我们修改数据触发table-column
组件重render
时并,仅仅在render
阶段是不会对store
数据进行改动的,所以此时store
中的columns
顺序依旧不变(回忆columns
的数据一开始是在mounted
阶段insert
的)。
既然明确了传入 key
带来的表象的原因,那现在又出现了另外一个问题:为什么不加 key
就可以按照用户预期进行表格的列互换?接着我们往下看。
2. 为什么不加 key
就能互换?
当然,到了这一步,盲猜都知道是触发整个 table
的重渲染了,table-column
、 table-header
、table-body
三者都重新 render
了一遍。盲猜归盲猜,但总得找出真的根源来分享给大家啊是吧,于是……于是又掉进了一个漩涡,深不见底!听笔者娓娓道来。(注意!由于代码复杂度问题,在调试这个案例的时候笔者仅以 table-header
的组件为调试例子,因为只要从 table-header
中可以得出结论的话,那 table-body
也是同理!)
要想搞明白这一点,不去看 Vue3
源码是不行的了,因为 Vue3
很多地方都跟 Vue2
不同,再以 Vue2
来理解和解释问题已经很难走下去了。当然,这里不会展开太多,因为跟主题不相关了,所以笔者会直接给结论。详细了解可自行查看 vue3 patch流程源码
和 diff的源码
哈~
-
v-for
中加key
和不加key
区别?- 有key:
patchKeyedChildren
。逻辑比较复杂,里面是分了5个步骤的diff
逻辑,最终会执行到patch
。 - 无key:
patchUnkeyedChildren
。逻辑比较简单(总共就20行代码),直接patch
。
- 有key:
-
两个
patch
之间有什么区别?表象就是参数不同。- 有key:经过一轮的
diff
算法的洗礼,传入patch
的参数是相同的table-column
,它们只是在新旧的list
中顺序不同。 - 无key:
patch
参数:(旧vnode[0], 新vnode[0])
,然后就直接开始patch
流程了,会走一趟完整的组件更新流程。
- 有key:经过一轮的
-
两种参数下会导致
patch
结果有什么区别?- 有key:最终
patch
的结果是复用vnode
,所有组件(column
、header
、body
)都不会rerender
(因为update
中有个shouldUpdateComponent
的判断,感兴趣自己去看看,还是比较有意思的) - 无key:组件更新,然后会重新赋值
props
值!!!。感兴趣的可以自行去仔细阅读updateComponentPreRender
中的逻辑,其中有updateProps
的处理。
没错,如图所示,无key
的情况下会在这个位置对props
进行重新赋值。区别就在这里了,这一点也是最重要的!
- 有key:最终
以上就是 Vue
部分的源码调试结论。大家千万不要觉得结论就几句话很简单,这可是笔者通宵达旦+1 +1 +1…给 debug
出来的。而且为了找到这个点,笔者正向 debug
了很久无果,在进行了无数次的反推才定位到的。只能说要调试 Vue
组件化、渲染这一块逻辑的话,真的太多太多了。好了扯远了,简单交代了有无 key
在 Vue
部分的区别后,我们回到 ElementPlus
的代码中,再接续听笔者娓娓道来~
上面已经提到 props
的更新,所以很明显无 key
状态下能触发 table
按照用户预期的渲染,当然跟这个 props
有不可分割的关系,这个时候我们也离真相很近了!还记得笔者在第二节 el-table
渲染 中提到过数据源 store.state
这玩意吧,笔者有提到在 table-column
组件 onMounted
阶段对 state
中的 columns
进行 “insert” 的这么一步。
这里我们仔细看看 table-column组件
具体的数据定义和实现(注意啦,columnConfig
是个 ref
的对象):
// columnConfig 是一个定义成 ref 的对象
const columnConfig = ref<Partial<TableColumnCtx<DefaultRow>>>({})
onMounted(() => {
// commite 'insertColumn' 的 mutation 实现 store 中的 columns 设置
columnIndex > -1 &&
owner.value.store.commit(
'insertColumn',
columnConfig.value, // 这里我们注意传进去的是一个引用
isSubColumn.value ? parent.columnConfig.value : null
)
})
再认真看看 insertColumn
这个 mutation
,主要关注其 push
参数 column
这一步:
// 这个 _columns 也是一个 ref 数据
insertColumn(
states: StoreStates,
column: TableColumnCtx<T>, // 注意这里的参数是上面的 columnConfig.value
parent: TableColumnCtx<T>
) {
// states._columns 本就是一个 ref 数据
const array = unref(states._columns)
let newColumns = []
if (!parent) {
// 这里push进去的,是一个 ref 的对象!!!
array.push(column)
newColumns = array
}
sortColumn(newColumns)
states._columns.value = newColumns // 将值赋给 _columns
...
}
最后再给大家看看一大堆的 state
的响应式数据定义:
好了,上面讲这些什么响应式数据啊、对象的引用啊不为别的,只为了明确一个点:只要table-columns
组件的 columnConfig.value
(再次强调其是个对象!) 的属性值改变了,就能触发 table-header组件
的重新渲染,因为这个组件接收了 store
这个 props
。伟大的Vue响应式系统,让我找这个数据变化找的好苦啊…
接下来是破案的最后一环。既然上面分析 vue
源码在处理 有 key
和 无 key
时候的核心区别是 rerender
和 对 props
进行重新赋值,那我们就来看看是哪个该死的 props
变化触发了 table-header
组件的更新。这个好办!反正 demo
中又没有几个 props
🤪:
最后,笔者在 table-column组件
中发现了 registerNormalWatchers
!这是在该组件的 setup
中执行的一个方法,用于观测 props
变化,具体我们往下看。其中里面的实现是这样的:
const props = [
'label', // 其实案例中就是 label 的值发生改变
...
]
Object.keys().forEach((key) => {
const columnKey = aliases[key]
if (hasOwn(props_, columnKey)) {
watch(
() => props_[columnKey],
(newVal) => {
// 看到没!!!columnConfig.value 的引用值改变了
instance.columnConfig.value[key] = newVal
}
)
}
})
不难发现,这个 watch
就是对一些 table-column组件
中的 props
进行监听,然后将变化的值同步到 columnConfig.value
中,其实也就是同步到整个 table
的数据源 store
中。然后在本次的调试 demo
中,其实就是 lable
改变了,大家可以回看 vue
源码调试截图,笔者特地截下了 label
被重新赋值的断点状态~
结论:由于 props.label
数据变更,必然造成 columnRows(上文提及渲染表头的关键数据源)
中的 label
属性更新(同一个引用),又因为数据变化触发了 header组件
的 rerender
(根据最新的 columnRows
重新生成表头 vnode
),所以表头互换位置成功!
分析完 为什么没有 key
的时候能触发 table-header
的 rerender
和最后成功互换两列的情况后,同理也可以推断出 table-body
也是受到这种原因的影响,不管是不是由于 label
的变化而引起的。反正现在可以很明确的一点是:因为有响应式数据改变所导致其 rerender
,所以互换列成功了。
ok!这下终于破案了,完全可以解释为什么不加 key
的时候就能使 table
按预期渲染。感觉自己能写到这里可真不容易啊,调试到人麻了~当然,能看到这里的伙伴也很不容易,毕竟好一大篇的内容呢哈哈哈。
写在最后
好啦,在最后其实笔者也想分享一下代码的调试心得,毕竟这种跟组件化、渲染相关的问题多多少少都得跟所依赖的 js框架 搭边,不能单凭对 ui库 调试就能得出结论的。所以如果要对 js框架 进行调试,那必定是一个很复杂的场景,所以 demo 一定要最简化!笔者自己调试的时候,几乎把整个 el-table
都删干净了,就留下了 slot
和 table-header
来调试,但还是耗费了很多时间。然后有时候也要进行一些适当性的反推,说不定能有妙用!
该说不说整个 el-table
的源码是很多也比较复杂的,源码阅读加调试确实很麻烦。另外呢,像这种问题一般很少人会究其原因,因为不加 key
就能解决问题,相比之下究其原因的成本就显得非常巨大。但是笔者发现这类 issue 不止一个(随便翻都找到2个相关的),所以肯定是有部分开发者会遇到这个问题,一旦要排查说不定就是大半天的时间,而且业内、开发者内部似乎也没有对这个问题进行说明,最多就告诉你是 key
导致的,所以笔者觉得还是有必要深究一下~
如果你觉得看完有所收获!不如给我个一键三连吧!我们一起进步。
本文正在参加「金石计划 . 瓜分6万现金大奖」
暂无评论内容