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

前言

自从微前端框架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之类的工具或团队之间协商好款式前缀,从源头解决问题。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理