一句话结论:
el-cascader的 model 值有四种形态(单选/多选 × 路径/叶子),join('/')只能处理其中一种;正确做法是一次建树索引、按值反查祖先路径、多选按路径去重。
现象:详情页里的地区字段显示成了编码
编辑态用 el-cascader 选地区,详情态直接展示 model:
html
<template>
<el-form-item label="所属地区">
<el-cascader v-model="form.region" :options="regionOptions" />
</el-form-item>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
const form = reactive({ region: ['zj', 'hz', 'xh'] })
const regionOptions = [
{
value: 'zj',
label: '浙江省',
children: [
{
value: 'hz',
label: '杭州市',
children: [
{ value: 'xh', label: '西湖区' },
{ value: 'yh', label: '余杭区' }
]
},
{ value: 'nb', label: '宁波市' }
]
}
]
</script>
把 v-model 换成文本后:
html
<span>{{ form.region.join('/') }}</span>
页面显示:
text
zj/hz/xh
期望是 浙江省/杭州市/西湖区。原因很简单:model 里存的是 option 的 value,不是 label 。join 只是把 value 拼起来。
更麻烦的是,改成 props: { emitPath: false } 之后,model 变成叶子值 'xh',join 只会显示 xh,连层级都没了。
最小复现:四种值,一行 join
把问题压缩到只剩 model 值:
ts
const singlePath = ['zj', 'hz', 'xh']
const singleLeaf = 'xh'
const multiplePaths = [
['zj', 'hz', 'xh'],
['zj', 'nb']
]
const multipleLeaves = ['xh', 'nb']
singlePath.join('/') // 'zj/hz/xh'
singleLeaf // 'xh'
multiplePaths.join('/') // 'zj,hz,xh/zj,nb'
multipleLeaves.join('/') // 'xh/nb'
四段代码的期望输出都是完整路径(单选一条,多选多条),但没有一段能靠 join 得到。原因不是 join 写错了,而是这四种值的含义本来就不同。
先搞清值的四种形态
el-cascader 的 multiple 和 emitPath 都在 props 配置里,而不是组件根上的 prop:
html
<el-cascader v-model="region" :options="options" :props="{ multiple: true, emitPath: false }" />
两个配置的默认值都是"朴素"的:multiple 默认 false,emitPath 默认 true。组合起来是四种形态:
multiple |
emitPath |
model 示例 | 含义 |
|---|---|---|---|
false |
true |
['zj', 'hz', 'xh'] |
一个选中项,值是完整路径 |
false |
false |
'xh' |
一个选中项,值是叶子值 |
true |
true |
[['zj', 'hz', 'xh'], ['zj', 'nb']] |
多个选中项,每项是路径 |
true |
false |
['xh', 'nb'] |
多个选中项,每项是叶子值 |
这四种形态就是 join 失效的根源:它对前三种都会给出错误结果,只是错法不同。
还有一个容易被忽略的配置是 checkStrictly。它允许选中任意层级,此时路径可能停在中间节点,比如 ['zj', 'hz'],而不是叶子。展示逻辑不能假设"路径最后一项一定是叶子"。
用一张图概括这四种形态的分支关系:

朴素方案为什么错
方案 1:value.join('/')
| 形态 | 直接展示结果 | 期望输出 |
|---|---|---|
| 单选 + 路径 | zj/hz/xh |
浙江省/杭州市/西湖区 |
| 单选 + 叶子 | xh |
浙江省/杭州市/西湖区 |
| 多选 + 路径 | zj,hz,xh/zj,nb |
浙江省/杭州市;浙江省/宁波市 |
| 多选 + 叶子 | xh/nb |
浙江省/杭州市/西湖区;浙江省/宁波市 |
多选 + 路径时,每个路径数组会先被转成 zj,hz,xh 这样的字符串,再用 / 连接,层级只能靠标点猜。
方案 2:options.find((item) => item.value === value)
只能匹配第一层。'hz' 在 children 里,找不到;即使找到了,也拿不到它的祖先。
方案 3:递归找到节点,直接返回它的 label
ts
function findLabel(options, target) {
for (const option of options) {
if (option.value === target) return option.label
if (option.children) {
const label = findLabel(option.children, target)
if (label) return label
}
}
}
这样 'xh' 能返回 西湖区,但祖先信息在递归返回时被丢掉了。要还原 浙江省/杭州市/西湖区,必须在递归过程中携带父级 label,或者事后维护一张"子 → 父"的表。
方案 4:给每个节点补一个 parent 指针
看起来能解决祖先问题:
ts
function attachParent(nodes, parent) {
for (const node of nodes) {
node.parent = parent
if (node.children) attachParent(node.children, node)
}
}
但它会修改业务数据 :选项通常是后端返回的、被多个组件共享的、甚至是冻结的。加 parent 字段还会让选项对象变成循环引用,后续 JSON.stringify 直接抛错。
四种朴素方案各自卡在哪一步,可以这样对照:

理论方案对比
| 方案 | 做法 | 复杂度 | 主要问题 |
|---|---|---|---|
| A. 每次从根递归查找 | 查找时携带父级 label | 每次 O(n) | 多选时重复遍历整棵树 |
| B. 一次 DFS 建索引 | 索引里保存每个节点的完整 label 数组 | 建 O(n),原始类型查 O(1) | 需要额外内存 |
C. 扁平 Map<value, label> |
只存当前节点 label | 建 O(n),查 O(1) | 丢失祖先路径,重复值会覆盖 |
| D. 后端直接返回 label | model 里同时存 value 和 label | 无 | 展示值与选项可能不一致 |
方案 B 是展示层最常用的折中:详情页通常只渲染一次,建一次索引的成本可以接受;多选时每个值都能 O(1) 查到完整路径。
方案 D 在"审计、快照、历史记录"场景反而更合适:这类数据要求展示内容不随选项变化,存 label 快照比每次解析更可靠。
一个失败方案:扁平 Map<value, label>
很多人会先写这个版本:
ts
function buildLabelMap(options) {
const map = new Map()
const walk = (nodes) => {
for (const node of nodes) {
map.set(node.value, node.label)
if (node.children) walk(node.children)
}
}
walk(options)
return map
}
它对"单选 + 叶子"看起来能用:
ts
const labels = buildLabelMap(regionOptions)
labels.get('xh')
// '西湖区'
但一旦需要完整路径就露馅了:
ts
labels.get('xh')
// '西湖区',期望 '浙江省/杭州市/西湖区'
还有三个隐藏问题:
- 重复 value 会覆盖。不同分支可能复用同一个 value(比如地区编码、组织 ID),后遍历到的节点会覆盖前面的,展示结果取决于遍历顺序;
- 对象 value 不能直接做
Map键 。{ id: 1 }每次都是新引用,get永远不命中; - 无法表达层级。即使把 label 改成数组,也要在遍历时携带父级,这已经回到方案 B 了。
所以索引至少要存两样东西:value → { labels: string[] } 的快速通道,以及一份 entries 数组,供对象值或引用比较兜底。
我是怎么解决的
1. 建索引
ts
interface CascaderOption {
[key: string]: unknown
}
interface CascaderConfig {
value?: string
label?: string
children?: string
multiple?: boolean
emitPath?: boolean
separator?: string
}
interface Entry {
value: unknown
labels: string[]
}
interface Index {
byKey: Map<string, Entry>
entries: Entry[]
}
function buildIndex(options: CascaderOption[], labelKey: string, valueKey: string, childrenKey: string): Index {
const index: Index = { byKey: new Map(), entries: [] }
const walk = (nodes: CascaderOption[], parentLabels: string[]) => {
for (const node of nodes) {
const labels = [...parentLabels, toText(node[labelKey])]
const entry = { value: node[valueKey], labels }
index.entries.push(entry)
const key = valueKeyOf(entry.value)
if (!index.byKey.has(key)) index.byKey.set(key, entry)
const children = node[childrenKey]
if (Array.isArray(children)) {
walk(children as CascaderOption[], labels)
}
}
}
walk(options, [])
return index
}
parentLabels 在递归时向下传递,每个节点保存的是从根到自己的完整 label 数组 ,而不是单个 label。这样查询时直接 labels.join(separator) 就行。
if (!index.byKey.has(key)) 是为了让重复 value 保留第一次出现的路径。这是一个取舍:重复 value 本身就无法从值反推出唯一路径,保留第一个至少是稳定的。
2. 按值查路径
ts
function lookup(index: Index, value: unknown, separator: string): string | undefined {
const entry = index.byKey.get(valueKeyOf(value)) ?? index.entries.find((item) => isSameValue(item.value, value))
return entry?.labels.join(separator)
}
先走 Map,处理原始类型;对象值走 entries 的线性查找,用 isSameValue 做引用或值比较。这样既保留了快速路径,也不会因为对象值而失效。
3. 四种形态分流
ts
function formatCascader(value: unknown, options: CascaderOption[], config: CascaderConfig = {}): string {
const valueKey = config.value ?? 'value'
const labelKey = config.label ?? 'label'
const childrenKey = config.children ?? 'children'
const multiple = config.multiple ?? false
const emitPath = config.emitPath ?? true
const pathSeparator = config.separator ?? '/'
const valueSeparator = ';'
if (!Array.isArray(options) || options.length === 0) return toText(value)
const index = buildIndex(options, labelKey, valueKey, childrenKey)
const formatPath = (path: unknown[]): string => {
let level: CascaderOption[] = options
const labels: string[] = []
for (const segment of path) {
const node = level.find((item) => isSameValue(item[valueKey], segment))
if (!node) break
labels.push(toText(node[labelKey]))
const children = node[childrenKey]
level = Array.isArray(children) ? (children as CascaderOption[]) : []
}
if (labels.length === path.length) return labels.join(pathSeparator)
const last = path[path.length - 1]
return lookup(index, last, pathSeparator) ?? toText(last)
}
if (Array.isArray(value)) {
if (value.some((item) => Array.isArray(item))) {
const paths = value.filter((item): item is unknown[] => Array.isArray(item))
return unique(paths.map((path) => formatPath(path))).join(valueSeparator)
}
if (multiple || !emitPath) {
return unique(value.map((item) => lookup(index, item, pathSeparator) ?? toText(item))).join(valueSeparator)
}
return formatPath(value)
}
return lookup(index, value, pathSeparator) ?? toText(value)
}
四个分支对应前面那张表的四行:
- 数组里还有数组:多选 + 路径,每条路径单独格式化;
- 数组但没有嵌套,且
multiple或!emitPath:多选 + 叶子,逐个查索引; - 数组、单选、
emitPath: true:单选路径,逐级下钻; - 标量:单选叶子,直接查索引。
formatPath 的逐级下钻与回退逻辑单独看更清楚:

4. 逐级下钻为什么还要做
formatPath 明明可以直接拿最后一段去查索引,为什么还要从根逐级 find?
因为索引和当前 options 可能不一致。典型场景:
- 选项是异步加载的,值先到、选项后到;
- 用户切换了地区层级,旧值还留在 model 里;
- 同一页面存在两棵树,值的来源不同。
逐级下钻能验证这条路径在当前选项里真实存在。只有每一段都命中,才返回拼接结果;否则退回"按最后一个值查索引",再退回原始值。这样不会显示一条已经不存在的路径。
代价是每次格式化多一次 depth × 宽度 的查找。详情页通常只有少量字段,这个成本可以接受;如果同一棵树要格式化成百上千次,可以直接用索引里的 labels。
5. 去重
ts
function unique(texts: string[]): string[] {
const seen = new Set<string>()
return texts.filter((text) => {
if (seen.has(text)) return false
seen.add(text)
return true
})
}
保留首次出现的顺序,不排序。多选值的顺序通常由用户点击决定,展示时保持原顺序比按选项树排序更符合预期。
把整个方案串起来,就是「一次建索引 + 按值反查 + 形态分流」三步:

实际踩坑
1. emitPath 在 props 里,默认是 true
html
<el-cascader :props="{ emitPath: false }" />
写成根上的 :emit-path="false" 不生效。读取时还要区分"没传"和"传了 false":
ts
function readEmitPath(mapping: unknown): boolean {
if (!mapping || typeof mapping !== 'object') return true
const value = (mapping as Record<string, unknown>).emitPath
return value === undefined ? true : Boolean(value)
}
如果直接用 Boolean(mapping?.emitPath),没传时会得到 false,单选路径被误判成叶子,展示结果从 浙江省/杭州市/西湖区 变成 西湖区。
2. multiple 也在 props 里
同一个对象里还可能有 checkStrictly、lazy、lazyLoad、disabled。读取时只取需要的字段,不要整对象透传。
3. 自定义字段映射
后端返回的树不一定用 value / label / children:
html
<el-cascader :options="options" :props="{ value: 'id', label: 'name', children: 'nodes' }" />
如果格式化函数 hardcode option.value,整棵树都查不到。映射键要同时用于建索引和逐级下钻,不能一处用配置、一处写死。
4. 数字和字符串的 value
后端 JSON 里 1 和 "1" 经常混用。展示层的比较应该宽松一些:
ts
function isSameValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true
if (left === null || right === null || left === undefined || right === undefined) return false
if (typeof left === 'object' || typeof right === 'object') return false
return String(left) === String(right)
}
代价是 1 和 '1' 会被当成同一个选项。如果业务上必须区分,就不能用这个比较函数,索引键也要带上类型。
5. 分隔符有两层
el-cascader 自己的 separator 控制"路径内"的分隔符。Element Plus 2.14.x 的默认值是 " / "(前后带空格),不是 "/";多个选中项之间是另一层,默认用 ;。不要把两者混成一个参数:
ts
const pathSeparator = config.separator ?? '/'
const valueSeparator = ';'
示例里为了输出紧凑用了 '/'。接入真实组件时,要么读取 separator prop,要么显式传入同一个值;否则展示文本和 el-cascader 输入框里的文本会不一致。
否则多选时会看到 浙江省/杭州市;浙江省/宁波市 变成 浙江省;杭州市;浙江省;宁波市,层级完全丢失。
6. 重复 value 是已知限制
如果两个不同分支用了同一个 value,索引只能保留第一个,展示路径可能指向错误分支。解决方式只有三种:
- 要求后端保证 value 全局唯一;
- 索引键改成"完整路径",但
emitPath: false的叶子值本身就没有路径信息,无法解决; - 展示层不解析,直接使用后端返回的 label。
文章里把这条写进边界,比假装它能处理所有情况更可靠。
7. 懒加载时索引不完整
lazy 模式下,未展开的节点 children 是空的,索引里没有深层节点。此时:
- 值能查到顶层或已加载层级,就展示已加载的部分;
- 查不到就回退原始值,不要显示
undefined; - 不要因为"查不到"就触发一次加载,展示组件不应该有副作用。
8. checkStrictly 允许选父节点
路径可能停在 ['zj', 'hz'],最后一项不是叶子。逐级下钻的判断是 labels.length === path.length,不依赖"最后一项有没有 children",所以这种情况天然支持。
9. 对象 value 的索引键
valueKeyOf 用 JSON.stringify 给对象生成键:
ts
function valueKeyOf(value: unknown): string {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'object') {
try {
return `object:${JSON.stringify(value)}`
} catch {
return `object:${String(value)}`
}
}
return `value:${String(value)}`
}
对象里有循环引用时 JSON.stringify 会抛错,所以要 try/catch 回退到 String(value)。另外,属性顺序不同但内容相同的对象会得到不同的键,最终靠 entries 的 isSameValue 兜底。
10. 选项更新与缓存
每次格式化都重建索引是 O(n)。如果选项树很大,可以按 options 的引用缓存:
ts
const cache = new WeakMap<object, Index>()
但要注意:原地修改 options(options.push(...)、改某个节点的 label)引用不变,缓存不会失效。详情页通常只渲染一次,直接重建更简单;需要缓存的场景,必须同时记录版本号或选项长度。
11. 空数组
[] 会进入数组分支,value.some(Array.isArray) 是 false,multiple || !emitPath 是 true,最终 [].map(...).join(';') 得到空字符串。展示层应该在外面先判空:
ts
if (isEmpty(value)) return emptyText
0 和 false 不是空值,空数组才是。这条规则和普通字段保持一致。
12. SSR 和纯函数
整个格式化过程只依赖传入的 value、options 和配置,没有 DOM、没有全局状态,服务端渲染可以直接调用。不要在函数里读 window 或触发组件更新。
测试
用同一棵树覆盖四种形态:
ts
const options = [
{
value: 'zj',
label: '浙江省',
children: [
{
value: 'hz',
label: '杭州市',
children: [
{ value: 'xh', label: '西湖区' },
{ value: 'yh', label: '余杭区' }
]
},
{ value: 'nb', label: '宁波市' }
]
}
]
describe('formatCascader', () => {
it('formats a single path', () => {
expect(formatCascader(['zj', 'hz', 'xh'], options)).toBe('浙江省/杭州市/西湖区')
})
it('formats an emitPath false leaf with its full path', () => {
expect(formatCascader('xh', options, { emitPath: false })).toBe('浙江省/杭州市/西湖区')
})
it('formats multiple paths and de-duplicates', () => {
expect(
formatCascader(
[
['zj', 'hz', 'xh'],
['zj', 'nb'],
['zj', 'hz', 'xh']
],
options,
{ multiple: true }
)
).toBe('浙江省/杭州市/西湖区;浙江省/宁波市')
})
it('formats multiple leaves in first-seen order', () => {
expect(formatCascader(['yh', 'xh', 'yh'], options, { multiple: true, emitPath: false })).toBe(
'浙江省/杭州市/余杭区;浙江省/杭州市/西湖区'
)
})
it('supports custom field mapping', () => {
const custom = [{ id: 'c1', name: '甲', nodes: [{ id: 'c2', name: '乙' }] }]
expect(formatCascader(['c1', 'c2'], custom, { value: 'id', label: 'name', children: 'nodes' })).toBe('甲/乙')
})
it('falls back when the option is missing', () => {
expect(formatCascader('unknown', options, { emitPath: false })).toBe('unknown')
})
})
断言要落在具体字符串上,而不是只断言"不报错"。这类格式化函数最典型的 bug 就是"有输出,但输出是错的"。
边界与替代方案
| 场景 | 建议 |
|---|---|
| 后端能直接返回 label / 路径 | 优先使用,展示层不解析 |
| 懒加载、超大选项树 | 索引可能不完整,回退到已加载层级或原始值 |
| 多语言 | label 随 locale 变化,展示时按当前语言解析,不要缓存文本 |
| value 可能重复 | 要求后端唯一,或直接存 label 快照 |
| 对象 value | 用引用或内容比较兜底,注意循环引用 |
| 审计、历史版本、快照 | 存 label 路径,不依赖当前选项 |
| 需要图标、Tag、多行富内容 | 用 el-cascader 的 #label 插槽或专用展示组件 |
| 只需要"最后一级名称" | 直接取路径最后一项对应的 label,不必拼完整路径 |
| 选项会被原地修改 | 不要按引用缓存索引,或同时记录版本号 |
总结
el-cascader的 model 有四种形态,展示前先判断multiple和emitPath,不要假设"数组就是多选"或"最后一项就是叶子"。- 用一次 DFS 建索引,索引里保存从根到节点的完整 label 数组;扁平
Map<value, label>会丢失祖先路径,也无法处理重复 value。 - 多选要按首次出现顺序去重;路径分隔符和多值分隔符分开配置;查不到选项时回退原始值,不要显示
undefined。
npm:www.npmjs.com/package/ep-... 验证版本:Vue 3.5.x / Element Plus 2.14.x 完整实现参考:github.com/jktantan/ep...