样式之谜
一直以来,我是不喜欢处理样式的。直到我遇见了麻烦。
直接在另一文件中使用一个库(以 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.mjsimport { 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.mjsconst __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.mjsconst __dirname = import.meta.dirname;
const path_join = (...paths) => path.join(__dirname, ...(dir || []));
// 其他配置
export default {
alias: {
exportsFields: ['exports'],
},
};
// 其他配置 -
还有其他很多修改一言难尽
2. 自以为是曙光
在查阅资料时发现使用 import('enr/common.scss'); 的形式可以解决该问题(当然是经过上述的磨练之后啦)。
于是:
- 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 实现)
npm install sass
# 或
yarn add sass
# 或
pnpm add sass
2. 在组件中导入 CSS/SCSS
可以直接全局导入样式,也可以导入与组件相关的局部样式。
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 文件中的
@import和url(),并将他们当作模块处理 - Style Loader : (开发常用)将解析后的 CSS 代码以字符串形式,通过 JavaScript 动态构建一个
<style>标签插入到 HTML 的<head>中 - MiniCssExtractPlugin :(生产常用)在生产构建阶段,代替 Style Loader ,将 CSS 代码提取 ( Extract ) 出来,生成单独的
.css文件。然后 Webpack 会生成一个<link>标签来引入这个 CSS 文件 - Sass Loader : 在 css-loader 之前运行,负责将
SCSS/Sass代码编译成普通的 CSS 代码。它需要node-sass或 sass (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. 安装生产环境依赖
pnpm add --save-dev mini-css-extract-plugin css-minimizer-webpack-plugin
# css-minimizer-webpack-plugin :压缩抽离的 CSS
2. 优化配置
import
3. 三方包解析
在导出为库的时候,如果使用三方包的样式文件,可以排除打包到结果中(多出现于 workspace 工作环境下)。
// 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 工具剔除未使用的样式,减少打包体积。
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 按需导入
- element-plus 按需导入
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'; // 自动添加
element-plus 推荐使用 unplugin 系列( ubplugin-auto-import + unplugin-vue-components 夸构建工具 )插件,比 babel-plugin-import 更灵活。
- 自动识别组件使用 : 插件会扫描 Vue 模版或脚本中的
<el-button>、ElButton等组件的使用痕迹 - 自动导入组件代码 : 无需手动写
import { ElButton } from 'element-plus',插件会自动注入这个单组件的导入插件自动导入// 插件自动注入
import { Element } from 'element-plus/es/components/button/index'; - 自动导入组件样式 : 同时自动注入对应的组件样式,无需手动引入
// 插件自动插入样式(配置按需导入时)
import 'element-plus/es/components/button/style/css'; - 全局样式按需排除 : 插件会自动忽略
element-plus的全局 CSS,只引入用到的组件样式,避免冗余
6. css in js
采用 「CSS-in-JS」方案,样式按需直接内联在组件代码中,无需单独导入 CSS ,天然支持按需加载(组件加载时自动生效,未使用的组件样式不会被打包)。
- 若作为 workspace 库打包,首选使用
externals排除包 - 若作为发布包,不确定宿主项目环境 --- 打包进包(或标记为
dependencies)- 保证独立可用 :第三方使用包时,无需手动安装
styled-components,直接引入即可使用组件样式,降低使用门槛 - 避免依赖缺失报错 : 如果互用忽略
peerDependencies的提示,未安装styled-components,会导致运行时报错;打包进包避免该问题
- 保证独立可用 :第三方使用包时,无需手动安装