说到前端样式,我们第一个会想到的肯定是 css,可是纯 css 语言本身有不少的缺陷,尤其是在前端趋于工程化的今天,css 缺少基础的模块化、样式复用等能力。不过幸运的是目前已经有了成熟的处理 CSS 的方案,下面我们就看看我们写的样式在构建过程中是怎么一步步被处理的。
预处理语言
顾名思义,预处理语言就是需要被预先处理成 css 的语言,他可以说是 css 的超集,支持 css 的所有语法,并且在这个基础之上支持了其他的特性。目前较为流行的预处理器有 sass, less, stylus 三种。
这三种预处理器在语法上各有不同,但是他们支持的特性都大同小异。
- 变量:这个其实后来 css 也有原生支持变量,可以进行 css 属性值的复用。
- 选择器的嵌套:这个特性能够让我们直接用嵌套写法来表示选择器之间的父子关系,从而免于写大量的选择器之间的关系。
- 函数:预处理器还有一些内置的工具函数,例如颜色函数,可以方便我们使用一些常用的颜色值或对颜色值进行转化等。
- Mixin(混用):这个特性也能够提高样式的复用性,支持我们在选择器里直接使用其他选择器的样式。
- import:可以导入其他样式文件,文件模块层面的复用。
- ...
这里仅介绍一些常用的特性,不对所有特性进行赘述。
这些预处理语言基本原理也都差不多,本质上都是一个编译器,根据一定的规则把各自的语法转译成标准 css 语法输出。
在 webpack 项目中还需要相应的 loader 来对 .less, .sass 等后缀的文件进行处理。例如 less-loader、sass-loader 等。而这些 loader 里主要也就是调用预处理器暴露的解析方法来处理文件的。
// less-loader
try {
result = await (options.implementation || _less.default).render(data, lessOptions);
} catch (error) {
}
如果项目使用的是其他打包器例如 Vite、Rollup 等,则需要提前了解这些工具是否内置了预处理器支持。否则也需要对应的插件进行处理。
postcss-loader
在实际 webpack 项目中,只有 JS 文件是合法的,因此就算我们把 less、sass 处理成标准 css 之后,我们还是需要对 css 再做进一步的操作。而 postcss-loader 就是对 css 操作的第一步。
postcss 与预处理语言不同,预处理会把 less、sass 等语法转化为 css 语法;而 postcss 则专门处理 css,负责做 css => css 的转换,可以类比为 css 里的 babel。
另外不同于预处理器的黑盒转换,postcss 的转换是高度可定制的,正如 babel 一样。有灵活的插件机制,用户可以自行在插件里决定自己的 css 应该怎么被转换。
比较有名的插件当属 autoprefixer 了,这个插件会根据 Can I Use 里浏览器对各种 css 特性兼容性情况自动给一些 css 属性添加上前缀。当然这里也允许用户自行配置哪些特性需要被处理。例如我们要处理grid 属性。
.red {
display: grid;
}
会被转化成下面这样👇
.red {
display: -ms-grid;
display: grid;
}
那这么多 css 特性难不成还需要一个一个自己配?这里还是参照 babel,babel 都有 preset 。postcss 也不例外,可以使用 postcss-preset-env 这个插件,再传入一些需要兼容的浏览器版本参数等,我们就可以放心使用最新的一些 css 特性了。
至于 Postcss 的原理实际上几乎跟 babel 一个套路:解析 AST => 对 AST 节点进行操作 => 用 AST 生成文本代码
css-loader
css-loader 主要是要处理 css 文件中的 @import 和 url() 此类涉及到模块引用的语句,会把他们转换为 import 或者 require,默认情况下会以 ESM 标准输出内容。
另外 css-loader 默认情况下还会把 css 的样式内容以字符串形式塞到数组里并将其导出,从而使文件内容变成标准的 JS 模块。
我们以一个简单的例子来分析,例子中引入了一个外部样式文件和图片。看看被 css-loader 处理后的东西是什么样的。
@import url('../../index.css');
.red {
color: red;
background-image: url('../../static/image/demo-image.jpg');
}
// Imports
import ___CSS_LOADER_API_IMPORT___ from "../../../node_modules/css-loader/dist/runtime/api.js";
import ___CSS_LOADER_AT_RULE_IMPORT_0___ from "-!../../../node_modules/css-loader/dist/cjs.js??ref--5-oneOf-8-1!../../index.css";
import ___CSS_LOADER_GET_URL_IMPORT___ from "../../../node_modules/css-loader/dist/runtime/getUrl.js";
import ___CSS_LOADER_URL_IMPORT_0___ from "../../static/image/demo-image.jpg";
var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(true);
___CSS_LOADER_EXPORT___.i(___CSS_LOADER_AT_RULE_IMPORT_0___);
var ___CSS_LOADER_URL_REPLACEMENT_0___ = ___CSS_LOADER_GET_URL_IMPORT___(___CSS_LOADER_URL_IMPORT_0___);
// Module
___CSS_LOADER_EXPORT___.push([module.id, ".red {\n color: red;\n background-image: url(" + ___CSS_LOADER_URL_REPLACEMENT_0___ + ");\n}\n", "",{"version":3,"sources":["/Users/bytedance/code/three-demo/src/page/Base/index.less","webpack://src/page/Base/index.less"],"names":[],"mappings":"AAEA;EACE,UAAA;EACA,yDAAA;ACAF","sourcesContent":["@import url('../../index.css');\n\n.red {\n color: red;\n background-image: url('../../static/image/demo-image.jpg');\n}","@import url('../../index.css');\n.red {\n color: red;\n background-image: url('../../static/image/demo-image.jpg');\n}\n"],"sourceRoot":""}]);
// Exports
export default ___CSS_LOADER_EXPORT___;
我们可以看到整个输出的内容已经变成了标准的 JS。主要分为三部分内容:
- Imports:模块引入代码,包括 css 文件里引入的外部 css 或图片等等,也包含一些 css-loader 自身的工具函数。
- Module:模块代码,主要就是文件的核心内容,这部分内容也是需要被导出的内容。
- Exports:模块导出代码。
重点可以看标黄部分的内容,引入的外部 css 文件和图片都被处理成了标准的import 。css 内容则被 push 进___CSS_LOADER_EXPORT___。最后导出的也是___CSS_LOADER_EXPORT___。
那___CSS_LOADER_EXPORT___是个什么东西,还有我们引入的外部 css 最后去哪了?
- 前面我们说自身的 css 内容被 push 进
___CSS_LOADER_EXPORT___了,这里其实可以很容易判断它是一个数组。 - 从上面的输出内容我们其实可以看到,引入的外部 css(即
___CSS_LOADER_AT_RULE_IMPORT_0___)最后是作为___CSS_LOADER_EXPORT___.i方法的参数了。
其实___CSS_LOADER_EXPORT___.i方法做的事情很简单,主要就是把传入的模块,在我们的场景下就是外部 css 模块,给 push 进自身 array 中,同时会记录已经存在的模块 id,防止相同模块重复 push。
所以结论是我们引入的外部 css 也是被 push 进了___CSS_LOADER_EXPORT___里,只不过这个过程是在___CSS_LOADER_EXPORT___.i方法里执行的。最后整个___CSS_LOADER_EXPORT___都会被默认导出。
css modules
前面提到了好几次「模块」这个概念,但是都仅仅是简单的复用其他文件而已,真正的「模块」还需要支持模块隔离,即模块间的选择器互不影响。
但是 css 本身是没办法支持这个特性的(除非你完全使用行内样式😅。
而除了行内样式以外其余两种样式style标签样式和link引入样式都是会作用到全局的。css 天然不具备隔离的概念。这么说样式隔离就是死路一条了吗?
其实回到我们的需求里,我们实际上并不需要完美的模块隔离,我们需要解决的是当多个模块的选择器相同的时候别相互影响就行了。
css modules 要解决的就是这个问题。
配置了 css modules 的 css-loader 会根据一定规则对样式文件里的选择器名进行转换,这里的转换规则,以及需要被转换的样式文件都是可配置的。
这里对 css 文件的所有转换中间都是借助 post-css 来实现的
我们继续以上面的文件为例来看配置了css modules 的 css-loader 会输出什么。
// Imports
import ___CSS_LOADER_API_IMPORT___ from "../../../node_modules/css-loader/dist/runtime/api.js";
import ___CSS_LOADER_AT_RULE_IMPORT_0___ from "-!../../../node_modules/css-loader/dist/cjs.js??ref--5-oneOf-5-1!../../../node_modules/postcss-loader/src/index.js??postcss!../../index.css";
import ___CSS_LOADER_GET_URL_IMPORT___ from "../../../node_modules/css-loader/dist/runtime/getUrl.js";
import ___CSS_LOADER_URL_IMPORT_0___ from "../../static/image/demo-image.jpg";
var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(true);
___CSS_LOADER_EXPORT___.i(___CSS_LOADER_AT_RULE_IMPORT_0___);
var ___CSS_LOADER_URL_REPLACEMENT_0___ = ___CSS_LOADER_GET_URL_IMPORT___(___CSS_LOADER_URL_IMPORT_0___);
// Module
___CSS_LOADER_EXPORT___.push([module.id, ".index-module__red--dRCSe {\n color: red;\n background-image: url(" + ___CSS_LOADER_URL_REPLACEMENT_0___ + ");\n}", "",{"version":3,"sources":["webpack://src/page/Base/index.module.css"],"names":[],"mappings":"AAEA;EACE,UAAU;EACV,yDAA0D;AAC5D","sourcesContent":["@import url('../../index.css');\n\n.red {\n color: red;\n background-image: url('../../static/image/demo-image.jpg');\n}"],"sourceRoot":""}]);
// Exports
___CSS_LOADER_EXPORT___.locals = {
"red": "index-module__red--dRCSe"
};
export default ___CSS_LOADER_EXPORT___;
可以看到这里原本的类名red变成了Base_red__ZMI3y,同时最后的导出内容增加了原类名到新类名的映射,使用时我们需要使用这个映射关系。
import style from './index.module.css';
<div className={style.red}></div>
这里为什么映射关系变成默认导出我们在下面介绍 style-loader 的时候会解释
babel-plugin-react-css-modules
由于 css modules 的使用需要我们引入新旧选择器的映射关系才能使用转换后的选择器,那有没有办法可以让我们既能使用到 css modules 转换后的选择器名,又能享受以前简便的写法呢?
思路很简单,既然 css modules 对选择器名做了转换,那我们就在对节点里的类名再做一次转换让他们能匹配上不就行了。
实际上 babel-plugin-react-css-modules 也就是这么做的。
- import style from './index.module.css';
- <div className={style.red}></div>
+ import './index.module.css';
+ <div styleName="red"></div>
只需要把以前的 className 换成 styleName,其余的写法和原来一模一样!
我们也可以看看上面的例子会生成什么内容。
Object(jsx_runtime.jsx)('div', { className: 'index-module__red--dRCSe' })
可以看到
styleName 已经被替换成了 className,选择器名也和 css modules 处理后的保持一致。
其实这个插件主要的原理就是我们上面说的,利用 babel 对节点的 styleName 按照一定的规则进行转换。不过尤其要注意的是,这里的规则一定要和 css modules 的选择器名生成规则保持一致,否则两者生成的名字不一样就白搭。
由于这个插件已有多年不维护,目前有些坑,例如它在做类名转换时采用的哈希算法跟 css modules 使用的哈希算法不一致,导致最终生成的类名匹配不上等问题,可能需要我们手动去升级它依赖的 generic-name 包版本
前面说到我们的样式文件经过 css-loader 的转换输出的也是标准 JS 内容,要让样式生效我们还需要让它变成真实的 css 内容。这里无非就是两种:style 标签或 link 样式资源。
style-loader
这个 loader 接收 css-loader 的处理结果,并将其中的 css 内容提取出来塞到一个 style 标签中,让样式真正发挥作用。
我们来看看经过 style-loader 处理后的结果是怎么样的。
var api = require("!../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js");
var content = require("!!../node_modules/css-loader/dist/cjs.js??ref--5-oneOf-4-1!../node_modules/postcss-loader/src/index.js??postcss!./index.css");
content = content.__esModule ? content.default : content;
if (typeof content === 'string') {
content = [[module.id, content, '']];
}
var options = {};
options.insert = "head";
options.singleton = false;
var update = api(content, options);
module.exports = content.locals || {};
这个就是 style-loader 生成的运行时代码,其中核心在于injectStylesIntoStyleTag这个模块,模块里就执行了 style 标签的生成以及插入。
最后默认导出的 content.locals是不是有种似曾相识的感觉,没错他就是在上一步 css-loader 处理结果最后导出的新旧选择器名的映射关系。style-loader 把这个映射关系默认导出,也就解释了上面的疑问。
mini-css-extract-plugin
style-loader 会让我们的样式以 style 标签的形式作用。另一种以 link 标签形式作用则需要使用到 mini-css-extract-plugin。
通常我们会在开发环境使用 style-loader,方便我们 debug 。而在生产环境,为了让文档资源体积更小,充分利用浏览器缓存的能力,追求更好的用户体验,我们会需要对资源进行拆包,例如对于不同路由的页面资源进行按需加载,这个时候页面的 css 资源则也会按需加载,要实现这个能力就要使用到 mini-css-extract-plugin。
不同于 style-loader 可以较为简单地把 css-loader 的输出插入到全局 style 标签里。mini-css-extract-plugin 拆分 css 的能力还需要深入到 webpack 的构建过程里去。
不难猜到以下两个功能是必须的:
- 处理 css 模块并需要标记下来。
- 在 webpack 生成文件的阶段把标记的 css 模块抽离出来单独生成。
mini-css-extract-plugin 的具体做法是:
- 提供了一个 pitch loader,获取到编译所需的一些参数,然后通过
createChildCompiler初始化一个子编译器并 css 模块为入口进行编译。
function pitch(request) {
const childCompiler =this._compilation.createChildCompiler(`${pluginName} ${request}`, outputOptions);
// 把入口设置为 css 模块的 request
new _SingleEntryPlugin.default(this.context, `!!${request}`, pluginName).apply(childCompiler);
// 获取到 loader 信息
const loaders =this.loaders.slice(this.loaderIndex + 1);
childCompiler.hooks.thisCompilation.tap(`${pluginName} loader`,compilation => {
normalModuleHook.tap(`${pluginName} loader`, (loaderContext,module) => {
// 把 loader 信息传进子编译流程里
module.loaders = loaders.map(loader => {
return {
loader:loader.path,
options:loader.options,
ident:loader.ident
};
});
});
});
}
- 子编译流程会产生 chunk assets,但这不是我们需要的,因为我们不希望每个 css 模块都单独生成一个 chunk,因此子编译流程产生的 chunk assets 需要删除。
function pitch(request) {
childCompiler.hooks.afterCompile.tap(pluginName,compilation => {
// 先把产物存起来,之后有用
source =compilation.assets[childFilename] &&compilation.assets[childFilename].source();
compilation.chunks.forEach(chunk => {
chunk.files.forEach(file => {
deletecompilation.assets[file]; // eslint-disable-line no-param-reassign
});
});
});
}
- 把子编译结果作为
_CssDependency添加到主编译流程的模块依赖中。这一步既可以标记好 css 模块,又可以把 css 模块和依赖它的模块关联上。
function pitch(request) {
for (const dependency ofdependencies) {
const count = identifierCountMap.get(dependency.identifier) || 0;
// 把编译结果构建成 CssDependency 添加进模块依赖
this._module.addDependency(new _CssDependency.default(dependency, dependency.context, count));
identifierCountMap.set(dependency.identifier, count + 1);
}
}
- 前面几步看起来感觉在 pitch loader 净做了些不像 loader 做的事情,所以接下来要回到正事上,在 子编译流程完了之后 pitch loader 是需要返回结果的,因为不需要其他 loader 处理了,因为产物都已经在子编译流程里生成了。因此这里只需要获取到子编译结果的导出内容作为 loader 的处理结果返回就行。
function pitch(request) {
// 这里的 locals 就是从编译结果的导出内容里获取的
const result = locals ? namedExport ?Object.keys(locals).map(key => `\nexport const ${key} = ${JSON.stringify(locals[key])};`).join('') : `\n${esModule ? 'export default' : 'module.exports ='} ${JSON.stringify(locals)};` : esModule ? `\nexport {};` : '';
let resultSource = `// extracted by ${pluginName}`;
resultSource += options.hmr ? hotLoader(result, {
context:this.context,
options,
locals
}) : result;
this.async(null, resultSource);
}
- 经过上面几步的处理,css 模块已经成功被编译并添加成了
CssDependency依赖,最后就需要在主流程的生成文件之前把 chunk 里的 css 模块依赖过滤出来添加到result列表里,这个result列表里的信息最终就会生成一个个的 chunk 文件。
compilation.chunkTemplate.hooks.renderManifest.tap(pluginName, (result, { chunk }) => { const { chunkGraph } = compilation;
// 过滤出 chunk 里的 css 模块。 const renderedModules = Array.from(this.getChunkModules(chunk, chunkGraph)).filter(module => module.type === _utils.MODULE_TYPE); const filenameTemplate = chunk.filenameTemplate || this.options.chunkFilename; if (renderedModules.length > 0) {
// 添加进 result 里 result.push({ render: () => this.renderContentAsset(compilation, chunk, renderedModules, compilation.runtimeTemplate.requestShortener), filenameTemplate, pathOptions: { chunk, contentHashType: _utils.MODULE_TYPE }, identifier: `${pluginName}.${chunk.id}`, hash: chunk.contentHash[_utils.MODULE_TYPE] }); } })
下面以一张流程图来概括 mini-css-extract-plugin 的工作流程:
Vue Style
写过 vue 的同学都知道,vue 的样式是和 template、script 一起写在同一个组件文件里的,因此 vue 的样式需要先被“拆”出来再进行处理。
Vue 提供了 vue-loader 来对 .vue 组件文件做“拆分”工作,具体原理不是本文的重点,大家可以自行去了解,下面用一张图简单做个概括。
简单来说 .vue 文件经过 vue-loader 完全处理会拆分出样式部分,除此之外如果用户配置了 css-loader,则还会在 css-loader 之前插入 stylePostLoader;最后再把拆出来的样式交给最终的 loader 配置进行处理。
// Inject style-post-loader before css-loader for scoped CSS and trimming
if (query.type === `style`) {
const cssLoaderIndex = loaders.findIndex(isCSSLoader)
if (cssLoaderIndex > -1) {
const afterLoaders = loaders.slice(0, cssLoaderIndex + 1)
const beforeLoaders = loaders.slice(cssLoaderIndex + 1)
const request = genRequest([
...afterLoaders,
stylePostLoaderPath,
...beforeLoaders
])
// console.log(request)
return query.module
? `export { default } from ${request}; export * from ${request}`
: `export * from ${request}`
}
}
而这个 stylePostLoader 则主要是调用 @vue/component-compiler-utils 这个包里的 compileStyle 方法来处理我们写在style里的内容。
function compileStyle(options) {
return doCompileStyle(Object.assign(Object.assign({},options), { isAsync: false }));
}
function doCompileStyle(options) {
const { filename, id, scoped = true, trim = true, preprocessLang, postcssOptions, postcssPlugins } =options;
const preprocessor = preprocessLang && styleProcessors_1.processors[preprocessLang];
const preProcessedSource = preprocessor && preprocess(options, preprocessor);
const source = preProcessedSource ? preProcessedSource.code :options.source;
const plugins = (postcssPlugins || []).slice();
if (scoped) {
plugins.push(scoped_1.default(id));
}
const postCSSOptions =Object.assign(Object.assign({}, postcssOptions), { to: filename, from: filename });
let result, code, outMap;
result = postcss(plugins).process(source, postCSSOptions);
// force synchronous transform (we know we only have sync plugins)
code = result.css;
outMap = result.map;
return {
code: code || ``,
map: outMap && outMap.toJSON(),
errors,
rawResult: result
};
}
这个 compileStyle 里主要做两个事情:
- 如果使用了预处理语言,例如
<style lang="less">,则先调用对应的预处理器编译,vue 内置支持了 less、sass、stylus。 - 如果是使用了 scoped
<style scoped>,则需进行样式隔离的处理,这里类似 css modules,也是对选择器名做一定的转换保证他的唯一性,并且也是通过 postcss 来做的转换。
// 转换前
<style scoped>.example {color: red;}</style>
<template><div class="example">hi</div>
</template>
// 转换后
<style>.example[data-v-f3f3eg9] {color: red;}</style>
<template><div class="example" data-v-f3f3eg9>hi</div>
</template>
总的来说 vue 在真正处理样式之前还是做了比较多的针对 .vue 文件的编译工作,对样式也内置支持了比较多特性,不需要开发者手动配置太多样式的 loader。
上面就是在当前前端工程化中主要使用的样式解决方案。不过除了上面的方案以外,社区还有其他一些颇具特点的方案,下面挑两个比较知名的简单介绍一下。
styled-components
相比于上面的 css 和 js 分离的开发模式,styled-components 则是社区典型的 React css-in-js 方案,他把 css 的编写跟 js 放在一起,把 css 直接定义在组件里。
另外一个不同点在于,上面的 css 和 js 分离模式都需要打包工具提前做一些处理,会有一定的编译时,然而 styled-components 是纯运行时的方案,意味着他不需要任何的 webpack 配置,开箱即用。当然这个特性的缺点就是会带来更多运行时的开销。
- styled-components 内置了 stylis 预处理器,因此也支持一些常见的预处理器语法例如嵌套写法等。
- 在解决样式冲突这一点,styled-components 同样也能支持。
- 样式定义可以接收参数,因此写法上更灵活。
- 样式复用可以基于组件实现。
styled-components 在开发环境会将样式以文本形式插入到 style 标签里,而在生产环境则会把样式加入到 style 标签的 CSSStyleSheet.cssRules中。
社区对 css-in-js 方案褒贬不一,我个人还是挺喜欢这种模式带来的灵活性。
Tailwindcss
虽然前面介绍的方案里都能支持「样式复用」,但是他们的复用要不就是以文件粒度,或者是组件(styled-components)粒度。tailwindcss 则提供了「原子粒度」的复用,他定义了一些原子类,用户可以直接使用这些原子类来最大程度减少 css 代码的编写。
<p class="text-base">
You have a new message!
</p>
例如"text-base"这个类名已经被 tailwind 定义了.text-base: {font-size: 1rem},我们只需要提前引入 tailwind 定义的样式,然后直接使用即可。
css 中
@tailwind base;
@tailwind components;
@tailwind utilities;
或者 js 中
import 'tailwindcss/base.css';
import 'tailwindcss/components.css';
import 'tailwindcss/utilities.css';
实际上 tailwind 说白了就是在内部预先定义好了一大堆 css,我们使用的时候先把这些 css 引入到项目里,然后需要用到哪些样式就去官网查阅对应的类名即可,这坨内置的样式也是可以根据用户喜好扩展的,比起 styled-components 的 css-in-js ,tailwind 更像是 css-in-config,用户做的扩展都要以配置形式传递给 tailwind,这里附上 tailwind 默认的配置。github.com/tailwindlab…