乐趣区

关于前端:从零开始写一个微前端框架样式隔离篇

前言

自从微前端框架 micro-app 开源后,很多小伙伴都十分感兴趣,问我是如何实现的,但这并不是几句话能够说明确的。为了讲清楚其中的原理,我会从零开始实现一个繁难的微前端框架,它的外围性能包含:渲染、JS 沙箱、款式隔离、数据通信。因为内容太多,会依据性能分成四篇文章进行解说,这是系列文章的第三篇:款式隔离篇。

通过这些文章,你能够理解微前端框架的具体原理和实现形式,这在你当前应用微前端或者本人写一套微前端框架时会有很大的帮忙。如果这篇文章对你有帮忙,欢送点赞留言。

相干举荐

  • micro-app 仓库地址
  • simple-micro-app 仓库地址
  • 从零开始写一个微前端框架 - 渲染篇
  • 从零开始写一个微前端框架 - 沙箱篇
  • 从零开始写一个微前端框架 - 款式隔离篇
  • 从零开始写一个微前端框架 - 数据通信篇
  • micro-app 介绍

开始

前两篇文章中,咱们曾经实现了微前端的渲染和 JS 沙箱性能,接下来实现微前端的款式隔离。

问题示例

咱们先创立一个问题,验证款式抵触的存在。在基座利用和子利用上别离应用 div 元素插入一段文字,两个 div 元素应用雷同的 class 名text-color,别离在 class 中设置文字色彩,基座利用为red,子利用为blue

因为子利用是起初执行的,它的款式笼罩了基座利用,产生了款式抵触。

款式隔离实现原理

要实现款式隔离必须对利用的 css 进行革新,因为基座利用无法控制,咱们只能对子利用进行批改。

先看一下子利用被渲染后的元素结构:

子利用的所有元素都被插入到 micro-app 标签中,且 micro-app 标签具备惟一的 name 值,所以通过增加属性选择器前缀 micro-app[name=xxx] 能够让 css 款式在指定的 micro-app 内失效。

例如:
.test {height: 100px;}

增加前缀后变为:
micro-app[name=xxx] .test {height: 100px;}

这样 .test 的款式只会影响到 name 为 xxx 的 micro-app 的元素。

渲染篇中咱们将 link 标签引入的近程 css 文件转换为 style 标签,所以子利用只会存在 style 标签,实现款式隔离的形式就是在 style 标签的每一个 CSS 规定后面加上 micro-app[name=xxx] 的前缀,让所有 CSS 规定都只能影响到指定元素外部。

通过 style.textContent 获取款式内容是最简略的,但 textContent 拿到的是所有 css 内容的字符串,这样无奈针对独自规定进行解决,所以咱们要通过另外一种形式:CSSRules

当 style 元素被插入到文档中时,浏览器会主动为 style 元素创立 CSSStyleSheet 样式表,一个 CSS 样式表蕴含了一组示意规定的 CSSRule 对象。每条 CSS 规定能够通过与之相关联的对象进行操作,这些规定被蕴含在 CSSRuleList 内,能够通过样式表的 cssRules 属性获取。

模式如图:

所以 cssRules 就是由单个 CSS 规定组成的列表,咱们只须要遍历规定列表,并在每个规定的选择器前加上前缀micro-app[name=xxx],就能够将以后 style 款式的影响限度在 micro-app 元素外部。

代码实现

创立一个 scopedcss.js 文件,款式隔离的外围代码都将放在这里。

咱们下面提到过,style 元素插入到文档后会创立 css 样式表,但有些 style 元素 (比方动态创建的 style) 在执行款式隔离时还没插入到文档中,此时样式表还没生成。所以咱们须要创立一个模版 style 元素,它用于解决这种非凡状况,模版 style 只作为格式化工具,不会对页面产生影响。

还有一种状况须要非凡解决:style 元素被插入到文档中后再增加款式内容。这种状况常见于开发环境,通过 style-loader 插件创立的 style 元素。对于这种状况能够通过 MutationObserver 监听 style 元素的变动,当 style 插入新的款式时再进行隔离解决。

具体实现如下:

// /src/scopedcss.js

let templateStyle // 模版 sytle

/**
 * 进行款式隔离
 * @param {HTMLStyleElement} styleElement style 元素
 * @param {string} appName 利用名称
 */
export default function scopedCSS (styleElement, appName) {
  // 前缀
  const prefix = `micro-app[name=${appName}]`

  // 初始化时创立模版标签
  if (!templateStyle) {templateStyle = document.createElement('style')
    document.body.appendChild(templateStyle)
    // 设置样式表有效,避免对利用造成影响
    templateStyle.sheet.disabled = true
  }

  if (styleElement.textContent) {
    // 将元素的内容赋值给模版元素
    templateStyle.textContent = styleElement.textContent
    // 格式化规定,并将格式化后的规定赋值给 style 元素
    styleElement.textContent = scopedRule(Array.from(templateStyle.sheet?.cssRules ?? []), prefix)
    // 清空模版 style 内容
    templateStyle.textContent = ''
  } else {
    // 监听动静增加内容的 style 元素
    const observer = new MutationObserver(function () {
      // 断开监听
      observer.disconnect()
      // 格式化规定,并将格式化后的规定赋值给 style 元素
      styleElement.textContent = scopedRule(Array.from(styleElement.sheet?.cssRules ?? []), prefix)
    })

    // 监听 style 元素的内容是否变动
    observer.observe(styleElement, { childList: true})
  }
}

scopedRule办法次要进行 CSSRule.type 的判断和解决,CSSRule.type 类型有数十种,咱们只解决 STYLE_RULEMEDIA_RULESUPPORTS_RULE 三种类型,它们别离对应的 type 值为:1、4、12,其它类型 type 不做解决。

// /src/scopedcss.js

/**
 * 顺次解决每个 cssRule
 * @param rules cssRule
 * @param prefix 前缀
 */
 function scopedRule (rules, prefix) {
  let result = ''
  // 遍历 rules,解决每一条规定
  for (const rule of rules) {switch (rule.type) {
      case 1: // STYLE_RULE
        result += scopedStyleRule(rule, prefix)
        break
      case 4: // MEDIA_RULE
        result += scopedPackRule(rule, prefix, 'media')
        break
      case 12: // SUPPORTS_RULE
        result += scopedPackRule(rule, prefix, 'supports')
        break
      default:
        result += rule.cssText
        break
    }
  }

  return result
}

scopedPackRule 办法种对 media 和 supports 两种类型做进一步解决,因为它们蕴含子规定,咱们须要递归解决它们的子规定。
如:

@media screen and (max-width: 300px) {
  .test {background-color:lightblue;}
}

须要转换为:

@media screen and (max-width: 300px) {micro-app[name=xxx] .test {background-color:lightblue;}
}

解决形式也非常简略:获取它们的子规定列表,递归执行办法scopedRule

// /src/scopedcss.js

// 解决 media 和 supports
function scopedPackRule (rule, prefix, packName) {
  // 递归执行 scopedRule,解决 media 和 supports 外部规定
  const result = scopedRule(Array.from(rule.cssRules), prefix)
  return `@${packName} ${rule.conditionText} {${result}}`
}

最初实现 scopedStyleRule 办法,这里进行具体的 CSS 规定批改。批改规定的形式次要通过正则匹配,查问每个规定的选择器,在抉择前加上前缀。

// /src/scopedcss.js

/**
 * 批改 CSS 规定,增加前缀
 * @param {CSSRule} rule css 规定
 * @param {string} prefix 前缀
 */
function scopedStyleRule (rule, prefix) {
  // 获取 CSS 规定对象的抉择和内容
  const {selectorText, cssText} = rule

  // 解决顶层选择器,如 body,html 都转换为 micro-app[name=xxx]
  if (/^((html[\s>~,]+body)|(html|body|:root))$/.test(selectorText)) {return cssText.replace(/^((html[\s>~,]+body)|(html|body|:root))/, prefix)
  } else if (selectorText === '*') {// 选择器 * 替换为 micro-app[name=xxx] *
    return cssText.replace('*', `${prefix} *`)
  }

  const builtInRootSelectorRE = /(^|\s+)((html[\s>~]+body)|(html|body|:root))(?=[\s>~]+|$)/

  // 匹配查问选择器
  return cssText.replace(/^[\s\S]+{/, (selectors) => {return selectors.replace(/(^|,)([^,]+)/g, (all, $1, $2) => {
      // 如果含有顶层选择器,须要独自解决
      if (builtInRootSelectorRE.test($2)) {// body[name=xx]|body.xx|body#xx 等都不须要转换
        return all.replace(builtInRootSelectorRE, prefix)
      }
      // 在选择器前加上前缀
      return `${$1} ${prefix} ${$2.replace(/^\s*/, '')}`
    })
  })
}

应用

到此款式隔离的性能基本上实现了,接下来如何应用呢?

在渲染篇中,咱们有两处波及到 style 元素的解决,一个是 html 字符串转换为 DOM 构造后的递归循环,一次是将 link 元素转换为 style 元素。所以咱们须要在这两个中央调用 scopedCSS 办法,并将 style 元素作为参数传入。

// /src/source.js

/**
 * 递归解决每一个子元素
 * @param parent 父元素
 * @param app 利用实例
 */
 function extractSourceDom(parent, app) {
  ...
  for (const dom of children) {if (dom instanceof HTMLLinkElement) {...} else if (dom instanceof HTMLStyleElement) {
      // 执行款式隔离
+      scopedCSS(dom, app.name)
    } else if (dom instanceof HTMLScriptElement) {...}
  }
}

/**
 * 获取 link 近程资源
 * @param app 利用实例
 * @param microAppHead micro-app-head
 * @param htmlDom html DOM 构造
 */
export function fetchLinksFromHtml (app, microAppHead, htmlDom) {
  ...
  Promise.all(fetchLinkPromise).then((res) => {for (let i = 0; i < res.length; i++) {const code = res[i]
      // 拿到 css 资源后放入 style 元素并插入到 micro-app-head 中
      const link2Style = document.createElement('style')
      link2Style.textContent = code
+      scopedCSS(link2Style, app.name)
      ...
    }

    ...
  }).catch((e) => {console.error('加载 css 出错', e)
  })
}

验证

实现以上步骤后,款式隔离的性能就失效了,但咱们须要具体验证一下。

刷新页面,打印子利用的 style 元素的样式表,能够看到所有规定选择器的后面曾经加上 micro-app[name=app] 的前缀。

此时基座利用中的文字色彩变为红色,子利用为蓝色,款式抵触的问题解决了,款式隔离失效🎉。

结语

从下面能够看到,款式隔离实现起来不简单,但也有局限性。目前的计划只能隔离子利用的款式,基座利用的款式仍然能够影响到子利用,这一点没有 iframe 和 shadowDom 做的那么欠缺,所以最好的计划还是应用 cssModule 之类的工具或团队之间协商好款式前缀,从源头解决问题。

退出移动版