From fc42eb47b58ae33ee2c0b9e9907d4cc82053ec30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E8=8F=9C=20Cai?= Date: Thu, 6 Aug 2026 16:29:05 +0800 Subject: [PATCH] feat(Cascader): support lazy loading --- packages/components/cascader/README.en-US.md | 1 + packages/components/cascader/README.md | 1 + .../__test__/__snapshots__/demo.test.js.snap | 27 +++ .../components/cascader/__test__/demo.test.js | 2 +- .../cascader/__test__/index.test.js | 135 ++++++++++++ .../cascader/_example/cascader.json | 1 + .../cascader/_example/cascader.wxml | 4 + .../cascader/_example/lazy-load/index.js | 33 +++ .../cascader/_example/lazy-load/index.json | 7 + .../cascader/_example/lazy-load/index.wxml | 10 + .../cascader/_example/lazy-load/index.wxss | 0 packages/components/cascader/cascader.ts | 194 ++++++++++++++---- packages/components/cascader/props.ts | 4 + packages/components/cascader/type.ts | 7 + 14 files changed, 389 insertions(+), 37 deletions(-) create mode 100644 packages/components/cascader/_example/lazy-load/index.js create mode 100644 packages/components/cascader/_example/lazy-load/index.json create mode 100644 packages/components/cascader/_example/lazy-load/index.wxml create mode 100644 packages/components/cascader/_example/lazy-load/index.wxss diff --git a/packages/components/cascader/README.en-US.md b/packages/components/cascader/README.en-US.md index ec856d59c3..fb604260ae 100644 --- a/packages/components/cascader/README.en-US.md +++ b/packages/components/cascader/README.en-US.md @@ -14,6 +14,7 @@ filter | Function | - | Typescript: `CascaderFilterFunction ` `type CascaderFilt filter-placeholder | String | - | \- | N filterable | Boolean | false | \- | N keys | Object | - | Typescript: `CascaderKeysType` `type CascaderKeysType = TreeKeysType`。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/common/common.ts)。[see more ts definition](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/cascader/type.ts) | N +load | Function | - | loading subtree data (only effective when the node's children value is true)。Typescript: `(node: CascaderOption) => Promise>` | N options | Array | [] | Typescript: `Array` | N placeholder | String | - | \- | N sub-titles | Array | [] | Typescript: `Array` | N diff --git a/packages/components/cascader/README.md b/packages/components/cascader/README.md index fb369262dd..e0ff7764bc 100644 --- a/packages/components/cascader/README.md +++ b/packages/components/cascader/README.md @@ -72,6 +72,7 @@ filter | Function | - | 自定义过滤函数。返回 true 表示匹配,未 filter-placeholder | String | - | 搜索框占位符描述文本 | N filterable | Boolean | false | 是否可搜索,开启后顶部会展示一个搜索框 | N keys | Object | - | 用来定义 value / label / children / disabled 在 `options` 中对应的字段别名。TS 类型:`CascaderKeysType` `type CascaderKeysType = TreeKeysType`。[通用类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/common/common.ts)。[详细类型定义](https://github.com/Tencent/tdesign-miniprogram/blob/develop/packages/components/cascader/type.ts) | N +load | Function | - | 加载子树数据的方法(仅当节点 children 为 true 时生效)。TS 类型:`(node: CascaderOption) => Promise>` | N options | Array | [] | 可选项数据源。TS 类型:`Array` | N placeholder | String | - | 未选中时的提示文案。组件内置默认值为:'选择选项' | N sub-titles | Array | [] | 每级展示的次标题。TS 类型:`Array` | N diff --git a/packages/components/cascader/__test__/__snapshots__/demo.test.js.snap b/packages/components/cascader/__test__/__snapshots__/demo.test.js.snap index 2a465f232b..116f8dd94a 100644 --- a/packages/components/cascader/__test__/__snapshots__/demo.test.js.snap +++ b/packages/components/cascader/__test__/__snapshots__/demo.test.js.snap @@ -752,6 +752,33 @@ exports[`Cascader Cascader keys demo works fine 1`] = ` `; +exports[`Cascader Cascader lazy-load demo works fine 1`] = ` + + + + +`; + exports[`Cascader Cascader theme-tab demo works fine 1`] = ` { mapper.forEach((demoName) => { diff --git a/packages/components/cascader/__test__/index.test.js b/packages/components/cascader/__test__/index.test.js index daa69bbe4b..9d5243d5a0 100644 --- a/packages/components/cascader/__test__/index.test.js +++ b/packages/components/cascader/__test__/index.test.js @@ -138,4 +138,139 @@ describe('cascader', () => { expect(leaf.value).toBe('110108'); }); }); + + describe(': lazy load', () => { + const renderCascader = ({ options, value = null, load, keys } = {}) => { + const id = simulate.load({ + template: ``, + data: { options, value, load, keys }, + usingComponents: { 't-cascader': cascader }, + }); + const comp = simulate.render(id); + comp.attach(document.createElement('parent-wrapper')); + return comp; + }; + + const select = ($cascader, level, value) => { + $cascader.instance.handleSelect({ + target: { dataset: { level } }, + detail: { value }, + }); + }; + + it('loads children:true nodes on selection without mutating options', async () => { + const options = [{ label: '中国', value: 'CN', children: true }]; + const load = jest.fn(() => Promise.resolve([{ label: '广东省', value: '440000' }])); + const comp = renderCascader({ options, load }); + const $cascader = comp.querySelector('#cas'); + + expect(load).not.toHaveBeenCalled(); + select($cascader, 0, 'CN'); + await simulate.sleep(); + + expect(load).toHaveBeenCalledTimes(1); + expect(load.mock.calls[0][0]).toMatchObject({ label: '中国', value: 'CN' }); + expect($cascader.instance.data.items[1]).toEqual([{ label: '广东省', value: '440000', disabled: undefined }]); + expect(options[0].children).toBe(true); + }); + + it('initializes a value whose full path already exists', () => { + const options = [ + { + label: '中国', + value: 'CN', + children: [{ label: '深圳市', value: '440300' }], + }, + ]; + const load = jest.fn(); + const comp = renderCascader({ options, value: '440300', load }); + const $cascader = comp.querySelector('#cas'); + + expect($cascader.instance.data.selectedIndexes).toEqual([0, 0]); + expect($cascader.instance.data.selectedValue).toEqual(['CN', '440300']); + expect(load).not.toHaveBeenCalled(); + }); + + it('restores an unresolved value after its path is loaded', async () => { + const options = [{ label: '中国', value: 'CN', children: true }]; + const load = jest.fn((node) => { + if (node.value === 'CN') { + return Promise.resolve([{ label: '广东省', value: '440000', children: true }]); + } + return Promise.resolve([{ label: '深圳市', value: '440300' }]); + }); + const comp = renderCascader({ options, value: '440300', load }); + const $cascader = comp.querySelector('#cas'); + const triggerChange = jest.spyOn($cascader.instance, 'triggerChange'); + + expect($cascader.instance.data.selectedIndexes).toEqual([]); + expect(load).not.toHaveBeenCalled(); + + select($cascader, 0, 'CN'); + await simulate.sleep(); + expect($cascader.instance.data.selectedIndexes).toEqual([0]); + + select($cascader, 1, '440000'); + await simulate.sleep(); + + expect(load).toHaveBeenCalledTimes(2); + expect($cascader.instance.data.selectedIndexes).toEqual([0, 0, 0]); + expect($cascader.instance.data.selectedValue).toEqual(['CN', '440000', '440300']); + expect(triggerChange).not.toHaveBeenCalled(); + }); + + it('deduplicates an in-flight request and caches the loaded children', async () => { + let resolveLoad; + const load = jest.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const comp = renderCascader({ options: [{ label: '中国', value: 'CN', children: true }], load }); + const $cascader = comp.querySelector('#cas'); + + select($cascader, 0, 'CN'); + select($cascader, 0, 'CN'); + expect(load).toHaveBeenCalledTimes(1); + + resolveLoad([{ label: '广东省', value: '440000' }]); + await simulate.sleep(); + select($cascader, 0, 'CN'); + await simulate.sleep(); + + expect(load).toHaveBeenCalledTimes(1); + expect($cascader.instance.data.items[1][0].value).toBe('440000'); + }); + + it('keeps children:true after a rejected request so it can retry', async () => { + const load = jest + .fn() + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValueOnce([{ label: '广东省', value: '440000' }]); + const comp = renderCascader({ options: [{ label: '中国', value: 'CN', children: true }], load }); + const $cascader = comp.querySelector('#cas'); + + select($cascader, 0, 'CN'); + await simulate.sleep(); + select($cascader, 0, 'CN'); + await simulate.sleep(); + + expect(load).toHaveBeenCalledTimes(2); + expect($cascader.instance.data.items[1][0].value).toBe('440000'); + }); + + it('supports lazy loading with custom keys', async () => { + const keys = { label: 'name', value: 'code', children: 'nodes', disabled: 'blocked' }; + const load = jest.fn(() => Promise.resolve([{ name: '广东省', code: '440000' }])); + const comp = renderCascader({ options: [{ name: '中国', code: 'CN', nodes: true }], load, keys }); + const $cascader = comp.querySelector('#cas'); + + select($cascader, 0, 'CN'); + await simulate.sleep(); + + expect(load).toHaveBeenCalledTimes(1); + expect($cascader.instance.data.items[1]).toEqual([{ name: '广东省', code: '440000', blocked: undefined }]); + }); + }); }); diff --git a/packages/components/cascader/_example/cascader.json b/packages/components/cascader/_example/cascader.json index ee90e17b08..a17ed02e66 100644 --- a/packages/components/cascader/_example/cascader.json +++ b/packages/components/cascader/_example/cascader.json @@ -7,6 +7,7 @@ "with-value": "./with-value", "with-title": "./with-title", "check-strictly": "./check-strictly", + "lazy-load": "./lazy-load", "filterable": "./filterable" } } diff --git a/packages/components/cascader/_example/cascader.wxml b/packages/components/cascader/_example/cascader.wxml index 6ea7b8c60c..a8483be475 100644 --- a/packages/components/cascader/_example/cascader.wxml +++ b/packages/components/cascader/_example/cascader.wxml @@ -24,6 +24,10 @@ + + + + diff --git a/packages/components/cascader/_example/lazy-load/index.js b/packages/components/cascader/_example/lazy-load/index.js new file mode 100644 index 0000000000..5935bd066e --- /dev/null +++ b/packages/components/cascader/_example/lazy-load/index.js @@ -0,0 +1,33 @@ +const childrenMap = { + CN: [{ label: '广东省', value: '440000', children: true }], + 440000: [ + { label: '广州市', value: '440100' }, + { label: '深圳市', value: '440300' }, + ], +}; + +Component({ + data: { + options: [{ label: '中国', value: 'CN', children: true }], + value: '440300', + visible: false, + note: '中国/广东省/深圳市', + load(node) { + return new Promise((resolve) => { + setTimeout(() => resolve(childrenMap[node.value] || []), 500); + }); + }, + }, + methods: { + showCascader() { + this.setData({ visible: true }); + }, + onChange(e) { + const { value, selectedOptions } = e.detail; + this.setData({ + value, + note: selectedOptions.map((item) => item.label).join('/'), + }); + }, + }, +}); diff --git a/packages/components/cascader/_example/lazy-load/index.json b/packages/components/cascader/_example/lazy-load/index.json new file mode 100644 index 0000000000..64b0031227 --- /dev/null +++ b/packages/components/cascader/_example/lazy-load/index.json @@ -0,0 +1,7 @@ +{ + "component": true, + "usingComponents": { + "t-cell": "tdesign-miniprogram/cell/cell", + "t-cascader": "tdesign-miniprogram/cascader/cascader" + } +} diff --git a/packages/components/cascader/_example/lazy-load/index.wxml b/packages/components/cascader/_example/lazy-load/index.wxml new file mode 100644 index 0000000000..284605b24c --- /dev/null +++ b/packages/components/cascader/_example/lazy-load/index.wxml @@ -0,0 +1,10 @@ + + + diff --git a/packages/components/cascader/_example/lazy-load/index.wxss b/packages/components/cascader/_example/lazy-load/index.wxss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/components/cascader/cascader.ts b/packages/components/cascader/cascader.ts index b7d9fb2dc8..cfe249e779 100644 --- a/packages/components/cascader/cascader.ts +++ b/packages/components/cascader/cascader.ts @@ -36,7 +36,7 @@ function parseOptions(options: OptionsType, keys: KeysType) { const value = keys?.value ?? 'value'; const disabled = keys?.disabled ?? 'disabled'; - return options.map((item) => ({ + return (options || []).map((item) => ({ [label]: item[label], [value]: item[value], [disabled]: item[disabled], @@ -55,20 +55,26 @@ function flattenPaths(options: OptionsType, keys: KeysType): FlatPath[] { const nextPath = [...path, item]; const nextIndexes = [...indexes, idx]; const children = item?.[childrenKey]; + + if (children === true) { + return; + } + if (Array.isArray(children) && children.length > 0) { walk(children, nextPath, nextIndexes); - } else { - const labels = nextPath.map((node) => String(node?.[labelKey] ?? '')); - const text = [labels.join(''), String(item?.text ?? '')].filter(Boolean).join(''); - result.push({ - key: nextPath.map((node) => String(node?.[valueKey] ?? '')).join('/'), - path: nextPath, - indexes: nextIndexes, - labels, - text, - disabled: nextPath.some((node) => node?.[disabledKey]), - }); + return; } + + const labels = nextPath.map((node) => String(node?.[labelKey] ?? '')); + const text = [labels.join(''), String(item?.text ?? '')].filter(Boolean).join(''); + result.push({ + key: nextPath.map((node) => String(node?.[valueKey] ?? '')).join('/'), + path: nextPath, + indexes: nextIndexes, + labels, + text, + disabled: nextPath.some((node) => node?.[disabledKey]), + }); }); }; @@ -76,6 +82,19 @@ function flattenPaths(options: OptionsType, keys: KeysType): FlatPath[] { return result; } +function cloneOptions(options: OptionsType, keys: KeysType): OptionsType { + const childrenKey = keys?.children ?? 'children'; + + return (options || []).map((item) => { + const cloned = { ...item }; + const children = item?.[childrenKey]; + if (Array.isArray(children)) { + cloned[childrenKey] = cloneOptions(children, keys); + } + return cloned; + }); +} + function buildFragments(labels: string[], keyword: string): ResultFragment[] { const joined = labels.join(' / '); const push = (acc: ResultFragment[], text: string, highlight: boolean) => { @@ -137,6 +156,14 @@ export default class Cascader extends SuperComponent { filterDebounced: ((value: string) => void) | null = null; + optionTree: OptionsType = []; + + optionTreeInitialized = false; + + optionTreeVersion = 0; + + loadingNodes = new Set(); + controlledProps = [ { key: 'value', @@ -186,9 +213,12 @@ export default class Cascader extends SuperComponent { }, options() { - const { selectedValue, steps, items } = this.genItems(); + this.syncOptionTree(); + const selectedIndexes = this.getSelectedIndexesByValue(); + const { selectedValue, steps, items } = this.genItems(selectedIndexes); this.setData({ + selectedIndexes, steps, items, selectedValue, @@ -202,6 +232,16 @@ export default class Cascader extends SuperComponent { }, keys() { + this.syncOptionTree(); + const selectedIndexes = this.getSelectedIndexesByValue(); + const { selectedValue, steps, items } = this.genItems(selectedIndexes); + this.setData({ + selectedIndexes, + steps, + items, + selectedValue, + stepIndex: items.length - 1, + }); this.invalidateFlatPaths(); if (this.data.isSearching) { this.applyFilter(this.data.filterKeyword); @@ -286,26 +326,36 @@ export default class Cascader extends SuperComponent { }, initWithValue() { - if (this.data.value != null && this.data.value !== '') { - const selectedIndexes = this.getIndexesByValue(this.data.options, this.data.value); - - if (selectedIndexes) { - this.setData({ selectedIndexes }); - } - } else { - this.setData({ selectedIndexes: [] }); + this.setData({ selectedIndexes: this.getSelectedIndexesByValue() }); + }, + syncOptionTree() { + this.optionTree = cloneOptions(this.data.options, this.data.keys); + this.optionTreeInitialized = true; + this.optionTreeVersion += 1; + this.loadingNodes = new Set(); + }, + getOptionTree() { + if (!this.optionTreeInitialized) { + this.syncOptionTree(); } + return this.optionTree; + }, + getSelectedIndexesByValue() { + const { value } = this.data; + if (value == null || value === '') return []; + return this.getIndexesByValue(this.getOptionTree(), value) || []; }, getIndexesByValue(options: OptionsType, value) { const { keys } = this.data; - for (let i = 0, size = options.length; i < size; i += 1) { + for (let i = 0, size = options?.length || 0; i < size; i += 1) { const opt = options[i]; if (opt[keys?.value ?? 'value'] === value) { return [i]; } - if (opt[keys?.children ?? 'children']) { - const res = this.getIndexesByValue(opt[keys?.children ?? 'children'], value); + const children = opt[keys?.children ?? 'children']; + if (Array.isArray(children)) { + const res = this.getIndexesByValue(children, value); if (res) { return [i, ...res]; } @@ -344,8 +394,8 @@ export default class Cascader extends SuperComponent { ensureFlatPaths() { let { flatPaths } = this.state; if (!flatPaths || flatPaths.length === 0) { - const { options, keys } = this.data; - flatPaths = flattenPaths(options, keys); + const { keys } = this.data; + flatPaths = flattenPaths(this.getOptionTree(), keys); this.state.flatPaths = flatPaths; } return flatPaths; @@ -422,7 +472,8 @@ export default class Cascader extends SuperComponent { this.hide('finish'); }, regenItemsByIndexes(selectedIndexes: number[]) { - const { options, keys, placeholder, globalConfig } = this.data; + const { keys, placeholder, globalConfig } = this.data; + const options = this.getOptionTree(); const selectedValue: any[] = []; const steps: string[] = []; const items: any[] = [parseOptions(options, keys)]; @@ -461,8 +512,10 @@ export default class Cascader extends SuperComponent { stepIndex: value, }); }, - genItems() { - const { options, selectedIndexes, keys, placeholder, globalConfig } = this.data; + genItems(indexes = this.data.selectedIndexes) { + const { keys, placeholder, globalConfig } = this.data; + const options = this.getOptionTree(); + const selectedIndexes = indexes || []; const selectedValue = []; const steps = []; const items = [parseOptions(options, keys)]; @@ -471,14 +524,16 @@ export default class Cascader extends SuperComponent { let current = options; for (let i = 0, size = selectedIndexes.length; i < size; i += 1) { const index = selectedIndexes[i]; - const next = current[index]; - current = next[keys?.children ?? 'children']; + const next = current?.[index]; + if (!next) break; + const children = next[keys?.children ?? 'children']; selectedValue.push(next[keys?.value ?? 'value']); steps.push(next[keys?.label ?? 'label']); - if (next[keys?.children ?? 'children']) { - items.push(parseOptions(next[keys?.children ?? 'children'], keys)); + if (Array.isArray(children) && children.length > 0) { + items.push(parseOptions(children, keys)); + current = children; } } } @@ -497,7 +552,9 @@ export default class Cascader extends SuperComponent { const { level } = e.target.dataset; const { value } = e.detail; const { checkStrictly } = this.properties; - const { selectedIndexes, items, keys, options, selectedValue } = this.data; + const { items, keys, selectedValue } = this.data; + const selectedIndexes = [...this.data.selectedIndexes]; + const options = this.getOptionTree(); const index = items[level].findIndex((item) => item[keys?.value ?? 'value'] === value); let item = selectedIndexes.slice(0, level).reduce((acc, item, index) => { @@ -530,8 +587,12 @@ export default class Cascader extends SuperComponent { } selectedIndexes.length = level + 1; - const { items: newItems } = this.genItems(); - if (item?.[keys?.children ?? 'children']?.length >= 0) { + const children = item?.[keys?.children ?? 'children']; + if (children === true && typeof this.data.load === 'function') { + this.setData({ selectedIndexes }); + this.loadChildren(item, selectedIndexes); + } else if (Array.isArray(children) && children.length > 0) { + const { items: newItems } = this.genItems(selectedIndexes); this.setData({ selectedIndexes, [`items[${level + 1}]`]: newItems[level + 1], @@ -547,6 +608,67 @@ export default class Cascader extends SuperComponent { this.hide('finish'); } }, + async loadChildren(item: Record, selectedIndexes: number[]) { + if (this.loadingNodes.has(item)) return; + + const { keys, load } = this.data; + if (typeof load !== 'function') return; + const childrenKey = keys?.children ?? 'children'; + const version = this.optionTreeVersion; + this.loadingNodes.add(item); + + try { + const result = await load(item); + if (version !== this.optionTreeVersion) return; + + const children = cloneOptions(Array.isArray(result) ? result : [], keys); + item[childrenKey] = children; + this.invalidateFlatPaths(); + + if (this.data.isSearching) { + this.applyFilter(this.data.filterKeyword); + } + + const current = this.getOptionByIndexes(this.data.selectedIndexes); + if (current !== item) return; + + const matchedIndexes = this.getIndexesByValue(children, this.data.value); + const nextIndexes = matchedIndexes ? [...selectedIndexes, ...matchedIndexes] : selectedIndexes; + const { selectedValue, steps, items } = this.genItems(nextIndexes); + this.setData( + { + selectedIndexes: nextIndexes, + selectedValue, + steps, + items, + stepIndex: items.length - 1, + }, + children.length === 0 + ? () => { + this.triggerChange(); + this.hide('finish'); + } + : undefined, + ); + } catch { + // Keep children as true so the user can retry loading the node. + } finally { + this.loadingNodes.delete(item); + } + }, + getOptionByIndexes(selectedIndexes: number[]) { + const childrenKey = this.data.keys?.children ?? 'children'; + let current: any = this.getOptionTree(); + let option: any; + + for (let i = 0; i < selectedIndexes.length; i += 1) { + if (!Array.isArray(current)) return undefined; + option = current[selectedIndexes[i]]; + if (!option) return undefined; + current = option[childrenKey]; + } + return option; + }, triggerChange() { const { items, selectedValue, selectedIndexes } = this.data; this._trigger('change', { diff --git a/packages/components/cascader/props.ts b/packages/components/cascader/props.ts index 01066b3fba..b1d02c4979 100644 --- a/packages/components/cascader/props.ts +++ b/packages/components/cascader/props.ts @@ -34,6 +34,10 @@ const props: TdCascaderProps = { keys: { type: Object, }, + /** 加载子树数据的方法(仅当节点 children 为 true 时生效) */ + load: { + type: null, + }, /** 可选项数据源 */ options: { type: Array, diff --git a/packages/components/cascader/type.ts b/packages/components/cascader/type.ts index f3dea9c22d..f180c5b82b 100644 --- a/packages/components/cascader/type.ts +++ b/packages/components/cascader/type.ts @@ -53,6 +53,13 @@ export interface TdCascaderProps Promise>; + }; /** * 可选项数据源 * @default []