《Vue3 组件库工程化实战:BEM 命名规范与 useNamespace 深度解析》
在阅读开源组件库(如 Element Plus、Ant Design Vue)的源码时,你可能会发现一个奇怪的现象:开发者几乎从不手写字符串形式的 CSS 类名(如 class="el-button"),而是使用类似 ns.b()、ns.e('icon') 这样的函数调用。
这是为什么?这背后的 useNamespace 到底是什么魔法?
今天我们就来拆解这个组件库开发中最基础、也是最优雅的BEM 命名管理工具。

1. 痛点:为什么不能直接写字符串?
假设你正在开发一个按钮组件,你可能会这样写:
html
<!-- ❌ 坏味道的代码 -->
<button class="my-button">
<span class="my-button__icon">...</span>
<span class="my-button__text">提交</span>
</button>
这种写法有三个致命问题:
- 难以维护的前缀 :如果有一天老板说:"我们要把品牌升级,所有组件的前缀从
my-改成super-"。你需要在几百个文件中进行全局查找替换,极易出错。 - 拼写错误 :手写
my-butotn__text(手误)是常有的事,CSS 也就是这样失效的。 - 不规范 :团队成员 A 喜欢用
my-btn-text,成员 B 喜欢用my-button_text,导致样式冲突和混乱。
为了解决这些问题,我们需要一套自动生成类名的工具。
2. 预备知识:什么是 BEM 规范?
在看代码前,我们需要先理解它的生成规则:BEM。
- B (Block) 块 :独立的组件实体。例如:
button(按钮)、dialog(弹窗)。 - E (Element) 元素 :组件内部的组成部分。例如:按钮里的文字
text、图标icon。连接符通常是双下划线__。 - M (Modifier) 修饰符 :组件的不同状态或版本。例如:蓝色的按钮
primary、禁用的按钮disabled。连接符通常是双横线--。
公式: 前缀-块__元素--修饰符
例子: my-button__text--large (我的-按钮组件__文字部分--大号版)
3. 核心代码拆解:它是如何工作的?
我们在 packages/utils/src/namespace.ts 中定义的 useNamespace 函数,就是一个类名生产工厂。
我们把代码拆成两部分看:
第一部分:底层的拼接逻辑 _bem
typescript
// 这是一个纯粹的字符串拼接函数
function _bem(namespace, block, blockSuffix, element, modifier) {
// 1. 先拼个头: "my-button"
let cls = `${namespace}-${block}`
// 2. 如果有块后缀(比如 button-group):"my-button-group"
if (blockSuffix) {
cls += `-${blockSuffix}`
}
// 3. 如果有元素(比如 icon):"my-button__icon"
if (element) {
cls += `__${element}`
}
// 4. 如果有修饰符(比如 primary):"my-button--primary"
if (modifier) {
cls += `--${modifier}`
}
return cls
}
第二部分:对外暴露的 useNamespace
为了让开发者用起来更爽,我们使用了闭包。你只需要在组件里告诉它一次"我是谁(block)",它就会返回一系列专门为你服务的简写函数。
typescript
export function useNamespace(block: string) {
// 拿到全局配置的前缀,比如 'my'
const namespace = defaultNamespace
// 生成 Block (块)
// 调用: ns.b() -> "my-button"
const b = (blockSuffix = '') => _bem(namespace, block, blockSuffix, '', '')
// 生成 Element (元素)
// 调用: ns.e('text') -> "my-button__text"
const e = (element?: string) =>
element ? _bem(namespace, block, '', element, '') : ''
// 生成 Modifier (修饰符)
// 调用: ns.m('primary') -> "my-button--primary"
const m = (modifier?: string) =>
modifier ? _bem(namespace, block, '', '', modifier) : ''
// 生成 State (状态) - 这是一个特殊的辅助函数
// 调用: ns.is('disabled') -> "is-disabled"
const is = (name: string, state: boolean | undefined = true) =>
state ? `is-${name}` : ''
return { b, e, m, is, ... }
}
4. 实战演示:在 Vue3 组件中使用
想象一下,你正在写 packages/components/input/src/input.vue。
步骤 1:引入并初始化
typescript
<script setup lang="ts">
import { useNamespace } from '@my-antd-ui/utils'
// 告诉工具:我是 'input' 组件
const ns = useNamespace('input')
</script>
步骤 2:在 Template 中畅快使用
| 你写的代码 (Vue Template) | 渲染结果 (HTML Class) | 说明 |
|---|---|---|
<div :class="ns.b()"> |
class="my-input" |
最外层容器 |
<span :class="ns.e('inner')"> |
class="my-input__inner" |
内部输入框元素 |
<div :class="ns.m('textarea')"> |
class="my-input--textarea" |
文本域变体 |
<div :class="[ns.b(), ns.is('focus')]"> |
class="my-input is-focus" |
聚焦状态 |
5. 对照速查表:传统写法 vs BEM 工具写法
为了方便大家快速上手,这里整理了一份详细的对照表 。
假设我们当前的组件是 Button (即 const ns = useNamespace('button')),前缀是 my。
基础场景
| 场景 | 以前你可能会这么写 (手动挡) | 现在你应该这么写 (自动挡) | 最终生成类名 |
|---|---|---|---|
| 基础组件 (Block) | 'my-button' |
ns.b() |
my-button |
| 内部元素 (Element) | 'my-button__icon' |
ns.e('icon') |
my-button__icon |
| 修饰符 (Modifier) | 'my-button--primary' |
ns.m('primary') |
my-button--primary |
| 状态 (State) | 'is-disabled' |
ns.is('disabled') |
is-disabled |
动态/逻辑场景 (Vue Template 中)
在 Vue 模板中,我们经常需要根据变量动态切换类名,这时候 useNamespace 的优势就更明显了。
场景 1:根据 props 判断状态
-
需求 :如果
props.disabled为true,添加is-disabled类。 -
传统写法 :
html<button :class="{ 'is-disabled': props.disabled }"> -
BEM 工具写法 :
html<button :class="ns.is('disabled', props.disabled)">优势 :
ns.is第二个参数接受布尔值,自动处理显隐,代码更语义化。
场景 2:根据 props 切换样式变体
-
需求 :根据
type属性(如'primary','success')生成对应的类名。 -
传统写法 :
html<button :class="`my-button--${props.type}`"> -
BEM 工具写法 :
html<button :class="ns.m(props.type)">优势 :不需要自己拼字符串,也不用担心
props.type为空时的处理(工具函数内部已处理)。
场景 3:复杂的组合类名
-
需求 :一个按钮,基础类是
my-button,如果是primary类型则加my-button--primary,如果是禁用则加is-disabled。 -
传统写法 :
html<button :class="[ 'my-button', props.type ? 'my-button--' + props.type : '', props.disabled ? 'is-disabled' : '' ]"> -
BEM 工具写法 :
html<button :class="[ ns.b(), ns.m(props.type), ns.is('disabled', props.disabled) ]">优势:结构清晰,数组里的每一项都代表一个明确的 BEM 逻辑。
高级场景(少见但有用)
| 场景 | 解释 | 写法 | 生成结果 |
|---|---|---|---|
| 块后缀 (Block Suffix) | 用于组件组,如按钮组 | ns.b('group') |
my-button-group |
| 复杂嵌套 (BE) | 带后缀的块 + 元素 | ns.be('header', 'title') |
my-button-header__title |
| 元素修饰符 (EM) | 元素的变体 | ns.em('text', 'bold') |
my-button__text--bold |
6. 进阶答疑:多层嵌套怎么写?(父+子+孙)
很多刚接触 BEM 的人都会纠结:"如果我的 HTML 嵌套了很多层,难道类名也要一直拼下去吗?"
比如:父元素 card -> 子元素 header -> 孙元素 title。
❌ 错误的写法(不要这样做)
不要试图还原 DOM 树的层级结构:
my-cardmy-card__headermy-card__header__title(❌ 这种写法太长了,权重也难计算)
✅ 正确的 BEM 写法(扁平化)
BEM 的核心原则是扁平化。无论嵌套多深,所有内部元素都直接归属于最外层的 Block。
- 父:
ns.b()->my-card - 子:
ns.e('header')->my-card__header - 孙:
ns.e('title')->my-card__title(✅ 它是 card 的 title,而不是 header 的 title)
代码示例
typescript
<script setup>
const ns = useNamespace('card')
</script>
<template>
<!-- 父元素 -->
<div :class="ns.b()">
<!-- 子元素 -->
<div :class="ns.e('header')">
<!-- 孙元素:依然使用 ns.e(),保持扁平 -->
<h2 :class="ns.e('title')">卡片标题</h2>
</div>
</div>
</template>
特殊情况:必须体现层级怎么办?
如果你觉得"孙元素"必须要在逻辑上属于"子元素"(例如 header 本身已经很复杂了),有两种解法:
-
使用
be(Block + Element) :
ns.be('header', 'title')-> 生成my-card-header__title -
拆分组件(推荐) :
将
Header部分拆成一个独立的子组件,在子组件里重新定义ns = useNamespace('card-header')。
总结速查:嵌套层级写法
| 层级 | 术语 | useNamespace 写法 |
最终生成类名 |
|---|---|---|---|
| 第一层 (父) | Block | ns.b() |
my-card |
| 第二层 (子) | Element | ns.e('header') |
my-card__header |
| 第三层 (孙) | Element (扁平) | ns.e('title') |
my-card__title |
| 第三层 (进阶) | Block-Element | ns.be('header', 'title') |
my-card-header__title |
7. 总结
使用 useNamespace 看起来像是多写了几行代码,但它带来了长期的巨大收益:
- 一键换肤 :修改
defaultNamespace = 'ant',整个组件库瞬间变身ant-button,ant-input。 - 杜绝手误:函数调用比手写字符串安全得多。
- 心智负担低:你不需要思考"这里应该用下划线还是中划线",工具替你统一了标准。
这就是组件库工程化的魅力:用工具约束习惯,用规范换取自由。
🔗 参考资料
- BEM 官方方法论 : en.bem.info (命名规范的起源)
- Element Plus 源码 : packages/hooks/use-namespace (工业级 BEM 函数实现参考)