前言
最近给公司新的后台项目使用qiankun搭了框架,在看文档的时候发现应用启动方法start的参数中有这样的参数:
sandbox - `boolean` | `{ strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }` // 可选,是否开启沙箱,默认为 `true`
结合官网的说明,strictStyleIsolation表示严格的样式隔离,其实就是使用shadowDom将各个子应用包起来,而experimentalStyleIsolation是给所有的样式选择器前面都加了当前挂载容器,看下官网示例就很容易明白了:
// 假设应用名是 react16 .app-main { font-size: 14px; } div[data-qiankun-react16] .app-main { font-size: 14px; }
好奇心驱使着我去探究下究竟是怎么实现的,下面就看下qiankun是怎么实现的吧。
qiankun实现
获取所有style标签并对每个标签进行处理
如果设置了样式隔离,qiankun会把子应用中的所有style标签获取到,然后对其遍历进行一波操作,接下来就是css.js文件中一系列关键逻辑了,css.process函数:
// loader.js var styleNodes = appElement.querySelectorAll('style') || []; _forEach(styleNodes, function (stylesheetElement) { css.process(appElement, stylesheetElement, appName); }); // css.js export var process = function process(appWrapper, stylesheetElement, appName) { // ... // 如果是外部样式表就给个提示,不处理这种 if (stylesheetElement.tagName === 'LINK') { console.warn('Feature: sandbox.experimentalStyleIsolation is not support for link element yet.'); } // ... var tag = (mountDOM.tagName || '').toLowerCase(); // 当样式是内联样式时对齐进行进一步的处理 if (tag && stylesheetElement.tagName === 'STYLE') { var prefix = "".concat(tag, "[").concat(QiankunCSSRewriteAttr, "=\"").concat(appName, "\"]"); processor.process(stylesheetElement, prefix); } };
处理style节点
上面的函数会对传入的style元素进行判断,如果是样式标签才会调用processor.process进行增加前缀的操作,先来看下processor.process函数的前半段:
function process(styleNode) { var _this = this; // 获取函数的第二个参数作为前缀 var prefix = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; var _a; // 当该style标签内有内容时进行操作 if (styleNode.textContent !== '') { var textNode = document.createTextNode(styleNode.textContent || ''); // 这里的swapNode是之前创建好的空style,起到工具人的作用,完事后会把里面的内容清空 this.swapNode.appendChild(textNode); // 通过StyleElement.sheet API获取样式表对象CSSStyleSheet var sheet = this.swapNode.sheet; // 从CSSStyleSheet的rules或cssRules字段获取所有样式规则,并将这个伪数组转为数组 var rules = arrayify((_a = sheet === null || sheet === void 0 ? void 0 : sheet.cssRules) !== null && _a !== void 0 ? _a : []); // 给每条css规则的选择器增加前缀后返回修改后的css var css = this.rewrite(rules, prefix); // eslint-disable-next-line no-param-reassign // 将处理后的css放到传入的style元素中 styleNode.textContent = css; // cleanup // 工具人完成了使命,用完清理干净 this.swapNode.removeChild(textNode); return; } }
CSSStyleSheet数据格式
主要利用StyleElement.sheet获取标签内的所有css规则代码,然后对其进行处理,具体处理逻辑之后会看到。这部分代码执行的前提是传入的style标签一开始就是有内容的,当style标签的内容是动态传入时就会执行到后半段代码了:
function process(styleNode) { var _this = this; // 获取函数的第二个参数作为前缀 var prefix = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; var _a; var mutator = new MutationObserver(function (mutations) { var _a; for (var i = 0; i < mutations.length; i += 1) { var mutation = mutations[i]; // ScopedCSS.ModifiedTag是之前已经设置好的Symbol对象 'Symbol(style-modified-qiankun)' 值应该是起到内部节点变更后重复触发mutaionObserver监听的作用 if (ScopedCSS.ModifiedTag in styleNode) { return; } if (mutation.type === 'childList') { // 下面的代码和前半段一样,只是少了工具人的参与,为什么呢🤔? var _sheet = styleNode.sheet; var _rules = arrayify((_a = _sheet === null || _sheet === void 0 ? void 0 : _sheet.cssRules) !== null && _a !== void 0 ? _a : []); var _css = _this.rewrite(_rules, prefix); // eslint-disable-next-line no-param-reassign styleNode.textContent = _css; // eslint-disable-next-line no-param-reassign styleNode[ScopedCSS.ModifiedTag] = true; } } }); // 源码在这里加了段注释,大意说因为节点删除后监听会被移除,因此就没有弄一个清理的函数 // 官网地址: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/disconnect // 监听当前style标签 mutator.observe(styleNode, { childList: true }); }
这部分代码主要是当style开始是空的,防止之后向里面添加样式代码,使用MutaionObserver来对空的style标签监听,并在节点变更时执行css选择器增加前缀的工作。
对style节点内每一条样式做处理
rewrite函数中遍历所有样式,根据每条样式的CSSStyleRule.type判断当前是什么类型的选择器(普通选择器,@media,@supports选择器),对@media和@supports选择器是递归使用rewrite处理内部的节点,主要看下处理普通样式的ruleStyle函数:
function ruleStyle(rule, prefix) { var rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm; var rootCombinationRE = /(html[^\w{[]+)/gm; var selector = rule.selectorText.trim(); var cssText = rule.cssText; // 下面两个判断是针对根选择器(body、html、:root)的判断的,处理逻辑是会将根选择器替换为prefix,也就是当前子应用挂载容器的选择器 if (selector === 'html' || selector === 'body' || selector === ':root') { return cssText.replace(rootSelectorRE, prefix); } // 如果选择器是html开头并且带有后代元素 if (rootCombinationRE.test(rule.selectorText)) { // 如果是不标准的用法,如html + body就不会对根选择器做处理,否则会把html删除 var siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm; if (!siblingSelectorRE.test(rule.selectorText)) { cssText = cssText.replace(rootCombinationRE, ''); } } cssText = cssText.replace(/^[\s\S]+{/, function (selectors) { // 以字符串或,开始,到下一个逗号之前结束,第一个参数是匹配到的整个字符串 // 第二个参数是空或者, 第三个参数是,之后到下一个逗号或结尾的内容 return selectors.replace(/(^|,\n?)([^,]+)/g, function (item, p, s) { // 处理带有根选择器的分组选择器 如:div,body,span { ... } if (rootSelectorRE.test(item)) { return item.replace(rootSelectorRE, function (m) { // 处理body、html 及 *:not(:root) 这样的情况,将根选择器直接转为前缀 var whitePrevChars = [',', '(']; if (m && whitePrevChars.includes(m[0])) { return "".concat(m[0]).concat(prefix); } return prefix; }); } // 在选择器之前加上前缀,并且删除之前选择器前面的所有空格 return "".concat(p).concat(prefix, " ").concat(s.replace(/^ */, '')); }); }); return cssText; }
主要逻辑就是使用正则表达式将html、body、:root这些根选择器替换为传入的前缀(其实就是子应用挂载点容器的css选择器),在常规的选择器之前加上自己挂载容器的独特前缀,然后再将处理后的css返回,这样就可以做到当前子应用的样式不会影响到其他子应用和父应用了。
小结
qiankun内处理experimentalStyleIsolation选项的主要逻辑并不复杂:遍历所有的style标签,分别获取每个标签内所有样式的选择器和css文本(利用StyleElement.sheet获取CSSStyleSheet,从而获取各条样式的具体信息),并对其进行处理,根选择器替换为前缀,普通的选择器前面直接加上前缀。
使用postcss对css进行处理
qiankun的原理是在主应用加载之后去请求子应用的相关资源,并把子应用挂载到自身的一个挂载点下,基于这种机制,只能在运行时对dom或字符串进行处理。如果我们开发时遇到给应用内的所有css选择器增加前缀的需求时(如,为别人页面添加一个插件时,防止自己编写的插件影响到页面原有样式),可以借助postcss生成css的ast来实现对css的相关操作。
css的ast简介
和babel可以生成js的ast类似,postcss也可以生成css的ast,从官网搬运来节点类型的解释:
Root
: ast的根节点,代表着每个css文件AtRule
: 每个以@开头选择器所在的RuleRule
: css选择器和声明共同组成了一个CSS规则,例如input, button {font-size: 20px;}
其中的大括号就是声明Declaration
: 声明,每个Rule中选择器后面跟着的大括号Comment
: 注释,可存在于选择器、@选择器的参数、css样式键值对的value中,节点中的注释会被保存在node的raws属性中
各种节点会有一些属性和API,包括parent、type、first、text、raws、clone()、replaceWith(newNode)、walkRules(rule)等,具体可以参考官方文档
这里介绍一些下面会使用到的:
-
parent:当前节点父节点
-
type:节点类型
-
clone: 上面提到的每一种节点都有的方法,作用是拷贝节点
-
replaceWith:也是每种节点都有的方法,将当前节点替换为传入的节点
-
walkRules:遍历ast中的每个Rule节点
编写给选择器添加前缀的postcss plugin
如果只是在node环境使用postcss插件,可以这么写(参考自官网例子):
const postcss = require('postcss') const plugin = () => { return { postcssPlugin: 'to-red', // 必须有 Rule (rule) { // 遍历每个css文件的Rule节点 console.log(rule.toString()) }, Declaration (decl) { // 遍历每个css文件的Declaration节点 console.log(decl.toString()) decl.prop === 'color' && (decl.value = 'red') } } } plugin.postcss = true // 必须有 postcss([plugin]).process('a { color: black; font-size: 100px; }').then(res => { console.log(res.toString()) // 将转换后的结果输出 }) // 输出:'a { color: red; font-size: 100px; }'
如果要编写可以放入webpack.config.js中的postcss插件的话,需要借助postcss.plugin方法实现(参照官网最新插件写法老是报错,就使用旧的写法了),直接上为每个css选择器加前缀的代码:
module.exports = postcss.plugin('postcss-add-css-prefix', function(opts = {}) { const { prefix = '' } = opts // 接收两个参数,第一个是每个css文件的ast,第二个参数中可获取转换结果相关信息(包括当前css文件相关信息) function plugin(css, result) { if (!prefix) return; // 没传入prefix,不执行下面的逻辑 css.walkRules(rule => { // 遍历当前ast所有rule节点 const { selector } = rule // 只有当节点是ast根节点直属子节点时才添加前缀 // 简单做了容错处理,只要带有根选择器的都不添加前缀,本身带有前缀了也不添加 // 加了个flag,防止节点更新后重复执行该逻辑进入死循环 if (rule.parent.type === 'root' && !(selector.includes(':root') || selector.includes('body') || selector.includes('html') || selector.includes(prefix)) && !rule.flag) { rule.flag = true const clone = rule.clone() clone.selector = `${prefix} ${selector}` rule.replaceWith(clone) } }) } return plugin })
postcss.plugin接收两个参数,第一个是插件名,第二个参数是回调函数,该回调函数接收在postcss plugin中配置时传入的参数,并返回一个函数,返回的函数plugin接收两个参数:第一个是每个css文件解析之后的ast,第二个是ast转换之后的结果信息。plugin函数中使用walkRules处理ast的所有Rule节点(walkDecls处理所有declaration节点,walk处理所有类型的节点,参考官方文档),从Rule节点中获取选择器修改后将修改后的节点替换之前的节点。另外,可以根据plugin的第二个参数(result.opts.file字段)获取当前css文件信息,为每一个css文件内的选择器添加不同的前缀。
最后在postcss.config.js或者webpack.config.js等文件的配置中注册刚刚编写的插件就可以了(以postcss.config.js为例):
// postcss.config.js const addCssPrefix = require('./addCssPrefix') module.exports = { plugins: [ addCssPrefix({ prefix: 'body' }) ] }