写这篇文章的主要原因是我在实习遇到这些问题:为什么
import './index.scss'就能让样式生效?为什么组件里要写scoped?:deep()到底在穿透什么?assets里的资源会不会打包?Element Plus 的样式为什么能被我覆盖?Tailwind 和原子类是不是内联样式?这篇文章尝试把这些问题归纳串成一条完整链路,从浏览器如何理解 CSS,到 Vite 如何处理样式,再到 Vue Scoped CSS 的实现原理,最后落到真实项目里的样式组织和组件库覆盖实践。
一、先建立一个核心模型
前端页面最终运行在浏览器里。浏览器真正认识的是:
txt
HTML
CSS
JavaScript
静态资源,比如图片、字体、视频
而我们在 Vue 项目里写的这些东西:
txt
.vue
.scss
TypeScript
别名路径 @/assets/xxx
组件库 import
都需要经过构建工具处理,最终变成浏览器能理解的 HTML、CSS、JS 和静态资源。
以 Vite + Vue 项目为例,入口通常是:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />(字符编码)
<meta name="viewport" content="width=device-width, initial-scale=1.0" />(视口view)
<meta name="description" content="页面描述">(seo)
<title>前端应用</title>(页面标题)
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
这里的 #app 是 Vue 应用的挂载点:
ts
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
Vue 页面、组件、路由视图,绝大多数 DOM 都会渲染到:
txt
body -> #app -> Vue 渲染出来的内容
但 CSS 不是渲染到 #app 里的。开发环境里,Vite 通常会把 CSS 注入到 <head> 里的 <style> 标签中;生产环境里,CSS 通常会被抽成 .css 文件,再通过 <link rel="stylesheet"> 放到 <head>。
所以可以先记住这个模型:
txt
Vue DOM 在 body 的 #app 里
CSS 样式表在 head 里
浏览器用 head 中的 CSS 选择器匹配整个 document 中的 DOM
CSS 不需要放在某个元素旁边才生效。只要样式表存在于当前 document 中,浏览器就会根据选择器去匹配页面上的 DOM。
二、CSS 和 SCSS 的区别
CSS 是浏览器原生支持的样式语言:
css
.card {
padding: 16px;
color: #333;
}
.card .title {
font-size: 16px;
font-weight: 700;
}
SCSS 是 CSS 的增强版。浏览器不直接执行 SCSS,构建时会先把 SCSS 编译成 CSS。
scss
$primary-color: #053dc8;
$text-color: #333;
.card {
padding: 16px;
color: $text-color;
.title {
font-size: 16px;
font-weight: 700;
&:hover {
color: $primary-color;
}
}
}
编译后变成:
css
.card {
padding: 16px;
color: #333;
}
.card .title {
font-size: 16px;
font-weight: 700;
}
.card .title:hover {
color: #053dc8;
}
SCSS 的价值主要体现在工程化维护上:
txt
变量
嵌套
mixin
函数
map
循环
拆分文件
批量生成工具类
尤其在中后台项目、组件库项目、设计系统项目里,SCSS 很常见。
SCSS 变量和 CSS 变量不是一回事
SCSS 变量是编译期变量:
scss
$primary: #053dc8;
.title {
color: $primary;
}
编译后:
css
.title {
color: #053dc8;
}
浏览器不知道 $primary 存在。
CSS 变量是运行时变量:
css
:root {
--primary: #053dc8;
}
.title {
color: var(--primary);
}
浏览器知道 --primary,所以它可以被运行时修改:
js
document.documentElement.style.setProperty('--primary', '#1677ff')
对比一下:
| 类型 | 阶段 | 浏览器是否知道 | 能否运行时修改 |
|---|---|---|---|
SCSS 变量 $primary |
构建编译时 | 否 | 否 |
CSS 变量 --primary |
浏览器运行时 | 是 | 是 |
所以现代组件库经常同时使用两者:
txt
SCSS 负责生成 CSS
CSS 变量负责主题和运行时可配置能力
三、为什么 import './index.scss' 就能生效
在 Vue/Vite 项目里,经常会看到:
ts
import '@/assets/styles/index.scss'
这不是 Vue 运行时的能力,而是 Vite 的构建能力。
这是一种"副作用导入":
ts
import '@/assets/styles/index.scss'
意思不是从 SCSS 文件里拿一个变量,而是告诉构建工具:
txt
请加载这份样式,让它参与当前应用构建
开发环境中,Vite 大致会做这些事:
txt
解析 import 的 .scss 文件
调用 Sass 编译 SCSS 为 CSS
处理 @import、@use、url()、路径别名
把 CSS 包装成 JS 模块
浏览器执行模块后创建 style 标签
把 style 标签插入 document.head
概念上页面会变成:
html
<head>
<style data-vite-dev-id="/src/assets/styles/index.scss">
.text-primary {
color: var(--text-color-primary);
}
</style>
</head>
<body>
<div id="app">
<!-- Vue 页面 DOM -->
</div>
</body>
生产构建时,Vite 通常会把 CSS 抽成文件:
txt
dist/assets/index-xxxx.css
然后在 HTML 中通过 <link> 引入:
html
<link rel="stylesheet" href="./assets/index-xxxx.css">
所以结论是:
txt
普通 .scss import 是 Vite 处理的
.vue 里的 style 是 @vitejs/plugin-vue + @vue/compiler-sfc 处理的
浏览器最终只看到 CSS
四、src/assets 和 public 的区别
很多人会误以为 assets 是静态资源目录,所以不会打包。这个理解不准确。
在 Vite 项目里:
txt
src/assets 里的资源会被构建工具处理
public 里的资源会被原样复制
比如:
txt
src/assets/images/logo.png
如果你这样用:
vue
<img src="@/assets/images/logo.png" />
或者:
scss
.logo {
background-image: url('@/assets/images/logo.png');
}
Vite 会处理它:
txt
解析路径
检查文件是否存在
计算 hash
复制到 dist/assets
替换引用路径
小资源可能内联成 base64
最终可能变成:
txt
dist/assets/logo-Dk39a8f2.png
而 public 目录里的资源通常原样复制:
txt
public/favicon.ico -> dist/favicon.ico
public/vendor/editor/editor.js -> dist/vendor/editor/editor.js
引用时一般用绝对路径:
html
<link rel="icon" href="/favicon.ico">
总结:
| 目录 | 是否经过构建处理 | 是否 hash | 引用方式 | 适合放什么 |
|---|---|---|---|---|
src/assets |
是 | 通常是 | import、@/assets/...、CSS url() |
项目图片、字体、SCSS、参与构建的资源 |
public |
否,原样复制 | 否 | /xxx |
favicon、第三方静态包、固定路径资源 |
src/assets/styles 只是一个目录约定,没有框架魔法。真正让它生效的是你在入口文件里导入了它:
ts
import '@/assets/styles/index.scss'
五、全局样式和组件样式应该怎么分
前端项目里通常同时存在两类样式:
txt
全局样式
组件局部样式
全局样式一般放在:
txt
src/assets/styles/index.scss
src/assets/styles/reset.scss
src/assets/styles/atom.scss
src/assets/styles/common.scss
它适合放:
txt
reset
normalize
CSS 变量
主题变量
字体定义
原子类
公共布局类
组件库全局覆盖
比如:
scss
:root {
--color-primary: #053dc8;
--text-color-primary: #333;
}
.text-primary {
color: var(--text-color-primary);
}
.mt-12 {
margin-top: 12px;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
组件局部样式一般写在 .vue 文件里:
vue
<template>
<div class="order-page">
<div class="order-page__title">订单列表</div>
</div>
</template>
<style scoped lang="scss">
.order-page {
padding: 24px;
&__title {
font-size: 20px;
font-weight: 700;
}
}
</style>
一个比较稳的规则是:
txt
所有页面都要用的样式,放全局
只有当前组件需要的样式,放组件 scoped style
不要轻易在页面组件里写无 scoped 的:
vue
<style>
.title {
color: red;
}
</style>
因为这会变成全局 .title,很容易污染其他页面。
六、原子类、Tailwind 和"内联样式"的关系
很多项目会有类似这样的类:
html
<div class="text-primary mt-12 flex-between"></div>
它们看起来很像 Tailwind,但不一定是 Tailwind。很多公司会用 SCSS 自己生成一套原子类。
例如:
scss
$space-list: 0, 4, 8, 12, 16, 20, 24;
@each $space in $space-list {
.mt-#{$space} {
margin-top: #{$space}px;
}
.mb-#{$space} {
margin-bottom: #{$space}px;
}
}
会生成:
css
.mt-0 {
margin-top: 0px;
}
.mt-12 {
margin-top: 12px;
}
.mb-12 {
margin-bottom: 12px;
}
文字类也可以这样生成:
scss
$fontSizeList: 12, 14, 16, 18, none;
$fontColorList: primary, sub, tip, theme;
$colorMap: (
primary: var(--text-color-primary),
sub: var(--text-color-regular),
tip: var(--text-color-secondary),
theme: var(--color-primary),
);
@each $fontSize in $fontSizeList {
$baseName: text;
@if $fontSize != none {
$baseName: text + '-' + $fontSize;
.#{$baseName} {
font-size: #{$fontSize}px;
line-height: 1.5;
}
} @else {
.text {
line-height: 1.5;
}
}
@each $fontColor in $fontColorList {
.#{$baseName}-#{$fontColor} {
@extend .#{$baseName};
color: map-get($colorMap, $fontColor);
}
}
}
最终可以使用:
html
<div class="text-16-primary">16px 主文字色</div>
<div class="text-14-tip">14px 提示色</div>
<div class="text-theme">主题色</div>
Tailwind 的写法则更像:
html
<div class="mt-3 flex items-center justify-between text-base font-bold text-gray-900"></div>
它和原子类的共同点是:
txt
样式声明靠近 markup
用小粒度 class 组合 UI
减少为样式起名字的成本
所以有人会说 Tailwind/原子类很像内联样式。这个直觉有道理。
但它和传统 style="" 有几个关键区别:
| 对比项 | inline style | 原子类 / Tailwind |
|---|---|---|
| 写在标签上 | 是 | 是,写在 class 上 |
| 样式来源 | style="" |
CSS class |
| 是否受设计 token 约束 | 通常不受 | 通常受 |
| hover/focus | 不方便 | 支持 |
| 响应式 | 不方便 | 支持 |
| 优先级 | 很高 | 普通 class |
| 主题化 | 麻烦 | 更自然 |
更准确地说,原子类和 Tailwind 是:
txt
受设计系统约束的、class 形式的、接近内联体验的样式写法
它不是传统 inline style,但确实把一部分视觉声明放回了模板里。
七、Vue Scoped CSS 是为了解决什么问题
CSS 默认是全局的。
如果 A 组件写:
vue
<style>
.title {
color: red;
}
</style>
B 组件也有:
html
<div class="title">B 标题</div>
那么 B 的标题也可能变红。
Vue 提供了 scoped:
vue
<style scoped>
.title {
color: red;
}
</style>
它的目标是:
txt
让当前组件的样式只作用于当前组件
编译后,Vue 会为组件生成一个作用域 ID:
txt
data-v-abc123
模板 DOM 会变成:
html
<div class="title" data-v-abc123>标题</div>
CSS 会变成:
css
.title[data-v-abc123] {
color: red;
}
这样它就只匹配当前组件里的 .title。
另一个组件的 DOM 可能是:
html
<div class="title" data-v-def456>标题</div>
它没有 data-v-abc123,所以不会被影响。
所以 scoped 的核心不是控制 CSS 是否加载,而是控制 CSS 加载后能匹配哪些 DOM。
八、Vue scoped hash 是怎么实现的
.vue 文件不是浏览器原生文件,它会经过编译。
在 Vite + Vue 项目里,主要由:
txt
@vitejs/plugin-vue
@vue/compiler-sfc
处理。
一个 Vue SFC:
vue
<template>
<div class="card">
<h2 class="title">标题</h2>
</div>
</template>
<style scoped>
.title {
color: red;
}
</style>
编译过程大概是:
txt
解析 .vue 文件
拆出 template / script / style
发现 style 上有 scoped
为组件生成 scopeId,比如 data-v-7ba5bd90
编译 CSS:.title -> .title[data-v-7ba5bd90]
编译 template/render:让组件渲染出的 DOM 带 data-v-7ba5bd90
概念上的编译结果:
css
.title[data-v-7ba5bd90] {
color: red;
}
DOM:
html
<div class="card" data-v-7ba5bd90>
<h2 class="title" data-v-7ba5bd90>标题</h2>
</div>
在 Vue 3 里,组件对象上会携带类似:
js
__scopeId: 'data-v-7ba5bd90'
Vue runtime 渲染真实 DOM 时,会把这个 scopeId 设置到元素上。
注意,这个 data-v-xxx 不是 source map。它是样式作用域标识。
全局样式不会有这个 hash:
scss
// src/assets/styles/index.scss
.text-primary {
color: #333;
}
会编译成:
css
.text-primary {
color: #333;
}
因为它不是某个 Vue 文件的 <style scoped>。
九、为什么路由懒加载仍然可能样式污染
我当时也疑惑:Vue 页面不是按需加载吗?为什么 A 页面的 CSS 会影响 B 页面?
因为:
txt
按需加载控制的是资源什么时候进入页面
CSS 一旦进入页面,如果不是 scoped,就是全局规则
SPA 页面切换不会刷新浏览器
旧页面的全局 CSS 通常不会因为组件卸载就自动删除
假设 A 页面:
vue
<style>
.title {
color: red;
}
</style>
B 页面:
vue
<template>
<div class="title">B 标题</div>
</template>
如果用户先访问 A,再访问 B,A 的 CSS 可能已经进入页面:
css
.title {
color: red;
}
B 的 .title 就可能被影响。
更麻烦的是,这类问题可能和访问顺序有关:
txt
直接刷新进入 B:正常
先进入 A 再进入 B:异常
这就是未 scoped 的页面级样式污染。
scoped 解决的是这个问题:
css
.title[data-v-a111] {
color: red;
}
即使 A 的 CSS 留着,它也匹配不到 B 的 DOM。
十、页面切换后 CSS 会不会越堆越多
某种程度上,会。
在 SPA 中,访问过的异步页面 CSS 通常会被加载进页面,并且不会因为路由离开就立刻卸载。
但这通常不是严重问题:
txt
同一个 CSS chunk 通常只加载一次
CSS 体积一般比 JS 和图片小
浏览器会缓存资源
生产构建可能抽公共 CSS
页面数量有限
构建工具通常不积极卸载 CSS,原因包括:
txt
CSS 可能被多个页面共享
组件可能 keep-alive
弹窗可能 teleport 到 body
删除 CSS 再加载可能导致闪烁
生产环境 CSS 可能被合并
判断某条 CSS 是否还被使用成本很高
所以工程上的常规策略不是"离开页面删除 CSS",而是:
txt
局部样式 scoped
公共样式全局
组件库覆盖要有明确范围
减少低质量全局选择器
十一、父子组件中的 scoped 规则
父组件的 scoped 样式默认能影响:
txt
父组件自己的 DOM
子组件的根节点
但不能深入影响:
txt
子组件内部深层 DOM
组件库内部结构
举个例子。
父组件:
vue
<template>
<div class="parent">
<Child />
</div>
</template>
<style scoped>
.child-title {
color: red;
}
</style>
子组件:
vue
<template>
<div class="child-root">
<div class="child-title">子组件标题</div>
</div>
</template>
父组件 CSS 会被编译成:
css
.child-title[data-v-parent] {
color: red;
}
但子组件内部 DOM 是:
html
<div class="child-title" data-v-child>子组件标题</div>
没有 data-v-parent,所以父组件样式匹配不到。
但子组件根节点比较特殊。父组件给子组件加 class:
vue
<Child class="child-from-parent" />
渲染出来的子组件根节点可能是:
html
<div class="child-root child-from-parent" data-v-parent data-v-child>
...
</div>
它同时带父组件和子组件的 scopeId,所以父组件 scoped 样式可以控制子组件根节点布局。
这是合理的,因为父组件经常需要控制子组件外部布局:
txt
margin
width
display
align-self
但父组件默认不应该随意修改子组件内部结构。
十二、:deep() 到底是什么
:deep() 的本质是:
txt
在 scoped CSS 中,让 deep 内部的选择器不追加当前组件的 data-v hash
它不是 props,不是事件,也不是数据传递。它只是影响 CSS 选择器的编译结果。
普通 scoped:
scss
.parent .child-title {
color: red;
}
可能编译成:
css
.parent[data-v-parent] .child-title[data-v-parent] {
color: red;
}
要求 .child-title 也必须是父组件自己的 DOM。
使用 :deep():
scss
.parent {
:deep(.child-title) {
color: red;
}
}
会编译成类似:
css
.parent[data-v-parent] .child-title {
color: red;
}
注意:
txt
.parent 有 data-v-parent
.child-title 没有 data-v-parent
所以它可以匹配子组件内部的:
html
<div class="child-title" data-v-child>子组件标题</div>
这就是所谓"穿透"。
:deep() 常见场景
覆盖组件库内部 DOM:
vue
<template>
<div class="search-page">
<el-input v-model="keyword" />
</div>
</template>
<style scoped lang="scss">
.search-page {
:deep(.el-input__wrapper) {
border-radius: 8px;
}
}
</style>
css
推荐总是带一个父级范围:
```scss
.search-page {
:deep(.qd-input__wrapper) {
border-radius: 8px;
}
}
少写这种裸穿透:
scss
:deep(.title) {
color: red;
}
因为它会影响当前组件下面所有深层 .title,包括多个子组件、组件库内部、插槽内容。
十三、Element Plus 的样式在哪里
组件库通常自带样式。
以 Element Plus 为例,它的样式通常在:
txt
node_modules/element-plus/theme-chalk
源码 SCSS:
txt
node_modules/element-plus/theme-chalk/src/index.scss
node_modules/element-plus/theme-chalk/src/button.scss
node_modules/element-plus/theme-chalk/src/input.scss
node_modules/element-plus/theme-chalk/src/table.scss
编译后的 CSS:
txt
node_modules/element-plus/theme-chalk/el-button.css
node_modules/element-plus/theme-chalk/el-input.css
node_modules/element-plus/theme-chalk/el-table.css
项目里可能通过全局样式入口引入:
scss
@use 'element-plus/theme-chalk/src/index.scss' as *;
或者在入口 TS 中引入:
ts
import 'element-plus/dist/index.css'
组件库会提供大量类:
css
.el-button {}
.el-button--primary {}
.el-input {}
.el-input__wrapper {}
.el-dialog {}
如果项目改过命名空间,可能不是 el,而是其他前缀,例如:
css
.qd-button {}
.qd-button--primary {}
.qd-input {}
.qd-input__wrapper {}
十四、为什么自己的样式能覆盖组件库
因为最终浏览器只看 CSS。
它不关心这条规则来自:
txt
Element Plus
业务组件库
assets/styles
某个 Vue 文件
浏览器只根据 CSS 层叠规则决定谁生效:
txt
选择器优先级
加载顺序
!important
比如组件库先定义:
css
.el-button {
border-radius: 4px;
}
你后面写:
css
.el-button {
border-radius: 8px;
}
选择器优先级一样,后出现的规则覆盖先出现的规则。
如果组件库选择器更具体:
css
.el-button.el-button--primary {
background: blue;
}
而你只写:
css
.el-button--primary {
background: red;
}
可能覆盖不了,因为你的选择器优先级更低。
你需要写得更具体:
css
.el-button.el-button--primary {
background: red;
}
或者在页面范围下覆盖:
scss
.order-page {
:deep(.el-button.el-button--primary) {
background: red;
}
}
组件库覆盖一般有三种方式。
1. 覆盖 CSS 变量
现代组件库大量使用 CSS 变量:
css
:root {
--el-color-primary: #409eff;
}
.el-button--primary {
background-color: var(--el-color-primary);
}
你可以改变量:
css
:root {
--el-color-primary: #1677ff;
}
这种方式最干净,适合主题级调整。
2. 全局覆盖组件库 class
适合统一规范:
scss
.el-button {
border-radius: 6px;
}
但要谨慎,因为它影响全项目。
3. 局部 deep 覆盖
适合某个页面特殊样式:
vue
<style scoped lang="scss">
.order-page {
:deep(.el-input__wrapper) {
height: 36px;
}
}
</style>
十五、常见样式不生效原因
1. CSS 没有被导入
比如你写了:
txt
src/assets/styles/custom.scss
但没有任何地方 import 它:
ts
import '@/assets/styles/custom.scss'
那它不会自动生效。
2. scoped 导致匹配不到子组件内部 DOM
错误:
vue
<style scoped>
.el-input__wrapper {
border-radius: 8px;
}
</style>
可能编译成:
css
.el-input__wrapper[data-v-xxx] {
border-radius: 8px;
}
组件库内部 DOM 没有 data-v-xxx,所以不生效。
正确:
scss
:deep(.el-input__wrapper) {
border-radius: 8px;
}
3. 选择器优先级不够
组件库:
css
.el-form-item.is-error .el-input__wrapper {
box-shadow: 0 0 0 1px red inset;
}
你写:
css
.el-input__wrapper {
box-shadow: none;
}
可能压不过。
4. 加载顺序靠前
如果你的样式先加载,组件库样式后加载,同优先级下会被组件库覆盖。
5. 被 CSS 变量间接控制
你以为要改:
css
.el-button--primary {
background: red;
}
但实际组件使用的是:
css
background: var(--el-button-bg-color);
这时可能应该改变量。
6. 对方用了 !important
如果已有规则是:
css
.some-class {
color: blue !important;
}
普通规则覆盖不了。
不要滥用 !important,但遇到老项目或第三方库时要知道它的影响。
十六、CSS 优先级简单复习
CSS 决定谁生效,大概看:
txt
!important
内联 style
ID 选择器
class / 属性 / 伪类
标签 / 伪元素
出现顺序
例如:
css
.button {
color: red;
}
.page .button {
color: blue;
}
第二个更具体,所以 blue 生效。
同优先级时:
css
.button {
color: red;
}
.button {
color: blue;
}
后面的 blue 生效。
Vue scoped 本质上会增加属性选择器:
css
.title[data-v-xxx] {}
它比普通 .title 优先级略高,因为多了一个属性选择器。
这也是 scoped 样式有时比普通全局类更容易覆盖的原因之一。
十七、推荐的样式组织实践
1. 全局样式只放真正全局的东西
适合全局:
txt
reset
主题变量
字体
原子类
公共布局
组件库统一覆盖
不适合全局:
txt
.title
.content
.card
.item
.header
这些名字太通用,很容易污染。
2. 页面样式默认 scoped
推荐:
vue
<style scoped lang="scss">
.invoice-page {
&__header {}
&__title {}
&__content {}
}
</style>
3. 使用页面根类做范围限制
不要裸写:
scss
:deep(.el-input__wrapper) {
height: 40px;
}
更推荐:
scss
.invoice-page {
:deep(.el-input__wrapper) {
height: 40px;
}
}
4. 优先用变量改主题
主题色、文字色、边框色,优先考虑 CSS 变量:
css
:root {
--el-color-primary: #1677ff;
}
而不是到处覆盖内部 class。
5. 少用 !important
!important 很容易让后续维护变成军备竞赛:
css
.a {
color: red !important;
}
.b {
color: blue !important;
}
能靠加载顺序、选择器、变量解决,就不要上来 !important。
6. 覆盖组件库前先看真实 DOM
组件库的模板不是你写的标签本身。
比如:
vue
<el-input />
真实 DOM 可能是:
html
<div class="el-input">
<div class="el-input__wrapper">
<input class="el-input__inner" />
</div>
</div>
十八、一句话串起全文
前端样式工程的核心不是"写在哪里都行",而是要知道样式最终如何进入浏览器、如何匹配 DOM、如何被编译工具改写、如何被组件库和框架共同影响。
你可以用这条链路理解大多数问题:
txt
源码中的 .vue / .scss
-> Vite / Vue compiler 编译
-> CSS 被注入 head 或抽成 css 文件
-> Vue DOM 渲染到 #app
-> 浏览器根据 CSS 选择器匹配 DOM
-> scoped 通过 data-v-hash 限制匹配范围
-> :deep() 改变 scoped 选择器编译结果,让它穿透子组件内部
-> 组件库样式通过 CSS 层叠规则被覆盖
掌握这条链路后,再遇到这些问题时就不会只靠猜:
txt
为什么我的样式不生效?
为什么组件库样式被覆盖了?
为什么 A 页面影响了 B 页面?
为什么 scoped 里改不了 el-input?
为什么 :deep() 有时有效有时无效?
为什么 assets 里的资源构建后变了名字?
CSS 不是"写几行颜色和间距"那么简单。到了工程规模,它其实是在和构建工具、组件模型、浏览器层叠规则、设计系统一起工作。理解这些机制,才是真正能稳住复杂前端项目样式的关键。
