ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ArkTS ForEach键值设计:避免列表复用错乱与性能问题

ArkTS ForEach键值设计:避免列表复用错乱与性能问题

1. 键值冲突到底是怎么把列表搞乱的

1.1 一个看起来毫无问题的 ForEach 引发的问题

去年做自行车商城的一个选购页,页面结构并不复杂:顶上一排品牌快捷筛选,中间是推荐车型卡片流,底下是"最近浏览"列表。三块内容都塞在同一个Scroll包裹的Column里,各自用一个ForEach渲染。写完之后预览,静态展示一切正常,UI 也漂亮,我就直接提测了。

问题在交互之后出现:点某张卡片上的"收藏",高亮描边跑到隔壁卡片上去了;删除一条浏览记录,被点的那条原地不动,反倒下面那条消失了;把同一辆车在推荐区和浏览区同时展示时,两边的库存标签串了。控制台翻来翻去,最扎眼的是一行行键值日志——三个ForEach打印出来的键值都是"0"、"1"、"2"。

这就是键值冲突最典型的样子:UI 不崩、不报错,但状态开始到处乱窜。它不像空指针那样把问题摆在明面上,而是藏在 diff 的复用逻辑里,等你点了几下才发现哪哪都不对。后来我把三个列表的键值生成函数全部加上各自的前缀,推荐列表用'w' + bike.brand + index这种形式,问题当场消失。这篇就把这套做法的来龙去脉、坑和边界讲清楚,适合刚上手 ArkTS 声明式 UI 的开发者,也适合已经在写业务、但列表偶尔"抽风"却找不到原因的同学。

1.2 ForEach 的 diff 到底在比什么

要理解键值为啥重要,得先明白ForEach在数据变化时做了什么。ArkUI 的声明式渲染不是"数据变了就把整棵树推倒重建",那样性能顶不住。它走的是 diff:新数据源和旧数据源逐项比对,判断哪些节点能复用、哪些要销毁、哪些要新建。而判断"这两项是不是同一个东西"的唯一依据,就是键值。

我常拿演唱会检票做类比:票号就是键值。检票员不看你是谁,只看票号。票号唯一,一人一座,井然有序;票号重复,两个人举着同一张票进场,座位就必然打架——有人坐错位置,有人被请出去,而秩序本身并不会报错。列表里的组件实例就是座位,键值就是票号。

更麻烦的是,键值决定的不仅是"谁坐哪",还决定组件实例内部的临时状态能不能保住。一个卡片里如果有展开/收起、输入框光标位置、动画进度,这些东西都挂在组件实例上。键值一变,实例销毁重建,这些状态全部归零。所以键值设计不只是防冲突,还直接影响交互手感和渲染开销。

顺带说一句默认行为:不写键值生成函数时,ArkUI 会自动生成一个,大致是把索引和元素内容序列化拼在一起。这个默认值能用,但有两个隐患——元素是大对象时序列化开销不小,而且不同版本实现细节可能微调。业务代码里我从不依赖默认键值,该写就写。

1.3'w' + bike.brand + index这套组合想解决什么

拆开看这三段,每一段都有明确分工。

'w'是命名空间前缀,我把它理解成"这条键属于哪块领地"。整页可能有五六个ForEach,各自占一个短前缀,互不侵占。字母本身没有语义强制,你可以用w代表 wheels、代表 bike 相关,重点是全页面唯一且团队约定统一。

bike.brand是业务维度。带上真实数据字段之后,键值不再单纯依赖位置。头部插入一辆车时,后面所有元素的位置索引都往后挪了一位,如果键值只有索引,那整段列表全部"换人",diff 会判定所有节点都是新的,全量重建。带上 brand 之后,品牌没变的元素至少还能部分命中。

index是兜底。同一个品牌下往往有好几款车,只用 brand 会立刻重复。

所以这套组合的本质是:命名空间保证跨列表不撞,业务字段保证头插尾插不失效,索引保证同组内不重复。三层各司其职,缺一层就出问题。后文我会讲这套方案在什么情况下仍然会撞,以及怎么补。

2. 键值生成函数的三条硬规则与设计考量

2.1 唯一性、稳定性、可读性,缺一不可

我给键值设计总结了三条必须同时满足的规则,任何一条破了都会出问题。

唯一性是底线。在同一个父容器作用域下,键值不允许重复。注意是"同一个父容器",不是"同一个 ForEach"。当多个ForEach渲染出的子组件挂在同一个Column、List、Swiper下面,它们实际上共处一个作用域,此时两组键值都产出"0"、"1"、"2",diff 层就没有足够信息区分来源,复用自然会串。

稳定性是最容易被忽略的。同一个逻辑元素,在多次渲染之间必须产出同一个键值。有人在键值里塞Math.random()或者Date.now(),理由听起来很合理——"我就是要保证唯一嘛"。结果每次重渲染键值全变,整列表全量重建,滚动卡成幻灯片,卡片里的展开状态还被重置了。唯一性和稳定性是两件事,别为了前者牺牲后者。

可读性是可维护性的一部分。出问题时你总得看日志吧?如果键值是一串哈希,你根本不知道它对应哪条数据、哪个列表。w烈风_0这种一眼就能读出来源和位置,排查效率天差地别。三条规则里,可读性是最容易被牺牲的,但恰恰是最省事的。

2.2 只用 index 当键值,为什么迟早出问题

(item, index) => index.toString()是我见过频率最高的写法,也是埋雷最深的写法。它在两种场景下必然翻车。

一是头部或中部插入。列表原本[A, B, C],键值是0, 1, 2。在头部插入 D,变成[D, A, B, C],键值变成0, 1, 2, 3。diff 一看:键值 0 还在,复用;1 还在,复用;2 还在,复用;3 是新的,新建。于是它把 D 的内容塞进了原来 A 的组件实例里,A 塞进 B,B 塞进 C,C 新建。视觉上就是"内容整体向下错了一格",如果组件内部有状态(比如已展开),展开状态就跟着往下错位了。

二是多列表共存。两个ForEach都从 0 开始数,键值天然重叠。这就是我开头遇到的场景。

还有一个更隐蔽的:删除中间项。删掉 B,[A, B, C]变[A, C],键值0, 1。diff 认为 0 复用、1 复用,然后把 C 的数据写进了 B 的实例里。用户看到的是"我明明删的中间那条,怎么删掉的是最后一条"。这个 bug 极其难描述,测试同学反馈时往往只能说"删除行为很怪"。

2.3 只用 brand 当键值,为什么也不行

从 index 跳到 brand,是从一个极端走向另一个极端。同品牌多车型的列表在真实业务里太常见了——烈风品牌下三款车在货架上,键值全是烈风,直接重复,diff 层连报错都不给,直接复用错乱。

更糟的是修改场景。用户在筛选栏切换品牌,列表从[烈风, 野途]换成[烈风, 疾影],键值从烈风, 野途变成烈风, 疾影。看起来没问题,但如果列表里同一品牌出现了两次(比如不同批次或不同配置),两个烈风会共用同一个实例,库存、价格、收藏状态全部互相污染。

brand的价值在于它提供了业务锚点,让键值和真实数据挂钩;但它的粒度不够。品牌是一对多的分类维度,不是唯一标识。把它当键值,相当于用"姓"来分辨同姓的一家人。

2.4 前缀'w'的真正价值在哪里

很多人会问:前缀不就是加个字符吗,有什么用?我一开始也这么想,直到那次跨列表冲突排查到深夜。

前缀的核心作用是划分作用域。一个页面上多个ForEach,它们的键值在父容器层面是被放在一起比较的。加了前缀,等于给每个列表划了一块自留地:w开头的是推荐列表,h开头的是历史记录,b开头的是品牌筛选栏。物理上不可能重叠,逻辑上一眼可辨。

第二层作用是排查友好。线上出问题时,我把键值打进日志,看到w烈风_0就知道是推荐列表第一条烈风车;看到h疾影_3就知道是浏览记录第四条。如果键值是纯索引,日志里全是0, 1, 2,你根本分不清是哪块内容在报错。

第三层是防御性。就算某天同事在同一个父容器里加了个新的ForEach,只要他遵守前缀约定,就不会跟已有的撞。约定比记忆可靠,尤其是团队里来来去去的人。

2.5 一个必须说的边界:这套组合仍可能撞

'w' + bike.brand + index不是银弹。有两种情况它会失效,得提前打补丁。

第一种是同品牌列表被整体替换。推荐列表从"烈风一批"换成"烈风另一批",数量相同、品牌相同、索引相同,键值完全一致,diff 认为没有变化,不重建,UI 显示的还是旧数据。这种场景必须换方案:要么在键值里带上足以区分批次的字段(比如上架时间戳),要么直接用业务主键。

第二种是字符串拼接的歧义。这坑更隐蔽:品牌叫S1,索引2,拼出来是wS12;品牌叫S,索引12,拼出来也是wS12。两个不同的逻辑元素产出了同一个键值。解决办法很简单,加分隔符:'w' + brand + '_' + index。一个下划线的事,但我在真实项目里见过它造成的诡异 bug。

所以我的结论是:有稳定业务主键时,优先用'w' + bike.id;没有主键、且列表是相对稳定的展示型数据时,用'w' + brand + '_' + index。判断标准在"这份数据会不会被整体换掉",换掉时必须能产生新键值。

3. 从零落地:自行车列表组件的完整实现

3.1 数据模型与状态设计

先把数据结构定下来。模型设计直接影响键值能取到什么字段,所以这一步不能糊弄。

// model/BikeModel.ets export interface Bike { id: string; // 业务主键,正常情况下唯一 brand: string; // 品牌,用于分类与键值锚点 model: string; // 车型名 price: number; // 价格 stock: number; // 库存 tags: string[]; // 标签:新品、热销等 }

注意id我单独列了出来。虽然标题方案里用的是brand + index,但真实业务里只要id存在,就应该优先用它。brand + index是兜底方案,适用于老数据没有主键、或者数据来自只读接口的展示场景。这点得摆正,否则容易被我误导成"所有列表都这么写"。

状态部分,我倾向于把不同区块的数据拆成独立的@State,而不是塞进一个大的对象里。拆开之后,某一区块更新时触发的刷新范围更小,ForEach的 diff 也更有针对性。

@Entry @Component struct BikeShopPage { @State brands: string[] = ['烈风', '野途', '疾影', '云梭']; @State recommend: Bike[] = []; @State history: Bike[] = []; aboutToAppear(): void { this.recommend = this.mockRecommend(); this.history = this.mockHistory(); } }

这里有个小细节:初始化数据放在aboutToAppear里,而不是直接给@State赋调用返回值。看起来结果一样,但前者在页面复用时才执行,后者在结构体构造期就跑了,遇到耗时数据源时会影响首帧。属于不必要但值得注意的差别。

3.2 键值生成函数的抽离与复用

键值生成函数绝对不建议内联在ForEach的第三个参数里。原因有三:第一,同一套规则在多个列表复用,内联会复制粘贴;第二,出问题时改一处漏一处;第三,内联写法没法统一加日志。

我习惯抽一个工厂类出来,前缀统一定义成常量,写完就不改。

// common/KeyFactory.ets export class KeyFactory { // 命名空间前缀:一个列表一个,全页面唯一 static readonly NS_RECOMMEND: string = 'w'; static readonly NS_HISTORY: string = 'h'; static readonly NS_BRAND: string = 'b'; // 推荐列表:'w' + brand + '_' + index static recommendKey(item: Bike, index: number): string { return `${KeyFactory.NS_RECOMMEND}${item.brand}_${index}`; } // 浏览记录:'h' + brand + '_' + index static historyKey(item: Bike, index: number): string { return `${KeyFactory.NS_HISTORY}${item.brand}_${index}`; } // 品牌筛选栏:'b' + brand + '_' + index static brandKey(brand: string, index: number): string { return `${KeyFactory.NS_BRAND}${brand}_${index}`; } }

抽出工厂之后,最大的收益不是省了几行代码,而是命名空间成了显式契约。谁要加新列表,先来这里领一个前缀,撞车风险直接降到零。团队里新人接手时,翻一眼这个文件就知道页面上有几块列表。

如果你更喜欢函数式,也可以写成顶层导出函数,但前缀常量最好还是收拢在一处。散落各处的'w'、'h'才是最可怕的,改都改不干净。

3.3 主界面与多个 ForEach 的共存

下面是把三个列表拼到同一个父容器里的完整写法。这是最考验键值设计的场景——三组键值会共处一个作用域。

@Entry @Component struct BikeShopPage { @State brands: string[] = ['烈风', '野途', '疾影', '云梭']; @State recommend: Bike[] = []; @State history: Bike[] = []; aboutToAppear(): void { this.recommend = this.mockRecommend(); this.history = this.mockHistory(); } build() { Scroll() { Column({ space: 16 }) { this.BrandBar() this.RecommendSection() this.HistorySection() } .padding(16) .alignItems(HorizontalAlign.Start) } .width('100%') .height('100%') .backgroundColor('#F5F6F8') } @Builder BrandBar() { Row({ space: 8 }) { ForEach(this.brands, (brand: string, index: number) => { Text(brand) .fontSize(14) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor('#FFFFFF') .borderRadius(14) }, (brand: string, index: number) => KeyFactory.brandKey(brand, index) ) } } @Builder RecommendSection() { Column({ space: 10 }) { Text('为你推荐').fontSize(16).fontWeight(FontWeight.Medium) ForEach(this.recommend, (bike: Bike, index: number) => { RecommendCard({ bike: bike }) }, (bike: Bike, index: number) => KeyFactory.recommendKey(bike, index) ) } .width('100%') .alignItems(HorizontalAlign.Start) } @Builder HistorySection() { Column({ space: 10 }) { Text('最近浏览').fontSize(16).fontWeight(FontWeight.Medium) ForEach(this.history, (bike: Bike, index: number) => { HistoryRow({ bike: bike }) }, (bike: Bike, index: number) => KeyFactory.historyKey(bike, index) ) } .width('100%') .alignItems(HorizontalAlign.Start) } }

注意@Builder的用法——把三块拆成独立构建函数,好处是每个ForEach的父容器边界清清楚楚。更进一步,如果每块都包在自己的Column里,键值实际上是在各自Column内比较的,冲突概率更低。但不能依赖这个,因为布局调整时很容易把多个ForEach平铺到同一层,那时候前缀就是唯一的护城河。

子组件用@Prop接收数据比较稳:

@Component struct RecommendCard { @Prop bike: Bike; @State favorited: boolean = false; build() { Row({ space: 12 }) { Column({ space: 4 }) { Text(`${this.bike.brand} ${this.bike.model}`).fontSize(15) Text(`¥${this.bike.price}`).fontSize(13).fontColor('#FF6B35') } .alignItems(HorizontalAlign.Start) .layoutWeight(1) Button(this.favorited ? '已收藏' : '收藏') .fontSize(13) .height(30) .backgroundColor(this.favorited ? '#FFE8DF' : '#FFFFFF') .fontColor(this.favorited ? '#FF6B35' : '#333333') .onClick(() => { this.favorited = !this.favorited; }) } .width('100%') .padding(12) .backgroundColor('#FFFFFF') .borderRadius(10) .border({ width: 1, color: this.favorited ? '#FF6B35' : '#00000000' }) } }

favorited是组件内部状态,不跟着数据源走。这正是键值冲突最容易暴露的地方:键值一撞,某个卡片的实例被复用给另一条数据,favorited就跟着错位了,用户看到的是"我收藏了这辆,亮的是那辆"。所以这个 Demo 其实是个很好的键值测试用例,改键值前后对比一下,效果非常直观。

3.4 运行验证与日志核对

改完不能只看界面顺不顺眼,得拿日志说话。我一般在键值生成函数里临时插一条日志,验证完就删。

// 临时排查用,上线前务必删除 static recommendKey(item: Bike, index: number): string { const key = `${KeyFactory.NS_RECOMMEND}${item.brand}_${index}`; console.info(`[ForEachKey][recommend] index=${index} key=${key}`); return key; }

跑起来看几条日志:w烈风_0、w野途_1、w疾影_2,前缀对、品牌对、索引对。再切一次数据源,看键值有没有随着头部插入而变化——如果插入一条后,原来的w烈风_0变成w烈风_1,说明键值里含索引,实际复用时会有部分失效,这在展示型列表里可以接受,但如果列表项里有重状态,就得考虑改用id。

验证清单我固定这么几项:

验证项操作期望结果
跨列表唯一同时展示推荐与浏览键值前缀不同,无重复
头部插入在数组首位 unshift 一条旧元素键值尽量不变
中间删除删除索引 1 的项删除的是目标项,状态不错位
收藏状态点第 2 张卡片收藏只有第 2 张高亮
重复品牌同品牌放三辆车键值靠索引区分,互不复用

跑完这五项,基本能确认键值方案站得住。少跑一项,就可能在测试同学那里返工一次。

4. 键值策略的进阶玩法与性能账

4.1 和 LazyForEach、List 配合时的差异

ForEach是"全量渲染",LazyForEach是"按需渲染",两者对键值的要求不完全一样。

ForEach的场景下,键值参与 diff 复用,只要保证唯一稳定,性能影响是可控的。但如果数据量大(比如超过 100 条),ForEach会一次性把所有子组件建出来,内存和首帧时间都吃不消。这时候要换List+LazyForEach。

LazyForEach的键值要求更严格:必须全局唯一且稳定,因为数据源要通过键值做增删改的增量通知。它的签名是三个参数——数据源、子组件生成函数、键值生成函数,数据源要实现IDataSource接口,提供totalCount、getData、registerDataChangeListener等方法。

class BikeDataSource implements IDataSource { private data: Bike[] = []; private listeners: DataChangeListener[] = []; totalCount(): number { return this.data.length; } getData(index: number): Bike { return this.data[index]; } registerDataChangeListener(listener: DataChangeListener): void { if (this.listeners.indexOf(listener) < 0) { this.listeners.push(listener); } } unregisterDataChangeListener(listener: DataChangeListener): void { const pos = this.listeners.indexOf(listener); if (pos >= 0) { this.listeners.splice(pos, 1); } } }

配合LazyForEach时,我强烈建议键值直接用'w' + bike.id。因为LazyForEach内部会维护一份键值到组件实例的映射表,如果键值重复,映射表直接被覆盖,onDataReloaded之后就彻底乱了,而且这类问题在滚动到特定位置才暴露,极难复现。

List里还有一个额外的细节:每个ListItem只允许一个直接子组件,ForEach要写在List内部、ListItem外层。层级摆错会导致键值作用域判断异常,我踩过一次,界面不报错但滚动白块,查了半天以为是自己算错了高度。

4.2 增删改查各种场景下的键值表现

我把常见数据操作和键值表现做了个对照,写代码前先扫一眼这张表,能省掉很多返工。

数据操作纯 index 键值brand + index业务主键 id建议
尾部追加全部复用,表现良好全部复用全部复用都可用
头部插入全部位置后移,状态错位部分复用,索引变化精准复用用 id
中间删除后续项整体前移,删错项部分错位精准定位用 id
元素内容修改复用但内容不刷新复用但内容不刷新键值不变,靠 @Prop 刷新配合 @Prop
整体替换数据源完全失效可能完全失效全新键值,正常重建用 id
多列表共存必然冲突跨列表仍冲突跨列表仍可能冲突加前缀

注意第四行——元素内容修改。键值没变的情况下,组件会复用,但内容能不能刷新取决于数据传参方式。如果子组件用@Prop bike: Bike,@Prop是深拷贝,父组件的bike对象变了但键值不变,@Prop未必触发刷新。这时候要么用@ObjectLink+@Observed做对象级观察,要么干脆在键值里带上会变的字段——但后者会导致重建,得不偿失。键值不是用来驱动内容更新的,那是状态观察的事,这个边界要分清。

4.3 一次对比实测的数据记录

光说理论没感觉,说一次实测。某跨平台项目中一个 200 条数据的品牌车型列表,设备是中端机,我做了三轮对比:纯 index 键值、w + brand + index、w + id。

测试方法:列表加载完成后,执行 20 次头部插入 + 20 次删除,记录平均帧耗时和组件重建次数(通过子组件aboutToAppear打点统计)。

结果大致是这样:纯 index 方案在头部插入时重建次数接近全量,帧耗时波动最大;w + brand + index方案在第一轮插入时仍有一定重建(因为索引整体后移),第二轮之后趋于稳定;w + id方案重建次数最少,帧耗时曲线最平。

这个结果给了我一个很实用的判断依据:如果列表是"只增不减的流式数据"(比如消息流、日志流),brand + index表现已经够用;如果是"会从中间删改的列表"(比如购物车、收藏夹),必须上 id。判断错了,用户滚动时就会感受到那种一顿一顿的滞涩感,虽然不掉帧,但体验差一截。

顺便提一句,键值生成函数本身的开销也要算。它会在 diff 期间被频繁调用,里面别写JSON.stringify整个对象,也别做数组遍历。字符串模板拼接是最省的,'w' + brand + '_' + index就属于这一类。我见过有人在键值函数里查一趟 Map 拿额外字段,结果列表一滚就掉帧。

5. 常见问题与排查技巧实录

5.1 症状对照表:先定位再动手

键值问题最难的地方不是修,是认出来它是键值问题。因为报错信息通常只有一行警告,甚至什么提示都没有。我把踩过的症状整理成一张表,遇到时先对号入座。

症状表现大概率根因处理方式
点击某卡片,另一卡片状态变化键值重复,实例被复用加命名空间前缀
删除中间项,被删的是别的项键值只用了索引键值加入业务字段或 id
头部插入后卡片内容整体错一格键值含索引,位置全变改用业务主键
列表更新后界面不刷新键值没变,diff 认为无变化检查键值是否覆盖变化维度
滚动时疯狂重建,明显卡顿键值不稳定(含随机值/时间戳)移除不稳定字段
输入框打字光标跳到别的项实例复用导致焦点跟随键值唯一化,必要时禁用复用
控制台提示键值重复同一作用域内键值冲突检查是否有多个 ForEach 同层

第二行和第三行最容易被误判成数据源问题,很多人会去查数组操作、查splice用没用好,其实数组操作是对的,是渲染层没跟上。判断方法很简单:在子组件的aboutToAppear里打一条日志,看它是不是被"错误地复用"了——如果删了 A 但 B 的aboutToAppear没触发,说明 B 的实例被直接拿去渲染了别的数据。

5.2 三步排查法:从现象到键值

我现在固定走三步,基本能在十分钟内定位。

第一步,打印键值。在键值生成函数里打日志,把index、关键字段、最终 key 全部输出。这一步能直接暴露"是否重复"和"是否稳定"。如果日志里出现两条一模一样的 key,问题就锁定了。

第二步,看作用域。如果单个ForEach内部键值不重复,但界面还是串,那就往上看父容器:是不是有多个ForEach平铺在同一层?是不是@Builder展开后恰好落到了同一个Column?把每个ForEach包进独立的Column试试,如果问题消失,基本可以确认是跨列表冲突。

第三步,做最小复现。把数据和结构抽出来,写一个只含两个ForEach的页面,每个三条数据。能复现就说明是键值逻辑问题;不能复现,说明还牵扯别的东西(比如数据源刷新方式、@Observed的嵌套层级),再往回加条件。

有个容易忽略的点:日志打太多也会掩盖问题。键值函数在滚动时会高频调用,日志刷屏反而看不清关键的那几条。我一般加个索引条件,只打前五条,或者用console.debug配合过滤开关,验证完立刻删掉。生产包里残留键值日志,除了拖慢性能没有别的用处。

5.3 团队里怎么把这条约定落地

一个人写代码靠自觉,一群人写代码靠约定。我们最后把键值规范写进了 review checklist,就三条:

第一条,每个ForEach必须显式提供键值生成函数,不允许依赖默认实现。review 时看到缺失直接打回。这条最容易推行,因为检查成本几乎为零。

第二条,前缀必须从统一常量里取,不允许硬编码字符串。这一条防的是"复制粘贴改一半"——从w改成h时漏改一个字符,造成前缀冲突。收拢到KeyFactory之后,加新列表就是加一个新常量。

第三条,键值生成函数不得包含副作用和随机值。这条最容易被违反,因为它不像前两条那么直观。有人觉得加个Date.now()后缀能保证唯一,其实破坏了稳定性。review 的时候扫一眼函数体,有random、now、Math.就打回。

另外我把前面那个五项验证清单做成了自测用例,提交前跑一遍。做成一键执行的脚本比较难,但做成手工清单已经足够拦住 90% 的问题。剩下的 10%,等 UI 自动化测试覆盖到交互场景时自然会暴露——比用户先发现要好。

最后说个反直觉的体会:键值冲突不是写错代码,而是设计缺位。写index.toString()的人不是不知道有问题,是没想过这个列表将来会不会有第二个ForEach、会不会头插、会不会整体替换。等到这些情况真的出现,改起来往往牵连一片。所以我在新建一个带列表的页面时,会先花两分钟在纸上写清楚:这个页面有几块列表、各自的数据源是什么、有没有稳定主键、会不会被整体替换。四个问题答完,键值方案基本也就定了,比出问题再回来重构省太多事。

返回列表