给 JSX 组件也来一份 Vue 式 scoped:jsx-scoped 让样式隔离不再靠自觉
如果你在 React / Solid 这类 JSX 项目里写过一阵子样式,多半经历过这样的时刻:
- 给一个列表项起了个朴素的类名
.card,结果发现别的页面也有个.card,两边样式互相打架,改一个崩一个; - 于是开始给类名加前缀、套命名空间、上 BEM……类名越来越长,沟通成本越来越高,只为了"别撞车";
- 或者干脆 CSS-in-JS,运行时开销和工具链改造成本又让人犹豫。
用 Vue 的同学这时候会轻描淡写一句:加个 scoped 不就行了。
说实话,这句话曾经挺让人羡慕的。直到我遇到 jsx-scoped——一个把 Vue 那种"scoped 样式"原样搬进 JSX/TSX 世界的编译期工具链。这篇文章不聊虚的,就从一个真实的四张卡片案例出发,讲清楚它做了什么、为什么是这个效果、以及你怎么用起来。
它是什么:三件套,纯编译期
jsx-scoped 是一个 pnpm monorepo,拆成三个小包,各管一段:
| 包 | 干什么 | 时机 |
|---|---|---|
@10coding/plugin-jsx-scoped | Babel 插件:给 JSX 里的 DOM 注入 data-v-{hash} 属性 | 编译期 |
@10coding/postcss-jsx-scoped | PostCSS 插件:给 CSS 选择器追加 [data-v-{hash}] | 编译期 |
@10coding/vite-plugin-jsx-scoped | Vite 插件:把 .scoped.css / .scoped.scss 等编排成虚拟 CSS 模块 | 打包时 |
核心思想一句话:组件文件里出现的类名,只在这个文件里算数。实现上就是两件事:JSX 侧给"自己人"盖个戳(data-v-{hash}),CSS 侧给"自己的规则"加把锁(选择器带上同一个 hash),两边都只认同一个 hash——而这个 hash,由组件文件的绝对路径算出来。同一个文件里不管写几个 scoped 样式文件、内联多少段 scoped 样式,hash 都是同一把,天然对齐;换一个文件,hash 就变,谁也污染不了谁。
它没有运行时、没有 Shadow DOM、没有 class 前缀,全靠编译期改写,产物体积几乎零增加,React / Solid / 任何会产出 JSX 的工具都能用。
一个真实案例:四张同名 .card
光说抽象,不如看一个我在文档站里放的真实 demo。下面这个组件渲染了四张类名完全相同的卡片,全都是 class="card",但它们的命运截然不同:
1. 结构直接写在父组件里 → 命中父组件自己的 scoped 样式
2. 子组件 ChildCard(同名 .card) → 命中子组件自己的 scoped 样式
3. 子组件 InheritCard(同名 .card)→ 主动继承父组件的样式
4. 子组件 GlobalCard(同名 .card) → 谁的 scoped 都不吃,只吃全局样式
先别急着往下看,想想看:如果这四张卡在同一个页面里、类名一模一样,你凭什么让它们长得不一样?答案就是文章开头说的那个 hash。
卡片 1 和卡片 2:同名,但各过各的
卡片 1 的结构写在父组件里,编译后它的 DOM 上是这样的:
<div class="card" data-v-47e7c7d3="">
<p class="card__name">父组件内直写 card</p>
...
</div>
父组件的样式文件 demo.scoped.scss 里写的 .card,编译后被改写成了:
.card[data-v-47e7c7d3] { /* ... */ }
注意看:规则带锁,DOM 带戳,锁和戳是同一把钥匙。所以父组件的 .card 规则只会命中带着 data-v-47e7c7d3 的元素——也就是父组件自己写的那张卡。
卡片 2 是子组件 ChildCard,它自己也 import 了一份 scoped 样式。子组件的文件路径不同,hash 就不同(比如 data-v-6405a751)。于是:
/* child-card.scoped.css,编译后 */
.card[data-v-6405a751] { /* 子组件的样式 */ }
父组件的规则带的是 47e7c7d3 的锁,子组件的 DOM 带的是 6405a751 的戳——两把钥匙对不上,谁也不碰谁。这就是"同名类互不污染"的全部秘密:不是靠改名,而是靠作用域。
卡片 3:想继承?自己认领
卡片 3 有个特殊需求:它想长得跟父组件一样,也就是让父组件的 scoped 样式也能命中自己。
父组件在渲染大写子组件时,会顺手把一个 scopedId(形如 data-v-47e7c7d3)作为 prop 传进去——注意,传是传了,但用不用由子组件决定。子组件如果把这个 scopedId 绑到自己 DOM 上,就等于主动"认领"了父组件的身份,父样式的锁就能打开了:
export default function InheritCard({ scopedId }: { scopedId?: string }) {
const inherit = scopedId ? { [scopedId]: '' } : {}
return (
<div className="card" {...inherit}>
<p className="card__name" {...inherit}>继承父组件样式的卡片</p>
...
</div>
)
}
这里有个值得说的小坑:继承要逐层认领。父组件的规则 .card__name[data-v-47e7c7d3] 要命中文字那层,那层的 <p> 也得带上同一个属性。只绑根元素的话,父样式里针对内层元素的选择器就落空了——只有根元素继承到父样式。jsx-scoped 刻意不帮你"自动继承",因为自动注入意味着父组件样式可能悄悄漏进子组件内部,破坏隔离的默认安全。想要什么,自己声明,这个取舍我认为是合理的。
卡片 4:什么都不带,就是全局样式
卡片 4 最诚实:它没有自己的 scoped 文件,也不认领父组件身份;那份不带 scoped 后缀的 global.css 是由父组件顺手 import 的——这份样式不经过改写,选择器原样输出、全站生效。父组件于是同时拿着 scoped(demo.scoped.scss)与全局(global.css)两份样式,而这张卡没有更高特异性的规则压着,所以被全局样式染上了颜色。
而更有意思的是:这份全局规则其实同时命中了前面三张卡(它没有 [data-v-*],匹配所有 .card),但它们纹丝不动。为什么?因为 .card[data-v-xxx] 的选择器特异性比 .card 高,scoped 规则把全局规则"压"下去了。这就是隔离生效时肉眼可见的效果:全局想污染,污染不动;而不带 scope 的那张卡,则老老实实展示着全局样式的样子。
为什么是"这个效果":说到底就两点
把上面的案例拆开,原理其实就两条,记住它们,你就能预测任何 scoped 行为:
- JSX 侧:只给"自己文件里的 DOM"盖戳。 同一个文件里写的标签、展开的组件内部 DOM,编译后带上本文件的
data-v-{hash}。hash 由文件绝对路径生成,换台机器、换个项目,只要路径不变,产物稳定。 - CSS 侧:只给"自己文件里的规则"加锁。
.scoped.css/.scoped.scss/.scoped.sass/.scoped.less以及组件内的<style scoped>,选择器统一追加[data-v-{hash}];普通 CSS 完全不碰。
锁和戳对上了,样式生效;对不上,规则就只是一段"写了等于没写"的代码。没有魔法,没有猜测,这也是它调试起来特别舒服的原因——你在元素面板里看到 data-v-47e7c7d3 这个属性,去样式文件里搜同样的 hash,一眼就知道谁管着谁。
怎么用:三步
第一步,装包:
pnpm add -D @10coding/vite-plugin-jsx-scoped
第二步,在 Vite 配置里注册。⚠️ 注意插件顺序:jsx-scoped 必须在框架插件(如 React)之前处理 JSX 文件——它需要在 JSX 还处于 AST 阶段时注入 data-v-* 属性、改写样式 import;如果框架插件先把 JSX 编译成了 jsx() 调用,jsx-scoped 就找不到 JSX 节点、无从注入了。以 React 项目为例,React 插件放在它后面:
// vite.config.ts
import jsxScopedVitePlugin from '@10coding/vite-plugin-jsx-scoped'
import react from '@vitejs/plugin-react'
export default {
plugins: [
jsxScopedVitePlugin(), // ⚠️ 放在框架插件前面,先拿到 JSX 文件
react(), // 框架插件放后面(插件内部虽已声明 enforce: 'pre',
// 但数组顺序显式前置更直观、也不易在调整时踩坑)
],
}
第三步,把样式文件命名为 *.scoped.css(或 scss/sass/less),在组件里 import,然后在 JSX 里照常写类名:
import './Card.scoped.css' // 文件名带 .scoped. 就表示"这份样式只属于本文件"
export default function Card() {
return (
<div className="card"> {/* 放心叫 .card,不会跟别人的 .card 打架 */}
<p className="card__title">你好,我是卡片</p>
</div>
)
}
就这些。没有 createGlobalStyle,没有 css 模块的 import 对象,写法和平时一模一样,只是文件多了一个 .scoped. 后缀的约定。
什么样的情况适合它
- 组件库 / 中后台项目:多人协作、类名随手起,最怕样式互相污染,scoped 直接把这个隐患关进笼子里;
- 想把 Vue 的 scoped 心智带到 React 的团队:几乎零学习成本,文档站本身就在用,连 Markdown 页面编译出来的 TSX 都能享受页面级 scoped 样式;
- 不想引入运行时开销的场合:纯编译期改写,产物体积、性能都没有额外负担;
- 喜欢"看得见"的隔离:
data-v-{hash}就摆在 DOM 上,调试体验比"类名约定"诚实得多。
当然它也有边界:scoped 不等于"不用想样式了",:global / 深度选择器这类需求仍然要靠其它手段;它的作用域是"文件级",不是"运行实例级"——同一组件被渲染一百次,一百份 DOM 用的是同一份 scoped 样式,这正是 Vue 用户熟悉的行为。
写在最后
说实话,这个插件最打动我的不是某个炫技功能,而是它把一件"本应该很简单"的事情做对了:让我可以继续写朴素的类名,而不必为全局命名空间操心。样式隔离本不该靠程序员的自律和长长的类名前缀来维持。
如果你也受够了类名撞车,欢迎到 GitHub 看看:github.com/ALiuYiLin/j… —— 仓库里带完整的文档、API 参考和本文章同款的四卡 demo,直接跑起来就能看到隔离生效的效果。觉得有用的话点个 Star,遇到问题提个 Issue,都对我帮助很大。
愿你的样式,各回各家,各找各妈。