跳到主要内容

样式之谜

· 阅读需 10 分钟
泥豆君
哇!是泥豆侠,我们没救了

一直以来,我是不喜欢处理样式的。直到我遇见了麻烦。

直接在另一文件中使用一个库(以 enr 为例)的 scss 文件,可以这么做:

  • 在 ts 、 js 、 tsx 或 jsx 中
    • 使用 import 'enr/common.scss'; 导入
    • 使用 import('enr/common.scss'); 异步导入
    • 使用 import(/* webpackMode: 'eager' */ 'enr/common.scss'); 同步导入
  • 在 scss 文件中使用 @use 'enr/common.scss' as enr; 导入(推荐)

问题在于我既想使用 pnpm 的 workspace 又想使用 import 'enr/common.scss'; 的模式。于是一错再错。

一、初遇麻烦以为只是个小麻烦

就像我的初恋,原以为是一场浪漫,却不知是撕心裂肺。

这个问题原以为改下配置就可以解决,谁知确实泥潭越陷越深。

我在将浏览器插件的项目使用 workspace 调整的时候,发现 enr 下导出的 scss 或 css 源码文件无法像以前那样正确导入了。

import 'enr/common.scss';

于是乎,又是两天的折腾。

也问过国产 5 大流氓了,由于蓝灯这几天不给力,让我着实吃尽了苦头。

1. 艰辛历程

根据五大流氓的建议,先后进行了如下的更改:

  • 尝试使用 require.resolve 加载实际的路径

    webpack.config.mjs
    import { createRequest } from 'node:module';

    const require = createRequire(import.meta.url);
    const enrCommonScss = require.resolve('enr/common.scss');

    export default {
    {/* ..., 其他配置 */}
    alias : {
    'enr/common.scss': enrCommonScss,
    }
    {/* ..., 其他配置 */}
    }
  • 更改包 enr 的导出方式(好在 enr 是我自己的包)

    package.json
    {
    // ..., 其他配置
    "exports": {
    "./common.scss": {
    "style": "./styles/common.scss",
    "scss": "./styles/common.scss",
    "default": "./styles/common.scss"
    }
    }
    // ..., 其他配置
    }
  • 禁用符号链接

    webpack.config.mjs
    // 其他配置
    export default {
    alias: {
    symlinks: false,
    },
    };
    // 其他配置
  • 使用导入条件名称

    webpack.config.mjs
    // 其他配置
    export default {
    alias: {
    conditionNames: ['import', 'require', 'default'],
    },
    };
    // 其他配置
  • 配置模块

    webpack.config.mjs
    const __dirname = import.meta.dirname;
    const path_join = (...paths) => path.join(__dirname, ...(dir || []));
    // 其他配置
    export default {
    alias: {
    modules: [
    'node_modules',
    path_join('../../node_modules'),
    path_join('node_modules'),
    ],
    },
    };
    // 其他配置
  • 设定导出字段

    webpack.config.mjs
    const __dirname = import.meta.dirname;
    const path_join = (...paths) => path.join(__dirname, ...(dir || []));
    // 其他配置
    export default {
    alias: {
    exportsFields: ['exports'],
    },
    };
    // 其他配置
  • 还有其他很多修改一言难尽

2. 自以为是曙光

在查阅资料时发现使用 import('enr/common.scss'); 的形式可以解决该问题(当然是经过上述的磨练之后啦)。

于是:

root.ts
- import 'enr/common.scss';
+ import('enr/common.scss');
// 或
+ import(/* webpackMode: 'eager' */ 'enr/common.scss');

但是所有可查阅的地方都提醒我这样做的弊端很多。

其实,当时已经查到可以由另一个项目本地的 'xx.scss' 文件中使用 @use ''enr/common.scss 的方式,头铁的我想着能用修改配置的方式能够处理好这件事(使用 )。

3. 人生就是兜兜转转

后来,我总算学会了如何去改。可惜时间早已远去,消失在宙海。

后来,终于在眼泪中明白,有些代码,能跑就不要改

既然使用 @use 'enr/common.scss'; 是最正确的路,为何在错误的死胡同撞的头破血流。

额,可能是我撞了南墙不也会回头       吧。

二、原来这本就是个误区

使用 import 'enr/common.scss'; 的导入模式本身就不是特别的友好,但对于模块的加载确是很好。

但我向来都是将样式打包成内嵌到根 html 的 <style> 的模式打包的。这似乎不是特别的好。(五大流氓都不建议这么做)

三、Webpack 打包样式

1. 安装必要的依赖

  • css : 通常脚手架已内置支持
  • SCSS/Sass :需要安装 sass 包 (Dart Sass 实现)
bash
npm install sass
# 或
yarn add sass
# 或
pnpm add sass

2. 在组件中导入 CSS/SCSS

可以直接全局导入样式,也可以导入与组件相关的局部样式。

App.jsx
import React from 'react';
import './App.css';
// 或 './App.scss';
import styles from './Button.module.css';
// 或 import styles from './Button.module.scss';
import { xcn } from 'xcn';

const Button = ({ children }) => (
<button className={xcn(styles.buttonPrimary)}>{children}</button>
);

export default function App() {
return (
<div>
<h1>你好</h1>
<Button>点我试试</Button>
</div>
);
}

3. Loader 的作用

当写 import './App.css'; 时, Node.js 本身并不会理解如何处理 .css 文件。这时就需要 Loader :

  • CSS Loader : 负责解析 CSS 文件中的 @importurl() ,并将他们当作模块处理
  • Style Loader : (开发常用)将解析后的 CSS 代码以字符串形式,通过 JavaScript 动态构建一个 <style> 标签插入到 HTML 的 <head>
  • MiniCssExtractPlugin :(生产常用)在生产构建阶段,代替 Style Loader ,将 CSS 代码提取 ( Extract ) 出来,生成单独的 .css 文件。然后 Webpack 会生成一个 <link> 标签来引入这个 CSS 文件
  • Sass Loader : 在 css-loader 之前运行,负责将 SCSS/Sass 代码编译成普通的 CSS 代码。它需要 node-sasssass (Dart Sass)作为编译器的后端

4. 工作流程

  • 遇到 import './App.scss';
  • 调用配置的 Loader 链(例如:sass-loader ➞ css-loader ➞ style-loader 或 css-loader ➞ css-loader + MiniCssExtractPlugin.loader )
  • sass-loader(如果是 SCSS )将 SCSS 编译成 CSS
  • css-loader 处理 CSS 中的依赖关系
  • 开发模式( style-loader ) : 将最终的 CSS 字符串注入到页面的 <style> 标签中
  • 生产模式( MiniCssExtractPlugin : 将最终的 CSS 写入一个或多个物理 .css 文件。 Webpack 输出中会包含一个将这些 CSS 文件链接到 HTML 的指令(通常由 HTMLWebpackPlugin 自动完成)

5. 开发打包总结处理

  • 开发( npm start/yarn start/pnpm dev
    • 构建工具( Webpack Dev Server ) 启用热更新
    • 样式通常以 <style> 标签动态注入到当前的页面的 <head> 中。这样做速度快,修改后及时生效
    • 性能不是首要考虑的因素,开发者体验优先
  • 打包( npm run build/yarn build/pnpm build
    • 构建工具执行生产优化
    • 默认或推荐配置 : 使用 MiniCssExtractPlugin(Webpack) 或类似插件将 CSS 提取到独立的 .css 文件中
    • 生成的 HTML 会通过 <link rel="stylesheet" href="..."> 引入这些 CSS 文件
    • 同时会对 CSS 进行压缩( Minification ) 、 Tree Shaking (移除未使用的 CSS - 取决于配置和工具支持 ) 、 可能的代码分割( Code Splitting )

四、生产 🆚 生产

开发时使用 <style> 标签是为了速度。生产时,最佳实践(也是默认或容易配置的选项)就是将 CSS 提取到外部 .css 文件并通过 <link> 加载

  • 并行加载 : 浏览器可以并行下载 JS 和 CSS 文件,而不必等待一个巨大的 CSS 包下载完后解析里面的 CSS 字符串
  • 缓存 : 独立的 CSS 文件可以被浏览器单独缓存。如果只更新 JS 而没有更改 CSS ,用户下次只需加载新的 JS 文件, CSS 缓存有效
  • 较少主包体积 : 将 CSS 移除 JS 包显著减少了 JS 的文件的体积,加快 JS 的下载和执行速度
  • 渲染阻塞 : 浏览器会阻塞页面渲染直到关键的 CSS (通常是 <link> 引入的第一个 CSS 文件)下载完成,形成“关键渲染路径”。内联关键 CSS 有时能优化首屏,但提取非关键 CSS 到外部文件并使用 preload 提示的是现代框架的常规做法

结论:使用 Loader 导入 CSS/SCSS 本身不会导致 生产环境性能不佳。 恰恰相反,通过正确的正常的生产构建(提取 CSS ),它是实现高性能的标准方式

1. 安装生产环境依赖

bash
pnpm add --save-dev mini-css-extract-plugin css-minimizer-webpack-plugin
# css-minimizer-webpack-plugin :压缩抽离的 CSS

2. 优化配置

webpack.config.mjs
import

3. 三方包解析

在导出为库的时候,如果使用三方包的样式文件,可以排除打包到结果中(多出现于 workspace 工作环境下)。

webpack.config.mjs
// webpack.config.mjs
export default {
module: {
rules: [
{
test: /\/(css|sass|scss)$/,
// 排除 node_modules 中的第三方 CSS
exclude: /node_modules\/(antd|bootstrap|enr)/,
use: [
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader',
'postcss-loader',
'sass-loader',
],
},
// 对第三包 CSS 仅作「识别」,不打包(或直接忽略)
{
test: /node_modules\/(antd|bootstrap|enr)\/.+\.css$/,
use: ['null-loader'], // 忽略第三方 CSS, 不打包进产物
},
],
},
};

4. 剔除普通 CSS 中无用的样式 ( PurgeCSS )

使用 purgecss 工具剔除未使用的样式,减少打包体积。

webpack.config.mjs
import PurgeCSSPlugin from 'purgecss-webpack-plugin';
import glob from 'glob';
import path from 'path';

const __dirname = import.meta.dirname;

export default {
plugins: [
// ... 其他插件
new MiniCssExtractPlugin({
filename: 'css/[name].[contenthash].css',
chunkFileName: 'css/[name].[contenthash].chunk.css',
}),
// 生产环境启用 PurgeCSS
isProduction &&
new PurgeCSSPlugin({
// 扫描项目中所有使用样式的文件(JS/TS/TSX/HTML)
paths: glob.sync(`${path.resolver(__dirname, 'src')}/**/*`, {
nodir: true,
}),
// 保留第三方包 CSS 中被使用的样式
safelist: {
// 需要手动保留需要的样式类(避免误删)
standard: [/ant-btn/, /ant-card/],
// 模糊匹配,保留所有以特定开头的样式类(按需开启)
deep: [/ant-/, /en-/],
},
}),
].filter(Boolean),
};
注意

需要手动添加需要保留的类名。

5. 三方包的按需导入

像 antd 、 element-plus 这类包,使用插件或 babel 等转化为按需导入,并自动导入对应组件的 css,这样有助于开发者简化按需导入的操作,只关心使用而不用实际引用到组件

antd 依赖 babel-plugin-import 实现按需导入

示意
// 原始代码
import { Button , Input } from 'antd';

// 插件转换后
import Button from 'antd/lib/button';
import Input from 'antd/lib/input';

// 自动追加样式导入(配置导入 CSS 时)
// 转换后的最终代码
import Button from 'antd/lib/button';
import 'antd/lib/button/style/index.css'; // 自动添加
import Input form 'antd/lib/input';
import 'antd/lib/input/style/index.css'; // 自动添加

6. css in js

采用 「CSS-in-JS」方案,样式按需直接内联在组件代码中,无需单独导入 CSS ,天然支持按需加载(组件加载时自动生效,未使用的组件样式不会被打包)。

  • 若作为 workspace 库打包,首选使用 externals 排除包
  • 若作为发布包,不确定宿主项目环境 --- 打包进包(或标记为 dependencies
    • 保证独立可用 :第三方使用包时,无需手动安装 styled-components ,直接引入即可使用组件样式,降低使用门槛
    • 避免依赖缺失报错 : 如果互用忽略 peerDependencies 的提示,未安装 styled-components ,会导致运行时报错;打包进包避免该问题