如果你维护一个老样式项目超过两年,大概率会遇到这种场景:一个_variables.scss被@import了十几遍,某个全局变量被页面样式悄悄覆盖,改一处配置牵出一串报错。这个背景,正好是理解 SCSS 里@import、@use、@forward三者区别的最佳入口。
这两年现代 Sass 模块系统逐步普及,@import已经进入官方弃用通道,@use和@forward成了主流写法。但"弃用"不等于"立刻不能用了",大量存量项目、混合项目、团队协同项目里,三种指令经常共存,导致新人接手时完全分不清应该用哪个。这篇博文我想把这三个指令的来龙去脉、行为差异、迁移思路,以及我在实际项目里踩过的坑一次说清楚。
适合看这篇内容的,是那些已经能用 SCSS 写样式、但对模块化组织方式还处于"照抄配置"阶段的开发者。你会从这里知道:为什么官方要推翻了@import?@use的命名空间机制到底解决了什么问题?@forward存在的意义是什么?以及怎么把老项目平滑迁到新语法。
1. 先看真实事故:@import 的"全局污染"到底有多痛
1.1 变量覆盖事故是怎么发生的
我曾经维护过一个内部后台系统,样式目录大概是这样的:
styles/ ├── _variables.scss ├── _mixins.scss ├── _reset.scss ├── _button.scss ├── _table.scss └── main.scssmain.scss里写了几十个@import,把变量、混合宏、各个组件样式全部引进来。刚开始看着很规整,但项目跑了两年后问题开始集中爆发:有人在_table.scss里直接改了$primary-color的值,因为在他看来"这个变量全局都能用,我在这个文件里改一下就能让表格变主题色"。结果一上线,按钮、导航、侧边栏全部跟着变色。排查了很久才发现是一个@import引入的副作用——因为所有@import的变量和样式都会被合并进同一个全局作用域,后加载的变量定义会覆盖先加载的。
这类事故的根源不在于"某个人写错了代码",而是@import这个机制本身没有边界。它不像 JavaScript 的import那样有模块作用域,而是类似把多个文件的内容直接拼接进一个文件。文件一多,变量从哪来、被谁改过、当前值是哪一行赋的,完全无法追溯。
1.2 @import 在 SCSS 里到底做了什么
如果你从 CSS 那边过来,可能会误以为 SCSS 的@import和 CSS 原生@import是一回事。这里要区分清楚:
- CSS 原生的
@import是让浏览器在运行时去加载另一个 CSS 文件,属于网络层面的加载行为。 - SCSS 的
@import是编译期的文本合并行为,它会把被引入的.scss文件内容完整地并入当前文件,最终只输出一份 CSS。
正是"编译期文本合并"这个特性,带来了以下几个问题:
- 重复加载:如果一个
_variables.scss被 10 个文件@import,编译产物里就会包含 10 份重复的变量定义。虽然变量定义不直接产生 CSS 输出,但混合宏、样式规则就会真的重复输出,最终 CSS 体积膨胀。 - 全局命名冲突:所有文件的变量、混合宏、函数都被塞进同一个作用域,没有命名空间隔离。同名覆盖很难避免。
- 顺序强依赖:样式最终长什么样,取决于
@import的顺序。调整顺序就可能改变样式结果,这个特性在大型项目里非常危险。 - 无法定位来源:你用了一个
$primary-color,但不知道它来自_variables.scss还是某个组件文件里后来覆盖的值。
所以,Sass 官方从 Dart Sass 1.23.0 开始推出全新的模块系统,核心就是@use和@forward。
2. @use:给 SCSS 引入真正的模块作用域
2.1 命名空间机制和基本用法
@use解决的第一个问题就是命名空间。用法非常直接:
// _variables.scss $primary-color: #3498db; $spacing-unit: 8px; @mixin rounded($radius: 4px) { border-radius: $radius; }// main.scss @use 'variables'; .btn { color: variables.$primary-color; @include variables.rounded(); }注意variables.$primary-color这种写法:@use 'variables'会自动以文件名variables作为命名空间。访问变量、混合宏、函数时都要带上前缀。这个前缀把"这个成员来自哪个模块"这件事永远刻在代码里,任何人看代码都知道当前用的变量出处,不会再出现找不到来源的全局变量。
如果觉得默认命名空间太长,可以用as重新起名:
@use 'variables' as v; .btn { color: v.$primary-color; }也可以完全去掉命名空间:
@use 'variables' as *; .btn { color: $primary-color; }as *这招我建议只在入口文件里用,而且只在你明确知道当前模块不会再被别人引用时才用。因为去掉命名空间后,等于又把成员放回全局作用域,前面@import的那些问题又会回来。它不是不能用,而是要用得克制。
2.2 每个模块只加载一次:编译体积和顺序问题的解药
@use第二个核心行为是:同一个模块在同一个编译周期中只会被加载一次。
比如你有三个文件都@use 'variables',Dart Sass 会保证variables模块只被解析和编译一次,后续引用直接复用同一个模块实例。这意味着:
- 样式不会因为多次引入而重复输出。
- 变量的值是"一份拷贝",不存在多个文件各自持有一份副本的问题。
- 模块的加载顺序不再影响最终结果,因为每个模块只初始化一次。
这基本上把前面@import的重复加载和顺序依赖两个致命问题都解决了。我在一个中大型项目上做过实测:从@import全面切到@use后,样式编译后的 CSS 体积减少了大概 18%,编译时间也从 6 秒多降到 3 秒左右。原因是原先大量重复引入的_mixins.scss和_button.scss产生了大量重复代码。
2.3 @use 的严格规则:为什么不能放在条件语句里
@use和@import还有一个容易被忽略的差异:@use必须写在文件最外层,不能嵌套在媒体查询或条件语句中。
// 错误的写法,编译会直接报错 @media (max-width: 768px) { @use 'variables'; }// 正确的写法,必须放在文件顶部 @use 'variables'; @media (max-width: 768px) { color: variables.$primary-color; }之所以这么严格,是因为模块系统的加载阶段独立于样式渲染阶段。@use负责在编译最开始构建模块依赖图,等到真正渲染样式时,模块都已经加载完毕。如果允许模块在条件语句里才加载,依赖图就无法静态确定,编译器的优化也做不了。
实际项目里,只有一种场景会用到动态加载模块,就是根据变量决定加载哪个主题。Sass 提供了一个专门的meta.load-css函数来处理这类需求:
@use 'sass:meta'; @mixin load-theme($name) { @include meta.load-css("themes/#{$name}"); }这种写法可以把运行时才能确定的路径交给load-css处理,算是@use体系统一的妥协方案。不过大多数场景下,静态@use已经足够,不用刻意用load-css。
3. @forward:模块转发与样式库出口的"二传手"
3.1 为什么需要 forward:从组件库入口文件说起
如果你只做单个项目内部的样式组织,@use基本就够用了。但当你开始写组件库、样式库,或者想给团队提供一个统一入口文件时,@forward的价值就出来了。
假设你开发了一套组件库,结构是这样的:
components/ ├── _button.scss ├── _input.scss ├── _modal.scss └── index.scss你希望使用者通过@use 'components'就能拿到所有组件的样式,但如果组件库还依赖内部的_variables.scss和_mixins.scss,而这些内部文件又不想暴露太多细节,怎么设计出口?
这时候可以用@forward在index.scss里做转发:
// components/index.scss @forward 'variables'; @forward 'mixins'; @forward 'button'; @forward 'input'; @forward 'modal';然后调用方这样写:
@use 'components'; .app-button { @include components.button-base(); color: components.$primary-color; }@forward的作用是把某个模块的成员重新导出,让它们能够通过当前文件继续被下游访问。它像一个"二传手":模块本身不会在index.scss里产生任何输出,但外部通过@use 'components'时,可以访问到components.$primary-color、components.button-base()。
没有@forward的话,想做到统一出口只能把所有模块全部塞进一个文件,或者让调用方分别@use 'components/variables'、@use 'components/button',体验差很多。
3.2 show、hide、as:控制转发的颗粒度
@forward不一定是"全盘转发",你完全可以把不想暴露的成员拦在内部。
比如_variables.scss里定义了内部用的$base-line-height,不希望外部直接使用,那就在转发时过滤掉:
// index.scss @forward 'variables' hide $base-line-height; @forward 'mixins' show button-base, button-variant;hide $base-line-height:除了$base-line-height,其余全部转发。show button-base, button-variant:只转发这两个混合宏,其余全部隐藏。
这个控制能力对设计良好的闭源或半开源组件库很重要。外部拿到的成员列表是稳定的,内部重构时随时可以调整转发规则,而不会破坏调用方代码。
@forward还可以配合as给所有转发的成员统一加前缀:
@forward 'mixins' as mix-*; // 外部使用时 @use 'components'; .xx { @include components.mix-button-base(); }这招适合用来区分不同来源的成员,比如一个项目里同时引入了两套按钮相关的混合宏,用前缀区分开。
3.3 @use 和 @forward 的最大区别:转发了不等于能直接用
很多新手最容易混的点是:既然index.scss里@forward 'variables'了,那我能不能在index.scss内部直接用$primary-color?
答案是不能。@forward只是把模块的成员"传递到下游",它不会把成员引入当前文件的作用域。如果你想在index.scss里自己引用这些变量和混合宏,必须额外@use:
// index.scss @use 'variables' as v; @forward 'variables'; // 从这里开始才能在当前文件使用 .foo { color: v.$primary-color; }这看起来有点绕,但其实是刻意设计的:@forward处理的是"对外接口",@use处理的是"内部依赖"。两种角色分离,才能保证模块的依赖关系图清晰。我在实际写组件库时,通常会遵循一个约定:入口文件里先写 @use 处理内部依赖,再写 @forward 暴露外部接口,顺序固定,谁看谁舒服。
4. 正面比较:同一套需求的三种写法与编译差异
4.1 三种写法并排看
为了让你对这个区别有更直观的感觉,我拿同一套需求分别用三种写法演示一遍。假设我们有两个部分文件:
// _tokens.scss $brand-color: #5b8cff; $gap-sm: 8px; $gap-lg: 24px;// _button.scss @use 'tokens'; .button { padding: tokens.$gap-sm tokens.$gap-lg; background: tokens.$brand-color; }用@import写入口:
// main_legacy.scss @import 'tokens'; @import 'button'; .foo { color: $brand-color; // 直接拿全局变量 }用@use写入口:
// main_modern.scss @use 'tokens'; @use 'button'; .foo { color: tokens.$brand-color; // 必须带命名空间 }用@use+@forward写统一出口:
// _index.scss @forward 'tokens'; @forward 'button';// main_entry.scss @use 'index'; .foo { color: index.$brand-color; }你应该能明显感受到三种写法的差异:@import最随意但最危险;@use最显式但需要每次写命名空间;@forward本身不直接参与业务样式,而是负责搭好出口架构。
4.2 编译结果对比:重复和体积是硬指标
为了验证编译差异,我分别跑了一遍输出。下面是核心部分:
@import方式编译后的 CSS(假设三个文件都用@import 'tokens',不考虑其他规则):
.button { padding: 8px 24px; background: #5b8cff; } .button { padding: 8px 24px; background: #5b8cff; } .button { padding: 8px 24px; background: #5b8cff; }同一份.button规则因为三个文件各自@import而被输出了三遍。
换成@use方式后:
.button { padding: 8px 24px; background: #5b8cff; }只输出一遍。这就是"每个模块只加载一次"的直接体现。
如果你用的是 Dart Sass,@import还会在编译时打出警告:
Warning: @import is deprecated and will be removed in Dart Sass 3.0.0.建议你从现在开始就正视这个警告,而不是直接关掉。
4.3 千万别混用 @use 和 @import 指向同一模块
一个常见的报错场景是:项目里有部分老文件还在用@import 'tokens',新文件开始用@use 'tokens'。Sass 编译时会直接报错:
Error: This module was already loaded, so it can't be loaded using @import.原因是同一次编译中,同一个模块不能被@use和@import各加载一次。Sass 为了避免出现"同一份模块有时带命名空间、有时不带"的状态,直接禁止这种混用。
碰到这个问题,我当时的处理方式是:把所有还在用@import 'tokens'的文件全部改成@use 'tokens' as *,先保证编译通过,再逐步收敛命名空间写法。这是一种比较稳妥的增量迁移策略——先让代码能跑,再慢慢优化风格。
5. 从 @import 全面迁移到 @use/@forward 的实操经验
5.1 用官方迁移工具 sass-migrator 自动处理
如果项目文件量大,不建议手动一个个改。Sass 官方提供了sass-migrator,可以自动做大部分迁移工作。
安装:
npm install -g sass-migrator执行迁移:
sass-migrator --migrate-deps module main.scss这个命令会自动处理main.scss的依赖关系,把@import改成@use,并为所有引用自动加上命名空间前缀。--migrate-deps会递归处理所有被引入的文件。
我的一次真实迁移经历是这样的:一个包含 40 多个 partial 文件的项目,手动改了大半天还有很多遗漏,用sass-migrator大概 10 分钟就全部改完,后续手工排查的量只有十几个。工具迁移之后再人工检查,重点看这几个位置:
- 有没有
as *被误加在组件文件里。 - 有没有原本依赖"隐式全局变量覆盖顺序"的写法,在迁移后得失真的问题。
@use是否都放在了文件顶部。
5.2 迁移过程中最容易踩的坑
变量覆盖顺序丢失。老项目里可能存在"后
@import的文件覆盖前一个文件的变量"这种隐式逻辑。迁移到@use后,变量是模块化的,后加载的模块并不能覆盖已加载模块的变量值。表现为某些组件的颜色或间距突然不对。这个只能靠人工比对每个@use文件里的变量值,没有完全自动化的解决办法。函数和混合宏重名。不同文件里如果都定义了同名混合宏,在
@import时代,后者会覆盖前者;在@use时代,带命名空间访问,不会覆盖,但你会得到两个不同来源的同名函数,调用方如果没写前缀,编译报错会提示"找不到该成员"。这时候要检查到底哪个混合宏才是业务需要的,然后把另一个用@forward ... hide藏起来。入口文件也要转发。迁移完之后,经常出现"我
@use 'components'了但拿不到components.$primary-color"的问题。这就是因为没有在components/index.scss里@forward 'variables'。记住:转发不是自动的,需要显式声明。
5.3 个人维护建议:善用命名空间前缀做视觉提示
最后分享一个我从一个开源组件库里学到的习惯。在团队约定里,我会建议在文件名和命名空间上做一点呼应:
// _tokens.scss $brand-color: #5b8cff; $semantic-success: #2ecc71;入口文件这样组织:
@use 'tokens' as t; @use 'mixins' as m; @forward 'tokens'; @forward 'mixins';外部使用时就形成了一种"带前缀引用"的肌肉记忆:看到t.$brand-color,立刻知道它来自 tokens;看到m.button-base(),立刻知道来自 mixins。代码的可读性和可维护性比@import时代强太多了。
至于@import,我的态度是:存量字段可以留,新代码一律不写。Dart Sass 已经把它标记为弃用,早晚会彻底移除。与其等那一天被动地大规模重构,不如现在看到哪个文件改动,顺手把它迁到@use。积少成多,项目会在不知不觉中完成模块化改造。