深入浅出微前端(qiankun)

微前端的理解

微前端的核心理念是将前端应用程序看作是一个整体,由多个独立的部分组成。每个部分被视为一个微前端应用,它们可以具有自己的技术栈、开发流程和团队组织。这种方式使得团队可以独立开发和部署各个子应用,减少了协调和合并的复杂性。

为什么 Iframe 无法胜任微前端的工作?

IFrame 在传统的前端开发中是一种常见的技术,用于在页面中嵌入其他网页或应用程序。然而,在微前端架构中,IFrame 并不是一个理想的选择,主要是因为以下几个方面的限制:

  1. 隔离性和通信复杂性:IFrame 本身提供了一种隔离的环境,但这也带来了通信和数据交互的复杂性。由于每个子应用都在独立的 IFrames 中运行,它们之间的通信需要通过特定的机制,如消息传递,而这增加了开发和维护的复杂性。
  2. 性能和加载时间:每个 IFrames 都需要加载和渲染独立的 HTML、CSS 和 JavaScript。这意味着在加载微前端应用时,需要同时加载多个 IFrames,导致额外的网络请求和页面资源占用,可能会影响性能和加载时间。
  3. 样式和布局限制:IFrame 的内容在页面中是独立的,它们具有自己的 CSS 样式和布局上下文。这导致在微前端架构中难以实现全局样式的一致性,以及子应用之间的布局和交互的协调问题。
  4. 浏览器安全性限制:由于安全策略的限制,IFrame 之间的跨域通信可能受到限制,特别是在涉及跨域资源访问和共享数据时。这可能导致在微前端架构中需要处理复杂的安全性问题。

鉴于以上限制,微前端架构通常采用其他技术手段来实现子应用的拆分和集成,例如使用 Web Components、JavaScript 模块加载器等。这些技术能够提供更好的隔离性、通信机制和性能优化,使得微前端架构更具可行性和灵活性。

微前端运行原理

  • 监听路由变化
  • 匹配子应用
  • 加载子应用
  • 渲染子应用

监听路由变化

  1. 监听 hash 路由: window.onhashchange
  2. 监听 history 路由

history.go、history.back、history.forward 使用 popstate 事件 window.onpopstate

监听的方式

js复制代码window.addEventListener('popstate', () => {})

重写: pushState、replaceState 需要通过函数重写的方式进行 劫持

js复制代码const rawPushState = window.history.pushState
window.history.pushState = function(...args) {
  rawPushState.apply(window.history, args)

  // 其他逻辑
}

const rawReplaceState = window.history.replaceState
window.history.replaceState = function(...args) {
  rawReplaceState.apply(window.history, args)

  // 其他逻辑
}

在 Vue 项目中,我们通过 this.router.push会触发‘history.pushState‘事件,this.router.push 会触发 `history.pushState` 事件,this.router.push会触发‘history.pushState‘事件,this.router.replace 会触发 history.replaceState 事件。

匹配子应用

监听路由的变化后,拿到当前路由的路径 window.location.pathname,然后根据 registerMicroApps 的参数 apps 查找子应用。因为子应用都配置了 activeRule。

js复制代码// 如果当前的 pathname 以 activeRule 开头,表明匹配到了子应用

const currentApp = apps.find(app => window.location.pathname.startWith(app.activeRule))

加载子应用

当我们找到了与当前路由匹配的子应用,接着就去加载这个子应用的资源。

js复制代码function handleRouter = async () => {
  // 匹配子应用

  // 加载资源
  const html = await fetch(currentApp.entry.then(res => res.text())

  // 将 html 渲染到指定的容器内
  const container = document.querySelector(currentApp.container)
}

这个时候,我们就拿到了子应用的 html 文本。

但是我们不能给直接通过 container.innerHTML = html 将文本放到容器内,这样是无法显示的。

注意 浏览器处于安全考虑,放到页面上的 html 如果包含了 js 脚本,它是不会去执行 js 的。我们需要手动处理 script 脚本。

importHTML 加载资源/处理脚本

我们来封装一个函数 importHTML,专门来处理 html 文本。(qiankun内部引用的 import-html-entry 就是做这个事的。)

我们可以把加载子应用资源的 fetch 请求放到 importHTML 函数中,它还有如下几个功能:

  • 将获取到的 html 文本,放到 template DOM节点中
  • 获取所有的 Script 脚本
  • 执行所有的 Script 脚本
js复制代码export const importHTML = url => {
  const html = await fetch(currentApp.entry).then(res => res.text()

  const template = document.createElement('div')

  template.innerHTML = html

  const scripts = template.querySelectAll('script')

  const getExternalScripts = () => {
    console.log('解析所有脚本: ', scripts)
  }

  const execScripts = () => {}

  return {
    template, // html 文本
    getExternalScripts, // 获取 Script 脚本
    execScripts, // 执行 Sript 脚本
  }
}

script 脚本分为 内联 脚本和外链脚本,这里需要分开处理,拿到内联脚本后,获取内容可以通过 eval 直接处理。如果是含有 scr 的 script 脚本,还需要拿到 src 的值,通过 fetch 去加载脚本。

我们在 getExternalScripts 方法中来处理

js复制代码const getExternalScripts = async () => {
  return Promise.all(Array.from(scripts).map(script => {
    // 获取 scr 属性
    const src = script.getAttribute('src')

    if (!src) {
      return Promise.resolve(script.innerHTML)
    } else {
      return fetch(src.startWith('http') ? src : `${url}${src}`).then(res => res.text())
    }
  }))
}

然后我们就可以通过 execScripts 方法去调用 getExternalScripts,拿到所有的脚本内容后,执行!

js复制代码const execScripts = async () => {
  const scripts = await getExternalScripts() 

  scripts.forEach(code => {
    eval(code)
  })
}

qiankun

http://qiankun.umijs.org/zh

我们看 qiankun 的依赖,可以发现 qiankun 是基于 single-spa 实现的,通过 import-html-entry 包处理 html / css

json复制代码"dependencies": {
  "import-html-entry": "^1.14.0",
  "single-spa": "^5.9.2"
  // ...
},

微前端的子项目是怎么引入到主项目里的

js复制代码registerMicroApps(
  [
    {
      name: 'react16',
      entry: '//localhost:7100',
      container: '#subapp-viewport',
      loader,
      activeRule: '/react16',
    },
    {
      name: 'react15',
      entry: '//localhost:7102',
      container: '#subapp-viewport',
      loader,
      activeRule: '/react15',
    },
  ]
)
  1. 容器指定路由匹配规则加载子应用,一旦路径匹配就会加载子应用资源
  2. 子应用打包输出格式为 umd,并且要允许跨域
js复制代码// vue.config.js

module.exports = {
  devServer: {
    // ...
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  // 自定义webpack配置
  configureWebpack: {
    output: {
      // 把子应用打包成库文件、格式是 umd
      library: `${name}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
}

umd

umd全称是UniversalModuleDefinition,是一种通用模块定义格式,通常用于前端模块化开发中。

由于不同的模块化规范定义不同,为了让各种规范的模块可以通用,在不同的环境下都可以正常运行,就出现了umd这个通用格式。

特点

umd 格式是一种既可以在浏览器环境下使用,也可以在 node 环境下使用的格式。它将 CommonJS、AMD以及普通的全局定义模块三种模块模式进行了整合。

js复制代码(function (global, factory) {
  // CommonJS
	typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
  // AMD
	typeof define === 'function' && define.amd ? define(['exports'], factory) :
  // Window
	(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.qiankun = {}));
}(this, (function (exports) {
  // 应用代码
})));

为什么 qiankun 要求子应用打包为 umd 库格式呢?

主要是为了主应用能够拿到子应用在 入口文件 导出的 生命钩子函数,这也是主应用和子应用之间通信的关键。

  • bootstrap
  • mount
  • unmount

获取子应用资源 - import-html-entry

HTML Entry + Sandbox 是 qiankun 区别于 single-spa 的主要两个特性。

single-spa和qiankun最大的不同,大概就是qiankun实现了html entry,而single-spa只能是js entry

通过 import-html-entry,我就能像 iframe 一样加载一个子应用,只需要知道其 html 的 url 就能加载到主应用中。

importHTML 几个核心方法:

首先importHTML的参数为需要加载的页面url,拿到后会先通过 fetch方法 读取页面内容。

js复制代码import importHTML from 'import-html-entry';

importHTML('./subApp/index.html')
  .then(res => {
    console.log(res.template);

    res.execScripts().then(exports => {
      const mobx = exports;
      const { observable } = mobx;
      observable({
        name: 'kuitos'
      })
    })
});

返回值

  • template - string - 处理过的 HTML 模板。
  • assetPublicPath - string - 资源的公共途径。
  • getExternalScripts - Promise - 来自模板的脚本 URL。
  • getExternalStyleSheets - Promise - 来自模板的 StyleSheets URL。
  • execScripts - (sandbox?: object, strictGlobal?: boolean, execScriptsHooks?: ExecScriptsHooks): - Promise - the return value is the last property on window or proxy window which set by the entry - script. sandbox - optional, Window or proxy window. strictGlobal - optional, Strictly enforce the sandbox.

processTpl

它会解析html的内容并且删除注释,获取style样式及script代码。通过大量的正则 + replace,每一个步骤都做了很多适配,比如获取script脚本,需要区分该script是不是entry script,type是JavaScript还是module,是行内script还是外链script,是相对路径还是绝对路径,是否需要处理协议等等。

processTpl的返回值有 template,script,style,entry。

JS 沙箱

JavaScript 沙箱是一种安全机制,用于隔离和限制 JavaScript 代码的执行环境,以防止恶意代码或意外行为对系统造成损害。沙箱提供了一种受控的环境,限制了代码的访问权限和执行能力,确保代码只能在受限制的范围内操作。

JavaScript 沙箱通常用于以下情况:

  1. 在多租户环境中,确保不同用户或组织的代码相互隔离,防止相互干扰或访问敏感信息。
  2. 在第三方代码或插件中使用,以确保其代码不会对宿主环境造成潜在的安全漏洞或冲突。
  3. 在应用程序中执行用户提供的代码,例如在线代码编辑器或脚本执行环境,以防止恶意代码对用户数据或系统进行攻击。
  4. 在浏览器中执行不受信任的代码,例如浏览器插件或扩展,以保护用户的隐私和安全。

JavaScript 沙箱通过限制代码的访问权限、提供隔离的执行环境、使用安全策略和沙箱沙盒技术等手段来实现。常见的 JavaScript 沙箱技术包括沙盒环境、Web Worker、iframe、JavaScript 虚拟机等。这些技术通过限制代码的执行权限、提供独立的运行环境、隔离全局上下文等方式来确保代码的安全执行。

快照沙箱-SnapshotSandbox

缺点:

  • 遍历 window 上所有属性,性能差
  • 同一时间只能激活一个微应用
  • 污染全局 window

优点:

可以支持不兼容Proxy的浏览器。

先了解 SnapshotSandbox 的功能

  1. 我们激活沙箱后,在 window 上修改的所有属性,都应该存起来,在下一次激活时,需要还原上次在 window 上修改的属性
  2. 失活后,应该将 window 还原成激活前的状态

我们来举个例子

js复制代码// 激活前
window.city = 'Beijing'

// 激活
sanbox.active()

window.city = '上海'

// 失活
sanbox.inactive()
console.log(window.city) // 打印 'Beijing'

// 再激活
console.log(window.city) // 打印 '上海'

接下来,实现一个简易版的 SnapshotSandbox

  1. SnapshotSandbox 能够还原 window 和记录自己以前的状态,那么就需要两个对象来存储这些信息
js复制代码1. windowSnapshot 用来存储沙箱激活前的 window

2. modifyPropsMap 用来存储沙箱激活期间,在 window 上修改过的属性
  1. 沙箱需要两个方法及作用
js复制代码1. sanbox.active() // 激活沙箱

  - 保存 window 的快照

  - 再次激活时,将 window 还原到上次 active 的状态

2. sanbox.inactive() // 失活沙箱

  - 记录当前在 window 上修改了的 prop

  - 还原 window 到 active 之前的状态

我们先来实现沙箱内部细节:

js复制代码class SnapshotSandbox {

  constructor() {
    this.windowSnapshot = {}

    this.modifyPropsMap = {}
  }

  active() {
    // 1. 保存 window 的快照
    for (let prop in window) {
      if (window.hasOwnProperty(prop)) {
        this.windowSnapshot[prop] = window[prop]
      }
    }

    // 2. 再次激活时,将 window 还原到上次 active 的状态,modifyPropsMap 存储了上次 active 时在 widow 上修改了哪些属性
    Object.keys(modifyPropsMap).forEach(prop => {
      window[prop] = this.modifyPropsMap[prop]
    })
  }

  inactive() {
    for(let prop in window) {
      if (window.hasOwnProperty(prop)) {
        // 两者不相同,表示修改了某个 prop 记录当前在 window 上修改了的 prop
        if (window[prop] !== this.windowSnapshot[prop]) {
          this.modifyPropsMap[prop] = window[prop]
        }

        // 还原 window
        window[prop] = this.windowSnapshot[prop]
      }
    }
  }
}

我们来验证一下,首先设置 window.city 一个初始值 beijing,然后初始化 沙箱,在第一次激活后,修改了 window.city 为 上海,那么应该在失活后,打印 beijing,再次激活时,window.city 是 上海

js复制代码window.city = 'beijing'

const ss = new SnapshotSandbox()

console.log('window.city0 ', window.city)

ss.active() // 激活

window.city = '上海'

console.log('window.city1 ', window.city) // 上海

ss.inactive()

console.log('window.city2 ', window.city) // beijing

ss.active()

console.log('window.city3 ', window.city) // 上海

ss.inactive()
console.log('window.city4 ', window.city) // beijing

ss.active()
console.log('window.city5 ', window.city) // 上海

不支持多个应用同时运行,因为污染了全局 window

js复制代码window.city = 'beijing'

const ss = new SnapshotSandbox()
ss.active() // 激活
window.city = '上海'

const ss1 = new SnapshotSandbox()
ss1.active() // 激活

window.city = '广州'

console.log(window.city) // 广州

Legacy沙箱-LegacySandbox(单例)

  • 不需要遍历 window 上的所有属性,性能比快照沙箱要好
  • 基于 proxy 实现,依然操作了 window,污染了全局,同一时间只能运行一个应用
  • 兼容性没有快照沙箱好

功能和 快照沙箱 一样,但内部实现是通过 proxy 实现的。

ProxySandbox 沙箱(多例)

  • 基于 proxy 代理对象,不需要遍历 window,性能要比快照沙箱好
  • 支持多个应用
  • 没有污染全局 window
  • 应用失活后,依然可以获取到激活时定义的属性。

主要实现在 constructor 中,创建一个 fakeWindow 对象,通过 Proxy 代理这个对象,全程没有改变 window

只是在获取属性值的时候,如果在代理对象上没有找到想要的属性,才回去 window 中查找。

js复制代码class ProxySandbox {

  constructor() {
    // 沙箱是否是激活状态
    this.isRunning = false

    const fakeWindow = Object.create(null)

    const _this = this

    this.proxyWindow = new Proxy(fakeWindow, {
      set(target, prop, value) {
        // 只有激活状态下,才做处理
        if (_this.isRunning) {
          target[prop] = value
          return true
        }
      },
      get(target, prop, reciver) {
        // 如果fakeWindow里面有,就从fakeWindow里面取,否则,就从外部的window里面取
        return prop in target ? target[prop] : window[prop]
      }
    })
  }

  active() {
    this.isRunning = true
  }

  inactive() {
    this.isRunning = false
  }
}

window.city = '北京'

const p1 = new ProxySandbox()
const p2 = new ProxySandbox()

// 激活
p1.active()
p2.active()

p1.proxyWindow.city = '上海'
p2.proxyWindow.city = '杭州'

console.log(p1.proxyWindow.city) // '上海'
console.log(p2.proxyWindow.city) // '杭州'
console.log(window.city) // 北京

// 失活
p1.inactive()
p2.inactive()

console.log(p1.proxyWindow.city) // '上海'
console.log(p2.proxyWindow.city) // '杭州'
console.log(window.city) // '北京'

qiankun 的样式问题

如果不启动样式隔离,主应用、子应用所有的样式都是全局环境下,意味着,如果我在主应用里面设置了高权重的 css 样式,是会直接影响到子应用的。

css复制代码// 主应用 main.css
h1 {
  color: red !important;
}

button {
  background-color: red !important;
}

主应用、子应用所有的 h1 和 button 都会应用以上颜色。

当然我们不能这样做,我们的应用间样式应该独立,不能互相影响。可以通过 BEM 解决,不过在大型项目下,约定是一件很不靠谱的事情,最好是在框架中解决此问题,一劳永逸。

qiankun 样式隔离方案

  • shadow dom(sanbox: strictStyleIsolation)
  • scoped css(sanbox: experimentalStyleIsolation)

在 start 方法中,配置 sanbox 属性,即可开启 css 隔离。

js复制代码// sanbox: boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }

start({
  sanbox: true
})
  • strictStyleIsolation

strictStyleIsolation 模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,所有的子应用都被 #shadow-root 所包裹,从而确保微应用的样式不会对全局造成影响。

当我们开启了 strictStyleIsolation 模式后,主应用设置的高权重 css 确实没有影响子应用了。但是,但是,咱们去看看 Vue dialog 的样式(别看 React 的,因为React事件在 Shadow DOM 中根本不起作用 )

注意:
shadow dom 并不是一个无脑的解决方案,特别是在 React 中,事件的处理可能不那么奏效了 !
React 官方关于 web component 的解释

乍一看是不是没问题?

深入浅出微前端(qiankun)_第1张图片

我们摁下电脑的 ESC 键,会触发 是否取消弹窗 的二次确认,你再看看有没有问题?

深入浅出微前端(qiankun)_第2张图片

样式完全丢失了,这是为什么呢?因为二次确认的 Dialog 是挂在 body 下,而我们整个子应用都被 shadow dom 所包裹,内部的样式对外部的样式起不到任何作用,所以这个弹窗失去了漂亮的外衣了 !

不过,为啥弹窗要挂在 body 下?

这个是为了避免被父元素的样式影响,比如父元素设置了 display:none,那么这个弹窗也是无法展示的。

  • experimentalStyleIsolation

experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:

css复制代码// 假设应用名是 react16 中的样式是这样
.app-main {
  font-size: 14px;
}

// ===== 处理后 ======>

div[data-qiankun-react16] .app-main {
  font-size: 14px;
}

有点类似 Vue 中的 css scoped 作用,给每个子应用加了一个 ”唯一“ 的属性选择器。

这个时候,React 的事件处理没问题了(真好啊 ),我们来到页面上看看效果:

深入浅出微前端(qiankun)_第3张图片

事件是生效了,但是弹窗样式丢失了

这个弹窗是挂在 body 下,而加了 experimentalStyleIsolation 之后,所有的样式都加了 div[data-qiankun="react16"] 前缀,唯独 body 下的 dialog 没有加前缀,导致无法应用到正确的样式了。(Vue子应用 也有这样的问题!!)

还有就是,在主应用设置的高权重样式依然影响到了子应用。

Vue Scoped

在 Vue 的单文件组件中使用

Outer Component

微前端框架

qiankun、wujie、micro-app 的区别主要还是实现容器(或者叫沙箱)上有区别

  • qiankun: function + proxy + with
  • micro-app: web components
  • wujie: web components 和 iframe。

微前端(qiankun)架构中,主应用和子应用有共同的组件,如何封装呢?

在微前端架构中,主应用和子应用可能会共享一些组件,为了实现组件的共享和封装,可以采用以下方法:

  1. 封装为独立的 npm 包:将共享的组件封装为独立的 npm 包,并发布到私有或公共的 npm 仓库中。主应用和子应用都可以通过 npm 安装该组件,并在需要的地方引入和使用。
  2. Git 仓库依赖:将共享组件放置在一个独立的 Git 仓库中,并通过 Git 仓库的依赖关系来引入组件。主应用和子应用可以通过 Git 仓库的 URL 或路径来引入共享组件。
  3. Git Submodule:如果主应用和子应用都在同一个 Git 仓库下,可以使用 Git Submodule 的方式来引入共享组件。将共享组件作为子模块添加到主应用和子应用的仓库中。
  4. 本地引用:如果主应用和子应用处于同一个代码仓库中,可以直接通过相对路径引入共享组件。将共享组件放置在一个独立的目录下,并通过相对路径引用。

无论选择哪种方式,关键是要保持共享组件的独立性和可维护性。确保共享组件的代码和样式与具体的主应用和子应用解耦,避免出现冲突和依赖混乱的情况。同时,建议对共享组件进行版本管理,以便在更新和维护时能够更好地控制和追踪变更。

在微前端架构中,可以通过合适的方式引入共享组件,使主应用和子应用可以共享和复用组件,提高开发效率和代码质量。

monorepo架构

Monorepo 模式也可以解决微前端中的公共依赖包和公共组件的问题。

Monorepo 模式是指将多个项目或应用放置在同一个代码仓库中管理的开发模式。

在 Monorepo 中,可以将公共依赖包和公共组件作为共享资源,放置在代码仓库的合适位置,供不同的项目或应用使用。这样可以避免不同项目之间重复安装和维护相同的依赖包,也能够统一管理和更新公共组件。

以下是 Monorepo 模式下解决微前端中公共依赖包和公共组件的方式:

  1. 公共依赖包管理:将公共的依赖包放置在代码仓库的根目录或指定目录下,通过工具如 Yarn 或 Lerna 管理依赖包的安装、更新和版本控制。不同的项目或应用可以通过引用共享的依赖包来解决依赖关系,避免重复安装和冲突。
  2. 公共组件封装:将公共的组件封装为独立的包或模块,放置在代码仓库的特定目录中,并通过工具如 NPM 发布和安装。不同的项目或应用可以通过引用共享的组件来实现组件的复用和共享,提高开发效率和代码一致性。

通过 Monorepo 模式,可以集中管理和维护公共依赖包和公共组件,减少重复工作和资源浪费。同时,还能够促进团队协作和代码共享,统一规范和风格,提高整体项目的质量和可维护性。

原文链接: https://juejin.cn/post/72426232

你可能感兴趣的:(状态模式)