微前端中的 CSS
我最常被问到的问题之一是如何在微前端中处理 CSS。毕竟,任何UI 片段都需要样式,然而,样式又是全局共享的,因此也存在潜在的冲突。
在本文中,我想介绍一些现有的策略,它们可以简化 CSS 开发,并使其在微前端开发中具有可扩展性。如果您觉得本文有任何内容合理,不妨考虑阅读“微前端的艺术”。
本文的代码可以在github.com/piral-samples/css-in-mf找到。请务必查看示例实现。
CSS 的处理会影响每个微前端解决方案吗?让我们检查一下可用的类型来验证这一点。
微前端的类型
过去我写过很多关于微前端的类型、它们存在的原因以及何时应该使用哪种类型的微前端架构的文章。采用 Web 方法意味着使用 iframe 来访问来自不同微前端的 UI 片段。在这种情况下,没有任何限制,因为每个片段都是完全隔离的。
在任何其他情况下,无论您的解决方案使用客户端、服务器端组合(或介于两者之间),最终都会在浏览器中评估样式。因此,在所有其他情况下,您都需要关注 CSS。让我们看看这里有哪些选项。
无特殊待遇
那么,第一个——也许是最明显的(或者根据观点,最不明显的)解决方案就是不做任何特殊处理。相反,每个微前端都可以附带额外的样式表,然后在渲染微前端的组件时附加这些样式表。
理想情况下,每个组件在首次渲染时仅加载所需的样式,但是,由于任何这些样式都可能与现有样式冲突,我们也可以假装在微前端的任何组件渲染时加载所有有问题的样式。
这种方法的问题在于,当使用诸如div
或div a
之类的通用选择器时,我们不仅会重新设置原始微前端的片段,还会重新设置其他元素的样式。更糟糕的是,类和属性也并非万无一失。类似的类.foobar
也可能用于其他微前端。
您可以在引用的演示存储库中找到两个冲突的微前端的示例solutions/default
。
摆脱这种困境的一个好方法是进一步隔离组件 - 就像 Web 组件一样。
影子 DOM
在自定义元素中,我们可以打开一个影子根 (shadow root),将元素附加到一个专用的迷你文档,该迷你文档实际上与其父文档屏蔽。总的来说,这听起来是个好主意,但与本文介绍的所有其他解决方案一样,并没有硬性要求。
理想情况下,微前端可以自由决定如何实现组件。因此,实际的影子 DOM 集成必须由微前端完成。
使用 Shadow DOM 也有一些缺点。最重要的是,虽然 Shadow DOM 中的样式会保留在内部,但全局样式不会影响 Shadow DOM。乍一看这似乎是一个优势,然而,由于本文的主要目标只是隔离微前端的样式,你可能会错过一些必要的功能,例如应用一些全局设计系统(例如 Bootstrap)。
link
要使用 Shadow DOM 进行样式设置,我们可以通过引用或标签将样式放入 Shadow DOM 中style
。由于 Shadow DOM 本身没有样式,外部的样式不会传递到其中,所以我们确实需要这样做。除了编写一些内联样式外,我们还可以使用打包器将.css
(或者类似.shadow.css
)处理为原始文本。这样,我们得到的就只是一些文本。
piral-cli-esbuild
对于 esbuild 我们可以配置如下的预制配置:
module.exports = function(options) {
options.loader['.css'] = 'text';
options.plugins.splice(0, 1);
return options;
};
这将删除初始 CSS 处理器 (SASS),并为.css
文件配置一个标准加载器。现在,shadow DOM 中的样式如下所示:
import css from "./style.css";
customElements.define(name, class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.style.display = "contents";
const style = this.shadowRoot.appendChild(document.createElement('style'));
style.textContent = css;
}
});
上面的代码是一个有效的自定义元素,从样式角度来看是透明的(display: contents
),也就是说,只有其内容会反映在渲染树中。它托管一个包含单个style
元素的影子 DOM。 的内容style
被设置为文件的文本style.css
。
您可以在引用的演示存储库中找到两个冲突的微前端的示例solutions/shadow-dom
。
避免在域组件中使用影子 DOM 的另一个原因是,并非所有 UI 框架都能够处理影子 DOM 中的元素。因此,无论如何都必须寻找替代方案。一种方法是回退到使用一些 CSS 约定。
使用命名约定
如果每个微前端都遵循一个全局的 CSS 约定,那么元级别的冲突就已经可以避免了。最简单的约定是在每个类前面加上微前端的名称。例如,如果调用一个微前端shopping
,同时调用另一个微前端,checkout
那么这两个微前端都会分别将其active
类重命名为shopping-active
/ 。checkout-active
同样的方法也适用于其他可能存在名称冲突的场景。例如,对于名为 的微前端,primary-button
我们不会像调用 那样使用 ID 。如果出于某种原因,我们需要为某个元素设置样式,则应该使用后代选择器(例如 )来为标签设置样式。现在,这适用于某些具有 类的元素中的元素。这种方法的问题在于,购物微前端可能还会使用来自其他微前端的元素。如果我们看到 会怎样?即使现在由通过微前端引入的组件托管/集成,它也将由微前端 CSS 设置样式。这并不理想。shopping-primary-button
shopping
.shopping img
img
img
shopping
div.shopping > div.checkout img
img
checkout
shopping
您可以在引用的演示存储库https://github.com/piral-samples/css-in-mf/tree/main/solutions/default中找到两个冲突的微前端的示例。
尽管命名约定在一定程度上解决了这个问题,但它们仍然容易出错,而且使用起来很麻烦。如果我们重命名微前端怎么办?如果微前端在不同的应用程序中有不同的名称怎么办?如果我们在某些时候忘记应用命名约定怎么办?这时,工具就可以帮到我们了。
CSS 模块
自动引入一些前缀并避免命名冲突的最简单方法之一是使用 CSS 模块。根据您选择的打包工具,这可以开箱即用,也可以通过更改配置来实现。
// Import "default export" from CSS
import styles from './style.modules.css';
// Apply
<div className={styles.active}>Active</div>
导入的模块是一个生成的模块,其中包含将其原始类名(例如active
)映射到生成类名的值。生成的类名通常是 CSS 规则内容与原始类名混合的哈希值。这样,名称应该尽可能唯一。
举个例子,我们考虑一个用 构建的微前端esbuild
。esbuild
你需要一个插件(esbuild-css-modules-plugin
)并进行相应的配置更改以包含 CSS 模块。
使用 Piral 我们只需要调整 已经提供的配置piral-cli-esbuild
。我们删除标准 CSS 处理(使用 SASS)并将其替换为插件:
const cssModulesPlugin = require('esbuild-css-modules-plugin');
module.exports = function(options) {
options.plugins.splice(0, 1, cssModulesPlugin());
return options;
};
现在我们可以在代码中使用 CSS 模块,如上所示。
您可以在引用的演示存储库中找到两个冲突的微前端的示例solutions/css-modules
。
CSS 模块有几个缺点。首先,它对标准 CSS 进行了一些语法扩展。为了区分需要导入的样式(因此需要预处理/哈希处理),以及需要保持原样的样式(即,稍后无需导入即可使用),这很有必要。另一种方法是将 CSS 直接引入 JS 文件中。
CSS-in-JS
CSS-in-JS 最近名声不太好,不过我认为这有点误解。我更喜欢称之为“CSS-in-Components”,因为它把样式带到了组件本身。有些框架(Astro、Svelte 等)甚至通过其他方式直接支持这种做法。它经常被提及的缺点是性能——这通常是由于在浏览器中编写 CSS 造成的。然而,这并非总是必要的,在最好的情况下,CSS-in-JS 库实际上是构建时驱动的,也就是说,没有任何性能缺陷。
然而,当我们谈论 CSS-in-JS(或者说 CSS-in-Components)时,我们需要考虑现有的各种选择。为了简单起见,我只列举了三种:Emotion、Styled Components 和 Vanilla Extract。让我们看看它们如何帮助我们在一个应用程序中整合微前端时避免冲突。
情感
Emotion 是一个非常酷的库,它自带了 React 等框架的辅助工具,但无需将这些框架设置为先决条件。Emotion 可以进行非常出色的优化和预计算,并允许我们使用所有可用的 CSS 技术。
使用“纯” Emotion 相当容易;首先安装包:
npm i @emotion/css
现在您可以在代码中使用它,如下所示:
import { css } from '@emotion/css';
const tile = css`
background: blue;
color: yellow;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
`;
// later
<div className={tile}>Hello from Blue!</div>
该css
助手允许我们编写 CSS,并将其解析并放置在样式表中。返回的值是生成的类的名称。
如果我们想特别使用 React,我们还可以使用jsx
Emotion 的工厂(引入一个名为 的新标准 prop css
)或styled
助手:
npm i @emotion/react @emotion/styled
现在感觉样式已经是 React 本身的一部分了。例如,这个styled
辅助函数允许我们定义新的组件:
const Output = styled.output`
border: 1px dashed red;
padding: 1rem;
font-weight: bold;
`;
// later
<Output>I am groot (from red)</Output>
相比之下,css
辅助属性使我们能够稍微缩短符号:
<div css={`
background: red;
color: white;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
`}>
Hello from Red!
</div>
总而言之,这样生成的类名不会冲突,并且能够避免样式混淆,从而提供稳健性。styled
其中的辅助函数很大程度上受到了这个流行styled-components
库的启发。
您可以在引用的演示存储库中找到两个冲突的微前端的示例solutions/emotion
。
样式化组件
这个styled-components
库可以说是最流行的 CSS-in-JS 解决方案,并且常常是这类解决方案声誉不佳的原因。从历史上看,它实际上只是在浏览器中编写 CSS,但在过去几年里,他们确实极大地推动了这一进程。如今,你也可以在服务器端对所使用的样式进行非常漂亮的组合。
与安装(针对 React)相比,emotion
所需的软件包较少。唯一的缺点是类型设置是事后才考虑的——因此,为了充分享受 TypeScript 的乐趣,你需要安装两个软件包:
npm i styled-components --save
npm i @types/styled-components --save-dev
一旦安装完成,该库就可以完全使用了:
import styled from 'styled-components';
const Tile = styled.div`
background: blue;
color: yellow;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
`;
// later
<Tile>Hello from Blue!</Tile>
原理与 相同emotion
。因此,让我们探索另一种选择,尝试从一开始就实现零成本——而不是事后才想到。
您可以在引用的演示存储库中找到两个冲突的微前端的示例solutions/styled-components
。
香草精
我之前写过关于如何利用类型来更贴近组件(并避免不必要的运行时开销)的内容,这正是最新一代 CSS-in-JS 库所涵盖的内容。其中一个最有前景的库是@vanilla-extract/css
。
该库的使用主要有两种方式:
- 与您的捆绑器/框架集成
- 直接使用 CLI
在这个例子中,我们选择前者——并将其集成到esbuild
。为了使集成正常工作,我们需要使用@vanilla-extract/esbuild-plugin
包。
现在我们将其集成到构建过程中。使用piral-cli-esbuild
配置我们只需要将其添加到配置的插件中:
const { vanillaExtractPlugin } = require("@vanilla-extract/esbuild-plugin");
module.exports = function (options) {
options.plugins.push(vanillaExtractPlugin());
return options;
};
为了使 Vanilla Extract 正常工作,我们需要写入.css.ts
文件而不是普通的.css
or.sass
文件。这样的文件可能如下所示:
import { style } from "@vanilla-extract/css";
export const heading = style({
color: "blue",
});
这些都是有效的 TypeScript。最终我们会得到一个类名的导出——就像我们从 CSS 模块、Emotion 等等中得到的一样——你懂的。
所以最后,上面的样式将像这样应用:
import { heading } from "./Page.css.ts";
// later
<h2 className={heading}>Blue Title (should be blue)</h2>
这将在构建时完全处理 - 而根本不会产生运行时成本。
您可以在引用的演示存储库中找到两个冲突的微前端的示例solutions/vanilla-extract
。
您可能感兴趣的另一种方法是使用 CSS 实用程序库,例如 Tailwind。
CSS 实用程序(例如 Tailwind)
这本身就是一个类别,但考虑到 Tailwind 是这个类别中的主导工具,我想就只介绍 Tailwind 了。Tailwind 的主导地位甚至达到了这样的程度,以至于有些人会问:“你是写 CSS 还是 Tailwind?” 这与 2010 年左右 jQuery 在 DOM 操作领域的主导地位非常相似,当时人们会问:“这是 JavaScript 还是 jQuery?”
无论如何,使用 CSS 实用程序库的优势在于,样式会根据实际使用情况生成。这些样式不会发生冲突,因为它们始终由实用程序库以相同的方式定义。因此,每个微前端只需附带实用程序库中显示所需内容所需的部分即可。
对于 Tailwind 和 esbuild,我们还需要安装以下软件包:
npm i autoprefixer tailwindcss esbuild-style-plugin
esbuild 的配置比以前稍微复杂一些。它esbuild-style-plugin
本质上是一个用于 esbuild 的 PostCSS 插件;因此必须正确配置它:
const postCssPlugin = require("esbuild-style-plugin");
module.exports = function (options) {
const postCss = postCssPlugin({
postcss: {
plugins: [require("tailwindcss"), require("autoprefixer")],
},
});
options.plugins.splice(0, 1, postCss);
return options;
};
在这里,我们删除默认的 CSS 处理插件(SASS),并用 PostCSS 插件替换它 - 同时使用autoprefixer
和tailwindcss
PostCSS 的扩展。
现在我们需要添加一个有效的tailwind.config.js文件:
module.exports = {
content: ["./src/**/*.tsx"],
theme: {
extend: {},
},
plugins: [],
};
这基本上是配置 Tailwind 的最低要求。它只是提到tsx
应该扫描文件中 Tailwind 实用类的使用情况。找到的类将被放入 CSS 文件中。
因此,CSS 文件也需要知道生成/使用的声明应该包含在哪里。我们至少需要以下 CSS:
@tailwind utilities;
还有其他@tailwind
指令。例如,Tailwind 附带一个重置层和一个基础层。然而,在微前端中,我们通常不关心这些层。这属于应用外壳或编排应用程序的职责,而不是领域应用程序的职责。
然后,CSS 将被 Tailwind 中已经指定的类替换:
<div className="bg-red-600 text-white flex flex-1 justify-center items-center">Hello from Red!</div>
您可以在引用的演示存储库中找到两个冲突的微前端的示例solutions/tailwind
。
比较
到目前为止,几乎所有介绍的方法都适用于您的微前端。通常,这些解决方案也可以混合使用。一个微前端可以选择 Shadow DOM 方法,而另一个微前端则适合使用 Emotion 方法。第三个库则可能选择 Vanilla Extract 方法。
最终,唯一重要的是所选的解决方案是无碰撞的,并且不会带来(巨大的)运行时间成本。虽然有些方法比其他方法更高效,但它们都能提供所需的样式隔离。
方法 | 迁移工作 | 可读性 | 鲁棒性 | 性能影响 |
---|---|---|---|---|
习俗 | 中等的 | 高的 | 低的 | 没有任何 |
CSS 模块 | 低的 | 高的 | 中等的 | 无至低 |
影子 DOM | 低至中等 | 高的 | 高的 | 低的 |
CSS-in-JS | 高的 | 中到高 | 高的 | 无至高 |
顺风 | 高的 | 中等的 | 高的 | 没有任何 |
性能影响很大程度上取决于具体实现。例如,对于 CSS-in-JS,如果解析和组合在运行时完成,可能会产生很大的影响。如果样式已经预先解析,但仅在运行时组合,则影响可能较小。对于像 Vanilla Extract 这样的解决方案,则基本上不会产生任何影响。
对于影子 DOM,主要的性能影响可能是影子 DOM 内部元素的投影或移动(这基本上为零),以及style
标签的重新评估。然而,这些影响相当低,甚至可能带来一些性能提升,因为给定的样式总是切中要点,并且只针对影子 DOM 中显示的某个组件。
在示例中,我们有以下捆绑包大小:
方法 | 索引 [kB] | 页数 [kB] | 表[kB] | 总体 [kB] | 尺寸 [%] |
---|---|---|---|---|---|
默认 | 1.719 | 1.203 | 0.245 | 3.167 | 100% |
习俗 | 1.761 | 1.241 | 0.269 | 3.271 | 103% |
CSS 模块 | 2.149 | 2.394 | 0 | 4.543 | 143% |
影子 DOM | 10.044 | 1.264 | 0 | 11.308 | 357% |
情感 | 1.670 | 1.632 | 25.785 | 29.087 | 918% |
样式化组件 | 1.618 | 1.612 | 63.073 | 66.303 | 2093% |
香草精 | 1.800 | 1.257 | 0.314 | 3.371 | 106% |
顺风 | 1.853 | 1.247 | 0.714 | 3.814 | 120% |
对这些数字持保留态度,因为对于 Emotion 和 Styled Components 来说,运行时可以(甚至应该)共享。此外,给出的示例微前端确实很小(所有 UI 片段的总大小为 3kB)。对于更大的微前端来说,增长肯定不会像这里描述的那样严重。
Shadow DOM 解决方案体积的增加,可以通过我们提供的简单实用脚本来解释,该脚本可以轻松地将现有的 React 渲染封装到 Shadow DOM 中(无需生成新的树)。如果这样的实用脚本能够集中共享,那么其体积应该会更接近其他更轻量级的解决方案。
结论
在微前端解决方案中处理 CSS 并不困难——只需从一开始就以结构化和有序的方式进行,否则就会出现冲突和问题。通常,建议选择 CSS 模块、Tailwind 或可扩展的 CSS-in-JS 实现等解决方案。
鏂囩珷鏉ユ簮锛�https://dev.to/florianrappl/css-in-micro-frontends-4jai