SCSS模块化:@import、@use、@forward的区别与迁移实践
如果你维护一个老样式项目超过两年大概率会遇到这种场景一个_variables.scss被import了十几遍某个全局变量被页面样式悄悄覆盖改一处配置牵出一串报错。这个背景正好是理解 SCSS 里import、use、forward三者区别的最佳入口。这两年现代 Sass 模块系统逐步普及import已经进入官方弃用通道use和forward成了主流写法。但弃用不等于立刻不能用了大量存量项目、混合项目、团队协同项目里三种指令经常共存导致新人接手时完全分不清应该用哪个。这篇博文我想把这三个指令的来龙去脉、行为差异、迁移思路以及我在实际项目里踩过的坑一次说清楚。适合看这篇内容的是那些已经能用 SCSS 写样式、但对模块化组织方式还处于照抄配置阶段的开发者。你会从这里知道为什么官方要推翻了importuse的命名空间机制到底解决了什么问题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 variablesDart 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; // 必须带命名空间 }用useforward写统一出口// _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 Sassimport还会在编译时打出警告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 cant 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。积少成多项目会在不知不觉中完成模块化改造。