引言
本篇文章主要介绍的是关于CSS Sandbox
的一些事情,为什么要介绍这个呢?在我们日常的开发中,样式问题其实一直是一个比较耗时的事情,一方面我们根据 UI 稿不断的去调整,另一方面随着项目越来越大可能哪一次开发就发现——诶,我的样式怎么不起作用了,亦或是怎么被另一个样式所覆盖了。原因可能有很多:
- 不规范的命名导致重复
- 为了简单,直接添加全局样式的修改
- 样式的不合理复用
- 多个项目合并时,每个子项目都有自己的独立样式和配置,可能在自己项目中不存在这样的问题,但是合并以后互相影响造成了样式污染
- 第三方框架引入
- ……
而CSS Sandbox
正式为了隔离样式,从而解决样式污染的问题
应用场景
通过上述我们了解了样式污染产生的原因,从中我们也可以总结一下哪些场景时我们需要使用CSS Sandbox
进行样式隔离呢
- 微前端场景下的父子以及子子应用
- 大型项目以及复杂项目的样式冲突
- 第三方框架以及自定义主题样式的覆盖
- ……
常见的解决方案
既然说了这么多样式污染产生的原因和应用场景,那我们该如何解决他们呢,目前有以下几种解决方案,其实解决的核心还是不变的——使CSS选择器作用的Dom元素唯一
Tips:当我们在实际的开发中可以根据项目的实际情况进行选择
CSS in JS
看名字是不是感觉很高级,直译下就是用 JS 去写 CSS 样式,而不是写在单独的样式文件里。例如:
css in js
这和我们传统的开发思想很不一样,传统的开发原则是关注点分离
,就比如我们常说的不写行内样式
、行内脚本
,即 HTML、JS、CSS 都写在对应的文件里。
关于 CSS in JS 不是一个新兴的技术,他的热度主要出现于一些 Web 框架的发展,比如说:React,它所支持的 jsx 语法,可以让我们在一个文件中同时写 js、html 和 css,并且组件
内部管理自己的样式、逻辑,组件化开发的思想深入人心。
const style = {
color: 'red'
}
ReactDOM.render(
css in js
,
document.getElementById('main')
);
每个组件的样式由自身的 style 决定,不依赖也不影响外部,从这一点来看确实实现了样式隔离的效果。
关于Css in js
的库也有很多,比如说:
- styled-components
- polished
- ······
其中 styled-components 会动态生成一个选择器
import styled from 'styled-components'
function App() {
const Title = styled.h1`
font-size: 1.5em;
text-align: center;
color: palevioletred;
`;
return (
Hello World, this is my first styled component!
);
}
优缺点
| 优点 | • 没有作用域的样式污染问题(主要指的是通过写内行样式以及生成唯一的 CSS 选择器)
• 减少了无用样式的堆积,删除组件即删除对应的样式
• 通过导出定义的样式变量方便进行复用和重构 | |
---|---|
缺点 | • 内联样式不支持伪类和选择器等写法 |
• 代码的可读性比较差,违背了关注点分离的原则
• 运行时会消耗性能,动态生成 CSS(我们在写 CSS 时其实还是 js)
• 不能结合一些 CSS 预处理器,无法进行预编译 |
样式约定
通过约应用的命名前缀实现统一的开发和维护,比如说 BEM 的命名方式,通过对块、元素以及修饰符三者的命名来规范的描述一个组件
.dropdown-menu__item-button--disabled
优缺点
| 优点 | • 样式隔离
• 语义化强,组件可读性高 | |
---|---|
缺点 | • 命名太长 |
• 依赖于开发者的命名 |
预处理器
通过 CSS 预处理器可以处理很多独特的语法格式,比如:
- 可嵌套性
body {
with: 20px;
p {
color: red;
}
}
- 父选择器
body {
with: 20px;
&:hover {
with: 30px;
}
}
- 属性继承
.dev {
width: 200px;
}
span {
.dev
}
通过这些特殊的语法让 CSS 更容易解读和维护
一些常见的市场上的预处理器
- Sass
- Less
- Stylus
- PostCss
优缺点
| 优点 | • 可读性较好,方便理解和维护 DOM 结构
• 利用嵌套等方式,也可以大幅度解决样式污染的问题 | |
---|---|
缺点 | •需要增加额外的包,借助相关编译工具 |
Tips:通常与类似于 BEM 的命名方式结合,可以达到提高开发效率,增强可读性以及复用的效果
CSS Module
顾名思义就是将 CSS 进行模块化处理,编译好后可以避免样式被污染的问题,不过依赖于Webpack需要配置css-loader
等打包工具,以下是我在create-react-app
创建的项目中运行,由于其已经在 webpack 配置了css-loader
,因此在此篇文章中不展示具体配置
index.ts 文件
import style from './style.module.css'
function App() {
return (
Css Module
);
}
style.module.css 文件
.text {
color: red;
}
// 等同于
:local(.text) {
color: blue;
}
// 还有一种全局模式,此时不会进行编译
:global(.text) {
color: blue;
}
打包工具会同时把 style.text 以及 text 编译成独一无二的值
优缺点
| 优点 | • 学习成本较低,不依赖于人工约束
• 基本上能 100%解决样式污染问题
• 方便实现模块的复用 | |
---|---|
缺点 | • 只能在构建时使用,依赖于 css-loader 等 |
• 可读性差,在控制台调试时出现 hash 值不方便调试 |
Shadow DOM
它可以将一个隐藏且独立的 DOM 附加到一个元素上。当我们用 Shadow DOM 包裹一个元素后,其内样式不会对外部样式造成影响,外部样式也不会对其内部造成影响
// 创建一个shadow dom,我这里是通过ref去拿附着的节点,一般可以用document去拿
import './App.css'; // 定义了shadow-text的样式
function App() {
const divRef = useRef(null)
useEffect(() => {
if(divRef?.current) {
const { current } = divRef
const shadow = current.attachShadow({mode: 'open'}); // mode用来控制能否用js获取shaow dom内的元素
shadow.innerHTML = 'Here is some new text
';
}
}, [])
return (
);
}
外部样式无法影响 shadow dom 内部的样式
我们再来看下 shadow dom 内部得样式会影响外部样式吗?
function App() {
useEffect(() => {
if(divRef?.current) {
const { current } = divRef
const shadow = current.attachShadow({mode: 'open'});
shadow.innerHTML = 'Here is some new text
';
}
}, [])
return (
Hello World, this is my first styled component!
lalla1
);
}
但是也有例外,除了[:focus-within](https://developer.mozilla.org/zh-CN/docs/Web/CSS/:focus-within)
import { useEffect, useRef } from 'react'
import './App.css'; // .shadow-host:focus-within { background-color: yellow;}
function ShadowExample() {
const divRef = useRef(null)
useEffect(() => {
if(divRef?.current) {
const { current } = divRef
const shadow = current.attachShadow({mode: 'open'});
shadow.innerHTML = '';
}
}, [])
return (
Css Module
);
}
export default ShadowExample;
问题
正由于shadow dom
内的样式只会应用于内部,如果我们在 shadow dom 内部用了类似于antd
的Modal
这些创建于document.body
下的弹窗或者其他组件时,无法应用于antd
的样式,需要把antd
的样式放到上一层中。
优缺点
| 优点 | • 不需要引入额外的包,浏览器原生支持
• 严格隔离 | |
---|---|
缺点 | • 在某些场景下可能出现样式失效的问题,如上问题中的 shadow dom 内创建了全局的 Modal |
浅析 QianKun 中的 CSS SandBox
上面我们讲解了一些实现样式隔离的基本方案,那作为一个比较成熟的微前端框架QianKun
中又是怎么实现样式隔离方案的呢,以下的源码解析是在v2.6.3
的版本上研究的,首先通过看文档可以发现
在 QianKun 中 CSS SandBox 有两种模式:
strictStyleIsolation
——严格沙箱模式experimentalStyleIsolation
——实验性沙箱模式
strictStyleIsolation
需要注意的是该方案不是一个无脑的解决方案,开启后需要进行一定的适配
下面我们来详细介绍下该模式:
我们设置strictStyleIsolation
为true
时,QianKun
采用的是Shadow DOM
方案,核心就是为每个微应用包裹上一个 Shadow DOM 节点。接下来我们看下是怎么实现的
先来个流程图我们有个大致的概念:
**registerMicroApps**
:注册子应用,同时调用 single-spa 中的registerApplication
进行注册**loadApp**
:加载子应用,初始化加载子应用的 Dom 结构,创建样式沙箱和 JS 沙箱等,同时返回不同阶段的生命周期**createElement**
:样式沙箱的具体实现,主要分为两种strictStyleIsolation
和experimentalStyleIsolation
registerMicroApps:注册子应用
export function registerMicroApps(apps: Array>,lifeCycles?: FrameworkLifeCycles,) {
...
registerApplication({
name,
app: async () => {
...
// 加载微应用的具体方法,暴露bootstrap、mount、unmount等生命周期以及一些其他配置信息
const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();
...
},
// 子应用的激活条件
activeWhen: activeRule
...
});
});
}
调用 single-spa 的 registerApplication 对应用进行注册,并且在应用激活的时候调用 app 的回调,其中最主要的是loadApp
加载微应用的具体方法
一些参数的说明:
apps
:微应用的注册信息
lifeCycles
:微应用的一些生命周期钩子
loadApp:加载子应用
function loadApp (app: LoadableApp, configuration: FrameworkConfiguration = {},lifeCycles?: FrameworkLifeCycles) {
...
/**
* 将操作权交给主应用控制,返回结果涉及CSS SandBox和JS SandBox
* template --template的为link替换为style注释script的HTML模版
* execScripts --脚本执行器,让指定的脚本(scripts)在规定的上下文环境中执行,只做了解暂时不讲
* assetPublicPath -- 静态资源地址,只做了解暂时不讲
*/
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// 给子应用包裹一层div后的子应用html模版, 是模版字符串的形式
const appContent = getDefaultTplWrapper(appInstanceId)(template);
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
// 是否开启了严格模式
strictStyleIsolation,
// 是否开启实验性的样式隔离
scopedCSS,
// 根据应用名生成的唯一值,唯一则为appName,不唯一则为appName_[count]为具体数量,重复会count++
appInstanceId,
);
...
// 下面还有一些生命周期的处理方法
}
Q1:到现在不知道还有没有人记得我们开启严格样式模式是需要做啥?
!!!把子应用的 Dom 结构放到 Shadow dom 中与主应用隔离,防止样式污染
Q2:那我们咋拿到子应用的 Dom 结构呢?
没错就是通过import-html-entry
库的import-html-entry
方法,有兴趣给看下关于import-html-entry 解析
没错我们拿到了template
、execScripts
和assetPublicPath
,这里我们不对后两个进行讲解,聚焦到template
上:
对比下子应用原来的 HTML 结构
可以发现我们拿到的template
是link
标签变成style
标签注释了script
的 HTML 模版,其中就有我们需要的子应用的 Dom 结构。
拿到以后 QianKun 里又在template
上包裹了一层 Div 形成一个新的 HTML 结构的模版字符串,这是为什么呢?主要是为了在主应用中标识该节点下的内容为子应用,当然在后面我们也需要它进行特别的处理,这个后面讲到的时候再说。因此我们现在拿到的appContent
长成这个样子:
这个 div 的 id 是唯一的哈!!!
那我们现在是不是已经做好了前期准备,现在我们需要进入最后一个步骤,把子应用的这个 Dom 结构挂载到一个 shadow dom 上,这就要用到createElement
方法。
进入createElement
方法前我们先来看下目前的参数值:
- appContent:包裹了一层 id 唯一的 div,具体如上所示
- strictStyleIsolation:
true
- scopedCSS:
false
- appInstanceId:
react16
createElement:添加 shadow dom
那我们现在如何去创建一个 shadow dom,在前面关于 shadow dom 的讲解中我们知道,创建一个 shadow dom 我们需要两个东西:
一、挂载的 Dom 节点
二、需要添加到 shadow dom 的内容
那我们从哪里去找呢,根据传进来的参数吧,我们无疑是要对appContent
进行处理了,回顾下appContent
有什么,包裹了一层 div 的子应用的 HTML 模版是吧,自然而然的我们就可以以外面的 div 为挂载的 dom 节点,拿子应用的 HTML 模版为需要添加到 shadow dom 的内容,即:
但是问题又来了, 目前的appContent
是模版字符串嘞,我们咋办?这边 QianKun 的处理方案是:
这只是个大致流程,下面让我们跟着这样的思想看下代码里处理:
function createElement(appContent: string,strictStyleIsolation: boolean,scopedCSS: boolean,appInstanceId: string) {
...
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
const appElement = containerElement.firstChild as HTMLElement;
// 严格样式沙箱模式
if (strictStyleIsolation) {
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
// 创建shadow dom节点
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// 兼容低版本
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
...
// 此处省略了开启experimentalStyleIsolation的处理方法
...
return appElement;
}
这里有个很有意思的是:
appContent 以 innerHTML 变成 dom 结构后,HTML 模版中的、
以及
会被去掉
最后我们再来看下子应用挂载到主应用的 Dom 结构:
笔者在实践的过程中也遇到了一些问题:
1、微应用中使用相对路径引入图片出现加载资源 404 的问题,这边笔者没有进行过多的尝试可以参考下官方的:https://qiankun.umijs.org/zh/faq#为什么微应用加载的资源会-404
2、还有一个问题就是 react 中动态打开 Modal 失效的问题,原因可以看下‣,大概看了下和 React 的事件机制有关,即使是设置弹窗默认开启,也会出现之前上面提到的,样式丢失的问题
experimentalStyleIsolation
我们设置experimentalStyleIsolation
为true
时,QianKun
采用的是Runtime css transformer
动态加载/卸载样式表方案,为子应用的样式表增加一个特殊的选择器从而限定影响范围,类似以下结构:
// 假设应用名是 react16