从 Issue 看 El-Table 源码,给 Element+ 提 Pr 的背后竟如此坎坷

news2024/7/6 19:55:33

Element Plus大家应该都不陌生,用过 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 成功互换了“两列”的位置,而加上了 keytable “表面上”并没有动静,还是维持了原本的顺序。

感兴趣的朋友可以通过在线调试: Playground 点进去自己玩一下~

好了,就是这么一个问题。不知道读到这里的朋友有没有在开发中遇到这样的问题呢?笔者自问了一下,为什么自己从来没有遇到这个问题呢???看来平时只要代码不规范,就不会有两行泪~此时此刻,我不由自主地掏出了下面这张图,原来图中的道理是真的,信不信由你!

走远了走远了,回到这个问题上来不妨先思考一下,对一个 v-for 列表的列表添加 key 本是最合理不过的事情了,为什么会导致 table 互换列失败呢?基于这一点的疑问,我们就要翻开它的源码了~当然,整个 table 的源码还是相对比较多的,而本文更多的是针对这个 issue ,以找到问题的根源为发起点去解析源码,不能尽善尽全还请多多见谅。

一、初识<el-table>

说起“初识”其实也不算“初识”,毕竟使用了这么久了,用这个组件来完成了很多业务需求,应该算是个最熟悉的陌生人吧!那现在我们就开始尝试更深入的了解这个老朋友吧。

1. 组成结构

虽然是看源码,但是一上来就堆代码就怕吓跑你们,这时候不妨先看看 el-table 完成渲染后是一个怎么样的 dom 结构组成。笔者就上面的 demo 截了个图如下:

1.表头部分。接着展开看看:
2.表格(body)部分。展开看看:

看完它们的表现形式后,我们真正的打开 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-headerv-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> 

尽管上述代码中,笔者删除了很多如 styleprops 等属性,但是对 v-showv-if 等决定组件显示隐藏的代码进行保留。这里笔者整理了一下对于排查 issue 需要多加注意的代码点:

1.默认插槽 <slot />。回顾一下 el-table 的用法,你就知道为什么要注意默认插槽。<el-table><el-table-column>这下你知道为什么默认插槽是需要我们注意的了吧</el-table-column></el-table> 2.第一个 table 标签。注意啦,这可是原生标签。从上述代码中可以看到,该 table 的显示条件是 showHeader && tableLayout === 'fixed'。这个我们一会说。
3.table-header 组件。这个 vue组件 出现在 table 标签的包裹内容中,盲猜它最终渲染出来的结果就是 <thead><tr><th> 这几个原生标签!!!
4.第二个 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'"

翻查文档:

1.showHeader 默认为 true
2.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组件 直接返回了一个 divvnode,好像并有没跟 tabledom 组成有啥关联,那是用来干啥的呢?这里我们先保留着疑问接着往下看。


接着看table-header。其模版代码也是直接写成了 render 的形式,代码比较多,笔者也就只保留我们需要关注的部分啦:

// table-header 组件
return h(// 哦豁,这不就是熟悉的 thead 标签的由来吗 'thead',// columnRows.map 会返回 <tr> 的 vnodecolumnRows.map((subColumns, rowIndex) =>h('tr',// subColumns.map 会返回 <th> 的 vnodesubColumns.map((column, cellIndex) => {return h('th',// 这一层开始木有循环了,看来就是单个表头单元格的结构了[h('div',{// 这个class="cell"熟悉了吧,不就是在dom上看到的放表头内容的地方吗class: ['cell'], },/* --- 下面就是具体表头单元格内容的vnode了,可以不用关注 --- */[...]),])})))
) 

来个小总结:

  • columnRows.map 作用看着是循环生成表头行的,有几项就返回几行 <tr>。为此笔者特地去找了个多行的表头验证想法(如下图所示)。照着样子看!猜想应该正确
  • subColumns.map 作用看着是循环生成单一单元格的(其中 subColumnscolumnRows 循环的每一个单项)。那就是一行有几列就返回几个 <th>

我们回顾整个 table-header 组件的 render 部分,并在 demo 上选中表头的其中一个单元格,可以发现 render 的结构跟最终的 dom 是完全对得上的。


最后看table-body。该组件也是只有 render函数 ,按照上面看两个组件的方式我们接着看。

// table-body 组件
render() {const { wrappedRowRender, store } = thisconst 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 的参数。于此,笔者总结一下:

  • datadata 一看就是整个 tbody核心数据部分,估计跟表格每个单元格的数据来源有密切关系。而其出处是:store.states,这意味着这个也是我们需要我们重点关注的。
  • wrappedRowRenderdata 循环后啥事没干就调用它,估摸着跟整个表格内容的单元格渲染是有关系的!

好,到这里我们算是对整个 table 有一个初步的认识了,为了加深对它的认识,我们要马不停蹄的进入到下一个阶段,了解它的工作机制,一探其是如何渲染到dom上的!

二、<el-table>渲染

这一步,我们将通过更深入的源码阅读、源码调试的方式、探究 <el-table> 最终是如何渲染到 dom 上的。经过初识 el-table,在此我们只需要聚焦三个问题即可:

1.表头如何生成?
2.表格内容如何生成?
3.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,}
} 

看到这里,我们明确接下来的两个点:

1.props.store.states.originColumns 是什么?
2.convertToRows 函数做什么?

首先我们得明确 props.store 里面到底装的啥?既然要找 props ,重新回到 table 组件,看看 store 是什么来头。在 table 组件的 setup 中找到了!并且不仅是 table-header组件table-bodytable-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 数据 originColumnsconst 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 值的是怎么来的,笔者在 storemutations 中找到了线索。其中一个为 insertColumnmutations,里面有对 _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-column组件,不难发现整个 column对象 就在其 onBeforeMount 中进行定义(包含了很多的属性);再在 onMounted 阶段 commitinsertColumn 的修改函数实现了 storecolumns 相关的数据新增。

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.valuecolumnConfig.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中是1const traverse = (column: TableColumnCtx<T>, parent: TableColumnCtx<T>) => {// 处理表格列横跨多少的,colSpan,无需重点关注。...}originColumns.forEach((column) => {column.level = 1// 表头横跨处理(详细了解可看多级表头的案例),demo 中的 colSpan 都是1traverse(column, undefined) })const rows = [] // n级表格就有 n 行,demo中是1for (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 = 1column.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阶段,就已经通过 proxyTablePropsstates.data 赋值了
  • 经过 debuger 会发现后续也会有 setDatamutation 给其赋值

so,结论就是,整个数据源 data 就是跟我们传入给 table 的无差的,其中可能 table 自己对其做了一些处理,如做了层 proxy 监测等,这都不影响我们对 data 的理解,反正就是我们传给 tabledata


wrappedRowRender 函数(源码位于 packages/components/table/src/table-body/render-helper.ts)。首先看看函数实现:

const wrappedRowRender = (row: T, $index: number) => {const store = props.storeconst { isRowExpanded, assertRowKey } = storeconst { treeData, lazyTreeNodeMap, childrenColumnName, rowKey } =store.statesconst columns = store.states.columns.valueconst 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 渲染的两点:

1.循环 data 数据,得到每一“行”的数据
2.在每一项 data 的循环中循环“列”,生成每一列(这里就是单元格了)的 vnode

三、定位问题

这一节将是本文的重点!跟大家一起破案啦!!在简单掌握了 el-table 的渲染原理之后,我们就可以开始分析问题的原因在哪里了。长篇大论了一堆,大家可能都忘记 issue 这件事了~ 回顾一下问题:

  • v-for 生成的 column 绑定上 key 后无法更改 column 的顺序。

1. 为什么 key 导致 columns 换位失败?

在没有阅读过 table 源码的时候,你可能会猜测是不是 vuediff 出问题啦;还是说数据改变因为 key 没有触发组件更新等等…但是当你了解了 el-table 的基本实现之后你会发现:最终呈现出来的组件成品,压根就没有以 column 为单位的 dom 元素。这么说不清楚,笔者还是接着画图吧~

你以为你 v-forcolumns 是这样的:

但其实最终是这样的:

所以,根本就不存在以列为单位的元素,只有以 tr 为行,td 为行中每一项的单元格。即使是互换列,也不可能是简单的互换两列,只能是第一行的两两单元格互换,第二行的两两单元格互换、第三行、第四行…说到这里,你可能大概能理解这个 issue 为什么会存在了。

基于此,笔者进行一个猜测:其实当我们改变列数据的顺序时,“真正”的 table-column 的顺序是改变了的,只是表格没有变化。大家可能不知道什么是 “真正”的 table-column ,此时我们回忆一下 column组件render函数,生成的是一个空 divvnode。既然是这样,不如做个实验验证一下!

首先找到 dom 的位置。回忆 table 组件的组成结构,默认插槽放在一个类名为 hidden-columnsdiv元素 内(方便标识,笔者搞了个 classccc-${index})。并且为了让大家能看得清变化的效果,笔者直接在浏览器上编辑这2个dom元素,给他们加入内容:

接下来,笔者点击按钮以互换两列顺序,效果如下:

事已至此,我们思考一下为什么 key 导致 table 换位失败?(ps:没办法啊没办法啊,搞个 table 被迫去看 Vue3 源码)

1.首先是组件更新问题。互换列时修改的响应式数据并没有直接在 table-headertable-body 组件中使用,然后 table-column 组件又命中了 diff 的逻辑,所以当修改数据触发组件更新时这三个组件都不会重新执行 render ,而是复用之前的 vnodepatch。所以:不重新 render,何来更改顺序后的新 vnode 呢?
2.其次是 store数据变更 问题。回忆 el-table渲染 章节,整个 table 的渲染都依赖 store 中的 columns 数据。而我们修改数据触发 table-column 组件重 render 时并,仅仅在 render 阶段是不会对 store 数据进行改动的,所以此时 store 中的 columns 顺序依旧不变(回忆 columns 的数据一开始是在 mounted 阶段 insert 的)。

既然明确了传入 key 带来的表象的原因,那现在又出现了另外一个问题:为什么不加 key 就可以按照用户预期进行表格的列互换?接着我们往下看。

2. 为什么不加 key 就能互换?

当然,到了这一步,盲猜都知道是触发整个 table 的重渲染了,table-columntable-headertable-body 三者都重新 render 了一遍。盲猜归盲猜,但总得找出真的根源来分享给大家啊是吧,于是…于是又掉进了一个漩涡,深不见底!听笔者娓娓道来。(注意!由于代码复杂度问题,在调试这个案例的时候笔者仅以 table-header 的组件为调试例子,因为只要从 table-header 中可以得出结论的话,那 table-body 也是同理!

要想搞明白这一点,不去看 Vue3 源码是不行的了,因为 Vue3 很多地方都跟 Vue2 不同,再以 Vue2 来理解和解释问题已经很难走下去了。当然,这里不会展开太多,因为跟主题不相关了,所以笔者会直接给结论。详细了解可自行查看 vue3 patch流程源码diff的源码 哈~

1.v-for 中加 key 和不加 key 区别?* 有key:patchKeyedChildren。逻辑比较复杂,里面是分了5个步骤的 diff 逻辑,最终会执行到 patch。* 无key:patchUnkeyedChildren。逻辑比较简单(总共就20行代码),直接 patch
2.两个 patch 之间有什么区别?表象就是参数不同。* 有key:经过一轮的 diff 算法的洗礼,传入 patch 的参数是相同的 table-column,它们只是在新旧的 list顺序不同。* 无key:patch 参数:(旧vnode[0], 新vnode[0]),然后就直接开始 patch 流程了,会走一趟完整的组件更新流程。
3.两种参数下会导致 patch 结果有什么区别?* 有key:最终 patch 的结果是复用 vnode ,所有组件(columnheaderbody都不会 rerender(因为 update 中有个 shouldUpdateComponent 的判断,感兴趣自己去看看,还是比较有意思的)* 无key:组件更新,然后会重新赋值 props 值!!!。感兴趣的可以自行去仔细阅读 updateComponentPreRender 中的逻辑,其中有 updateProps 的处理。

以上就是 Vue 部分的源码调试结论。大家千万不要觉得结论就几句话很简单,这可是笔者通宵达旦+1 +1 +1…给 debug 出来的。而且为了找到这个点,笔者正向 debug 了很久无果,在进行了无数次的反推才定位到的。只能说要调试 Vue 组件化、渲染这一块逻辑的话,真的太多太多了。好了扯远了,简单交代了有无 keyVue 部分的区别后,我们回到 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.valueparent: 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-headerrerender 和最后成功互换两列的情况后,同理也可以推断出 table-body 也是受到这种原因的影响,不管是不是由于 label 的变化而引起的。反正现在可以很明确的一点是:因为有响应式数据改变所导致其 rerender ,所以互换列成功了。

ok!这下终于破案了,完全可以解释为什么不加 key 的时候就能使 table 按预期渲染。感觉自己能写到这里可真不容易啊,调试到人麻了~当然,能看到这里的伙伴也很不容易,毕竟好一大篇的内容呢哈哈哈。

写在最后

好啦,在最后其实笔者也想分享一下代码的调试心得,毕竟这种跟组件化、渲染相关的问题多多少少都得跟所依赖的 js框架 搭边,不能单凭对 ui库 调试就能得出结论的。所以如果要对 js框架 进行调试,那必定是一个很复杂的场景,所以 demo 一定要最简化!笔者自己调试的时候,几乎把整个 el-table 都删干净了,就留下了 slottable-header 来调试,但还是耗费了很多时间。然后有时候也要进行一些适当性的反推,说不定能有妙用!

该说不说整个 el-table 的源码是很多也比较复杂的,源码阅读加调试确实很麻烦。另外呢,像这种问题一般很少人会究其原因,因为不加 key 就能解决问题,相比之下究其原因的成本就显得非常巨大。但是笔者发现这类 issue 不止一个(随便翻都找到2个相关的),所以肯定是有部分开发者会遇到这个问题,一旦要排查说不定就是大半天的时间,而且业内、开发者内部似乎也没有对这个问题进行说明,最多就告诉你是 key 导致的,所以笔者觉得还是有必要深究一下~

最后

整理了一套《前端大厂面试宝典》,包含了HTML、CSS、JavaScript、HTTP、TCP协议、浏览器、VUE、React、数据结构和算法,一共201道面试题,并对每个问题作出了回答和解析。

有需要的小伙伴,可以点击文末卡片领取这份文档,无偿分享

部分文档展示:



文章篇幅有限,后面的内容就不一一展示了

有需要的小伙伴,可以点下方卡片免费领取

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/190467.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

什么是Intel Elkhart Lake?专用于物联网的处理器系列

随着世界向工业4.0过渡&#xff0c;边缘计算应用程序对实时推理、连接和数据遥测的更高性能和更低功耗处理的需求激增&#xff0c;并加强了加固。物联网和边缘计算的爆炸性增长造成了上一代处理器无法维持的性能瓶颈和限制。英特尔最新推出的Atom x6000 E系列、奔腾和赛扬N&…

IDEA无法使用Git Pull的问题

一、问题描述 我们开发项目时&#xff0c;经常会和第三方接口打交道&#xff0c;有些第三方项目为了安全起见&#xff0c;会把项目部署在"内网"环境&#xff0c;不对外暴露&#xff0c;通常会提供VPN代理才能访问到资源。 而在项目中我就因为这个而遇到了一个问题&am…

RK3568平台开发系列讲解(Linux系统篇)什么是Linux文件

🚀返回专栏总目录 文章目录 一、文件描述符二、常用文件 I/O 操作和函数2.1、open2.2、close2.3、read2.4、write沉淀、分享、成长,让自己和他人都能有所收获!😄 📢 Linux 把大部分系统资源当作文件呈现给用户,用户只需按照文件 I/O 的方式,就能完成数据的输入输出。…

Diffusion 扩散模型(DDPM)详解及torch复现

文章目录torch复现第1步&#xff1a;正向过程噪声调度器Step 2: 反向传播 U-NetStep 3: 损失函数采样Training我公众号文章目录综述&#xff1a;https://wangguisen.blog.csdn.net/article/details/127065903 保姆级讲解 Diffusion 扩散模型&#xff08;DDPM&#xff09; ht…

python面向对象:入门

python面向对象&#xff1a;入门 文章目录python面向对象&#xff1a;入门一、实验目的二、实验原理三、实验环境四、实验内容五、实验步骤1.创建类和对象2.构造方法3.类中的实例方法一、实验目的 掌握类的基本使用 二、实验原理 面向过程&#xff1a;根据业务逻辑从上到下写…

Win10 BCD文件损坏怎么修复?

在Windows 10操作系统中&#xff0c;BCD代表引导配置数据&#xff08;Boot Configuration Data&#xff09;&#xff0c;Windows运行时BCD将告诉Windows引导加载程序在哪里查找引导信息&#xff0c; 因此&#xff0c;它对成功地加载和运行操作系统是非常重要的。哪些情况下会导…

二叉树的前序遍历

题目144. 二叉树的前序遍历 - 力扣&#xff08;LeetCode&#xff09;本章我们来探讨运用二叉树中所学的知识解决本题。对二叉树仍有疑问的小伙伴可以点击下方链接哦。参考文献&#xff1a;(1条消息) 二叉树&#xff08;三&#xff09;_染柒_GRQ的博客-CSDN博客原理首先我们来回…

CyberBattleSim-(内网自动化渗透)研究分析

**01 **背景知识介绍 CyberBattleSim介绍 CyberBattleSim是一款微软365 Defender团队开源的人工智能攻防对抗模拟工具&#xff0c;来源于微软的一个实验性研究项目。该项目专注于对网络攻击入侵后横向移动阶段进行威胁建模&#xff0c;用于研究在模拟抽象企业网络环境中运行的…

基于蜣螂算法优化的核极限学习机(KELM)分类算法-附代码

基于蜣螂算法优化的核极限学习机(KELM)分类算法 文章目录基于蜣螂算法优化的核极限学习机(KELM)分类算法1.KELM理论基础2.分类问题3.基于蜣螂算法优化的KELM4.测试结果5.Matlab代码摘要&#xff1a;本文利用蜣螂算法对核极限学习机(KELM)进行优化&#xff0c;并用于分类1.KELM理…

R语言贝叶斯方法在生态环境领域中的应用

贝叶斯统计已经被广泛应用到物理学、生态学、心理学、计算机、哲学等各个学术领域&#xff0c;其火爆程度已经跨越了学术圈&#xff0c;如促使其自成统计江湖一派的贝叶斯定理在热播美剧《The Big Bang Theory》中都要秀一把。贝叶斯统计学即贝叶斯学派是一门基本思想与传统基于…

STM32CubeMx使用FreeRTOS搭建printf输出串口打印-----基于正点原子开发板阿波罗

文章目录STM32CubeMx使用FreeRTOS搭建printf输出串口打印-----基于正点原子开发板阿波罗1.输入目标芯片2.选择RCC时钟3.配置调试模式4.USART的配置5.配置中断6.printf的重定向功能7.代码添加8.修改中断函数9.添加全局变量10.增加FreeRTOS支持11.在FreeRTOS中添加源码STM32CubeM…

【数学建模】数学建模中的常用工具推荐

前言 整理了几款我在建模比赛中需要准备的小工具&#xff0c;后续会随时不定期更新&#xff0c;以及完善内容&#xff0c;需要的小伙伴建议收藏一波~ [1] 公式编译器 Axmath&#xff08;建议购买正版&#xff09;mathtypeWord内置的公式编辑器 Axmath是国产的软件&#xff0…

pytorch安装(模式识别与图像处理课程实验)

pytorch安装&#xff08;模式识别与图像处理课程实验&#xff09;1、 打开cmd&#xff0c;创建torch虚拟环境。2、 激活创建的torch虚拟环境2.1、 进入pytorch官网&#xff0c;复制如下的命令&#xff0c;进行pytorch的安装2.2、测试安装是否成功3、 通过pip命令安装pytorch&am…

基于WPS实现Excel表的二级下拉选择框

基于WPS实现Excel表的二级下拉选择框第一步&#xff1a;先在sheet2上创建源数据第二步&#xff1a;创建一级下拉框第三步&#xff1a;创建二级下拉框报错记录&#xff1a; “列表源”XXXXXX第一步&#xff1a;先在sheet2上创建源数据 第二步&#xff1a;创建一级下拉框 一级下…

难受啊,备战字节跳动132天,因为一个疏忽让我前功尽弃...

&#x1f4cc; 博客主页&#xff1a; 程序员二黑 &#x1f4cc; 专注于软件测试领域相关技术实践和思考&#xff0c;持续分享自动化软件测试开发干货知识&#xff01; &#x1f4cc; 如果你也想学习软件测试&#xff0c;文末卡片有我的交流群&#xff0c;加入我们&#xff0c;一…

Kibana报错:Kibana server is not ready yet

背景 网页中访问kinaba http://localhost:5601 ,一直提示“Kibana server is not ready yet”。 执行如下命令查看kibana日志&#xff0c; docker logs kibana 发现有提示&#xff1a; 正文 怀疑是不是容器重启后&#xff0c;各容器内部ip变化了导致。 1、故执行如下命令查看…

Android框架源码分析——从设计模式角度看 RxJava 核心源码

从设计模式角度来看 RxJava 核心源码 从订阅者模式和装饰器模式的角度来看 RxJava 源码。 1. 普通订阅者模式与 RxJava 中的订阅者模式 订阅者模式又叫做观察者模式&#xff0c;主要用于实现响应式编程。其本质&#xff0c;就是接口回调。 普通订阅者模式&#xff1a;多个对…

来啦,华东师范大学2024年入学MBA提前面试流程及时间

项目简介华东师范大学系国家教育部和国务院学位办【2007&#xff08;36&#xff09;号】批准的工商管理硕士&#xff08;MBA&#xff09;培养单位。华东师范大学MBA项目&#xff0c;依托学校深厚的人文底蕴和育人文化&#xff0c;利用多学科支撑的优势、利用多元化的办学资源&a…

CUAD学习笔记

目录一、头文件**1、mex.h****2、matrix.h****3、string****4、iostream****5、omp.h**6、cuda_runtime.h7、stdlib.h8、sys/time.h9、stdio.h10、string.h11、time.h12、math.h13、device_launch_parameters.h二、一些声明语句1、using namespace std**2、typedef unsigned ch…

pytorch数据读取深入理解

来源&#xff1a;投稿 作者&#xff1a;小灰灰 编辑&#xff1a;学姐 了解数据 Q&#xff1a;我现在什么基础也没有&#xff0c;我要学习深度学习&#xff0c;学习cv&#xff0c;学习nlp。 A&#xff1a;首先我们知道&#xff0c;深度学习是建立在数据集的基础上。现在呢&…