(六)el-cascader 的展示值为什么不能直接 join:emitPath、路径还原与多选去重

一句话结论: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')
// '西湖区',期望 '浙江省/杭州市/西湖区'

还有三个隐藏问题:

  1. 重复 value 会覆盖。不同分支可能复用同一个 value(比如地区编码、组织 ID),后遍历到的节点会覆盖前面的,展示结果取决于遍历顺序;
  2. 对象 value 不能直接做 Map 键 。{ id: 1 } 每次都是新引用,get 永远不命中;
  3. 无法表达层级。即使把 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,不必拼完整路径
选项会被原地修改 不要按引用缓存索引,或同时记录版本号

总结

  1. el-cascader 的 model 有四种形态,展示前先判断 multiple 和 emitPath,不要假设"数组就是多选"或"最后一项就是叶子"。
  2. 用一次 DFS 建索引,索引里保存从根到节点的完整 label 数组;扁平 Map<value, label> 会丢失祖先路径,也无法处理重复 value。
  3. 多选要按首次出现顺序去重;路径分隔符和多值分隔符分开配置;查不到选项时回退原始值,不要显示 undefined。

npm:www.npmjs.com/package/ep-... 验证版本:Vue 3.5.x / Element Plus 2.14.x 完整实现参考:github.com/jktantan/ep...

相关推荐
用户093671755411 小时前
用 4500 行 TypeScript 复刻了童年方格纸上的海战棋
前端·vue.js
郝学胜-神的一滴3 小时前
游戏设计:原型与开发 19:U3D工位布置与性格说明书
人工智能·算法·unity·unreal engine·游戏策划·游戏设计
行走的陀螺仪3 小时前
Claude Code 从入门到实战(一):安装、登录与第一个Vue项目
前端·javascript·vue.js
hahaha60164 小时前
数字图像时域滤波或者3D降噪算法FPGA实现
算法·计算机视觉·fpga开发
YSL0701244 小时前
一文通关-----树和二叉树
数据结构·算法
青少儿编程课堂4 小时前
蓝桥杯青少组图形化+信息素养+电子学会Scratch考级:校园口算闯关解析
c++·python·算法·bfs·信息学竞赛
computersciencer4 小时前
应用贝叶斯公式:迟到了,那坐的是哪种交通工具呢?
算法·机器学习·概率论
若亦_4 小时前
排序>>交换排序
java·数据结构·算法
dialect_13 小时前
KMP算法
开发语言·c++·算法