跳转到内容
关系图谱

word-cloud:确定性布局的标签词云组件

1,836 字 7 分钟

属性 1
series
VaultKit 包指南

词云是标签页与归档页的经典一图流:词频权重一眼可读,还顺手当导航用。但大多数词云组件有个恼人的通病——每次刷新都洗牌,昨天在左上角的标签今天跑到右下角。@vaultkit/vitepress-plugin-word-cloud 用一个种子随机数生成器根治了这个问题:同一份词集,布局永远恒定。本站首页侧栏的「标签」词云就是它,现场即可看到效果。

本文通读这个包的全部导出(WordCloud 组件与 CloudWord 类型),逐个 prop 讲清语义与调法,并展开 mulberry32 种子随机、测量渲染字体同串、SVG 自渲染这些源码注释里的架构决策。

为什么需要它

经典词云算法(Wordle 螺旋碰撞布局)的标准实现是 d3-cloud,但直接用它有三个坑:

  1. 布局非确定——d3-cloud 内部多处消费 Math.random(),初始落点、螺旋方向、词的旋转全是随机的,刷新即洗牌
  2. canvas 渲染失能——多数封装把词画在 canvas 上:点击要自己算命中、暗色主题要整张重画、文本不可选中不可朗读
  3. 字体不同串——布局用 A 字体测量、渲染用 B 字体显示,碰撞检测失真,词与词叠在一起

这个包对三个坑各给了一个明确解法:注入种子随机源、SVG text 自渲染、布局与渲染同取 getComputedStyle(document.body).fontFamily。d3-cloud 只负责词位计算,其余全部自己来。

安装

sh
pnpm add -D @vaultkit/vitepress-plugin-word-cloud

若使用 @vaultkit/vitepress-theme-vault 主题则开箱即带——首页侧栏标签词云已接好,点击词跳标签筛选页。

快速上手

vue
<script setup lang="ts">
import type { CloudWord } from '@vaultkit/vitepress-plugin-word-cloud'
import { WordCloud } from '@vaultkit/vitepress-plugin-word-cloud'
import { useRouter, withBase } from 'vitepress'

const router = useRouter()

// level 为 1-5 加权级,分级口径由宿主决定
const words: CloudWord[] = tags.map(t => ({ text: t.name, level: t.level, count: t.count }))

function goToTag(tag: string) {
  router.go(withBase(`/tags?tag=${encodeURIComponent(tag)}`))
}
</script>

<template>
  <WordCloud
    :words="words"
    :height="200"
    :font-min="13"
    :font-max="26"
    aria-label="标签词云"
    @select="goToTag"
  />
</template>

分级口径由宿主决定是刻意的设计:标签计数的分布千站千面,线性分级、对数分级还是分位数分级,组件不替你猜。一个实用的对数分级(数据 loader 里做):

ts
function levelOf(count: number, maxCount: number): number {
  const ratio = Math.log(count + 1) / Math.log(maxCount + 1)
  return Math.min(5, Math.max(1, Math.round(ratio * 5)))
}

全部导出一览

  • WordCloud——词云组件(同时作为具名导出与默认导出
  • 类型:CloudWord——{ text, level, count? }text 词文本,level 为 1-5 加权级(映射字号与着色层),count 可选计数(只用于 aria 朗读文案,如「Vue (12)」)

组件没有插槽、没有 v-model、没有 defineExpose,API 面就是 props + 一个 select 事件。

Props 逐项详解

words(必填)

  • 类型:CloudWord[]

词集。words 引用变化时自动重排布局(watch),标签页筛选后词集收窄的场景无需手动触发。

selected

  • 类型:string | null
  • 默认值:null

高亮词。匹配的词条常驻品牌色加粗,并带 aria-pressed="true"——典型用法是标签页把当前筛选中的标签传进来,词云同时充当「当前筛选指示器」:

vue
<WordCloud :words="words" :selected="currentTag" @select="toggleTag" />

height

  • 类型:number
  • 默认值:320

画布高(px)。同时经 v-bind(height) 写进容器 min-height——布局是异步的(onMounted 后才有词),占位高度保证 SSR 首帧到词浮现之间页面不跳动。

fontMin / fontMax

  • 类型:number
  • 默认值:16 / 46

1 级与 5 级词的字号(px),中间层级线性插值(fontMin + (level - 1) / 4 × (fontMax - fontMin))。侧栏小词云把两端都压小,全页词云可以放开:

vue
<!-- 侧栏紧凑版 -->
<WordCloud :words="words" :height="200" :font-min="13" :font-max="26" />

<!-- 标签页全宽版 -->
<WordCloud :words="words" :height="400" :font-min="16" :font-max="52" />

注意字号区间直接影响装填密度:词多而画布小时调低 fontMax,否则放不下的词会被 d3-cloud 丢弃。

seed

  • 类型:number
  • 默认值:42

确定性布局种子。同数据 + 同种子 + 同容器宽 = 恒定布局。对当前布局不满意(比如最长的词被竖排了)不用改数据,换个种子重掷一次即可:

vue
<WordCloud :words="words" :seed="7" />

rotateRatio

  • 类型:number
  • 默认值:0.25

竖排词比例(0-1)。每个词消费一次随机数,小于该值则旋转 90°。0 全横排(可读性最好),0.25 是经典词云的错落感,1 全竖排(除非做视觉实验,不建议)。

ariaLabel

  • 类型:string
  • 默认值:'Word cloud'

容器无障碍名。容器是 role="group"(内部词条各自可聚焦,与 canvas 的「一张哑图」形成对比),中文站点记得覆盖:

vue
<WordCloud :words="words" aria-label="标签词云" />

事件

名称载荷说明
selecttext: string点击或键盘(Enter / Space)激活词条时触发

Space 键按下时组件内部 preventDefault,防止页面随空格滚动。跳转、筛选等行为交给宿主。

设计机制

mulberry32:一个 32 位的确定性随机源

洗牌问题的根源在 d3-cloud 源码里有三处消费随机源(index.js 的 L29/66/116,源码注释里留了取证):词的初始落点、螺旋展开方向、旋转判定。好在 d3-cloud 提供了 .random() 注入口——组件把 Math.random 换成 mulberry32:

ts
function mulberry32(seed: number): () => number {
  let state = seed
  return () => {
    state |= 0
    state = (state + 0x6D2B79F5) | 0
    let t = Math.imul(state ^ (state >>> 15), 1 | state)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

这是一个仅 32 位状态的 PRNG:快、小、统计质量对布局场景绰绰有余。每次布局用 props.seed 重建生成器,于是「随机」变成了种子的纯函数——刷新、路由往返、明暗切换,词的位置纹丝不动。

测量与渲染字体必须同串

d3-cloud 的碰撞检测靠 canvas measureText 量词的包围盒。这里有个隐蔽陷阱:CSS 变量(如 var(--vp-font-family-base))在 canvas 字体串里不生效,直接传会静默落回默认字体——量的是衬线体、画的是无衬线体,碰撞盒对不上,词就叠在一起。组件的解法是布局期以 getComputedStyle(document.body).fontFamily已求值的真实字体串传给 d3-cloud,SVG 渲染继承 body 同源字体,测量与渲染严格同串。

SVG 自渲染的红利

d3-cloud 输出的只是每个词的 { x, y, rotate, size },渲染层是 Vue 模板里的 SVG text

  • 点击/键盘事件原生可绑,无需命中计算
  • fill 走 CSS 变量:暗色切换零重渲染,浏览器自己换色
  • 文本可被选中、可被读屏器朗读

着色按权重分层:1-3 级依次取 --vp-c-text-3 / --vp-c-text-2 / --vp-c-text-1,4 级混入 60% 品牌色(color-mix),5 级纯品牌色加粗——权重越高越「亮」,明暗两态自动适应。词浮现时有 0.5s 淡入动画。

高级与边界

Important

dist 内含 .vue 源文件,消费方必须配置,否则 SSR 构建报错:

ts
// .vitepress/config.ts
export default defineConfig({
  vite: { ssr: { noExternal: ['@vaultkit/vitepress-plugin-word-cloud'] } },
})
  • SSR:布局测量依赖 document.createElement('canvas'),只能客户端执行;SSR 输出固定高度占位容器,词组挂载后淡入
  • 自适应:容器 ResizeObserver 去抖 200ms 重布局;宽度不变(如纵向滚动)不会误触发
  • 非 VitePress 宿主:着色依赖 --vp-c-* 语义变量,需自行提供同名变量
  • a11y:词条 role="button" + tabindex="0" + aria-pressed + focus 虚线轮廓;count 提供后朗读文案带计数
  • peer 依赖vue ^3.5.0;运行时依赖仅 d3-cloud

常见问题

Q:有些词不见了? d3-cloud 放不下的词会被丢弃。要么加大 height、要么调低 fontMax、要么减少词数(比如只取 top 40)。

Q:为什么窗口拉伸后布局变了? 布局是「数据 + 种子 + 容器宽」的函数,容器宽变了自然重排。同宽度下永远恒定。

Q:能不能不转竖排?:rotate-ratio="0" 即可全横排。

相关链接