2023-09-03
微前端
00
请注意,本文编写于 319 天前,最后修改于 319 天前,其中某些信息可能已经过时。

目录

简介
微前端概念
微前端的发展史
微前端的特点
微前端要考虑的问题
微前端有哪些解决方案
基于Iframe完全隔离的方案
npm包
webpack构建时方案
纯Web Component构建方案
主流的微前端框架
基于sigle-spa 的路由劫持方法
qiankun
浅谈微前端的原理
监听路由变化
HTML解析
样式隔离
JS沙盒环境
快照沙箱SnapshotSandbox
单例沙箱legacySandbox
多例沙箱proxySandbox

本文讲述微前端的发展背景,简单总监微前端的几种解决方案,讲解qiankun的使用案例和大致实现原理。

简介

微前端概念

  • 微前端的概念来自于后端微服务。
  • 微服务是一种开发软件的架构和组织方法
  • 其中软件由明确定义的API进行通信的小型独立服务组成。

微服务的主要思想是:

  • 将应用分解为小的,互相连接的微服务,一个微服务完成某个特定功能。
  • 每个微服务都有自己的业务逻辑和适配器,不同的微服务,可以使用不同的技术去实现。
  • 使用统一的网关 进行调用。

微服务的主要思路是化繁为简,通过更加细致的划分,使得服务内容更加内聚,服务之间耦合性降低,有利于项目的团队开发和后期维护。把微服务的概念应用到前端就是微前端。
微前端是⼀种架构⻛格,它允许可独⽴交付的前端应⽤程序被组合成⼀个更⼤的整体。

微前端的发展史

image.png

  • 2014年 MartinFowler和JamesLewis共同提出了微服务的概念
  • 2018年 第一个基于微前端的工具single-spa在github上开源
  • 2019年 基于single-spaqiankun框架问世
  • 2020年 Module Federation(Webpack5)把项目中的模块分为本地模块和远程模块,远程模块在运行时异步从所谓的容器中加载。

微前端的特点

  • 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈
  • 独立开发/部署 各团队之间,仓库独立,单独部署,互不依赖
  • 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而为应用具备渐进式升级的特性
  • 独立运行时 微应用之间运行时互补依赖,有独立的状态管理。
  • 提升效率 应用越庞大 ->越难以维护 &协作低下,微应用可以很好拆分,提升效率

微前端要考虑的问题

或者说具备哪些能力

  • CSS隔离 子应用之间样式互不影响,切换时装载和卸载
  • JS沙箱 子应用之间互不影响,包括全局变量和事件
  • HTML Entry 匹配到子应用路由,先加载子应用入口的html,解析html加载其他静态资源(CSS/JS)
    • Config Entry的进阶版,但解析消耗留给了用户
  • 按需加载 切换页面才加载相应的资源,进入子应用路由,再去装载运行子应用
  • 公共依赖加载 将一些通用的工具方法、组件、甚至npm包抽取出来作为公共依赖,这样可以提升整体项目的体验
  • 父子应用通信 抽离公共依赖后,子应用如何调用父应用方法,父应用如何下发事件

微前端有哪些解决方案

抛开single-spa qiankun我们来看一下微前端有哪些实现方案。

基于Iframe完全隔离的方案

优点:

  • 非常简单,几乎无需任何改造
  • 完美隔离,JS/CSS都是独立的运行环境
  • 不限制使⽤,⻚⾯上可以放多个iframe来组合业务

缺点:

  • 页面或状态切换,每次进来都要重新加载,状态不能保留
  • 完全的隔离导致与子应用的交互变得极其困难,无法与主应用进行资源共享
  • iframe中的弹窗无法突破其自身,比如无法实现全屏弹窗
  • 整个应用全量资源加载,加载太慢

npm包

将子应用封装成npm包,通过组件的方式引入,在性能和兼容性上是最优的方案,但却有一个致命的缺点,每次发版需要通知接入方同步更新,管理非常困难

webpack构建时方案

image.png

纯Web Component构建方案

google推出的浏览器的原子组件,这里简单介绍下。它由三部分组成

  • Custom elements 自定义元素
  • Shadow DOM(影子DOM) 用于将DOM树附加到元素上(与主文档DOM分开) 并控制其关联的功能,不用担心与文档其他部分发生冲突。
  • HTML templates <template><slot>元素可以编写不在页面中显示的标记模板,可以作为自定义元素的基础被多次重用

关于WebComponet,

Web Component 有以下优势

  • 技术栈无关 是浏览器原生的组件,任何框架都可以用
  • 独立开发 开发的应用无需与其他任何应用关联
  • 应用间隔离: ShadowDOM的特性,各个引⼊的微应⽤间可以达到相互隔离的效果

Web Component 不足之处

  • 兼容性 WebComponent是一组技术的组合,部分特性还是存在一些兼容性问题
  • 成本高 虽然WebComponent是浏览器的API,天生与技术栈无关,但目前使用的范围比较窄,改造起来成本大
  • 开发体验上相对差一些 这里是尤大总结的,WC VS Vue组件

主流的微前端框架

基于sigle-spa 的路由劫持方法

sigle-spa官网

  • 微前端系统有一个主应用和N个子应用组成。子应用要在主应用中注册(路由规则、各种资源、公共依赖等)
  • 路由劫持 跳转或首屏进入匹配到子应用路由,先加载主应用,主应用运行后再加载子应用,
  • 提供子应用生命周期管理 (注册、挂在、卸载)其中加载微应用的方法要自己写

生命周期
image.png

  • load   当应用匹配路由时就会加载脚本(非函数,只是一种状态)
  • bootstrap 引导函数 (对接html,应用内容首次挂载到页面前调用)
  • mount   挂在函数
  • unmount  卸载函数(移除事件绑定等内容)
  • unload  非必要(unload之后会重新启动bootstrap流程;借助unload可实现热更新)。

我写了一个极简single-spa案例

qiankun

官方文档

image.png

  • qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
  • 通过import-html-entry包解析HTML获取对资源进行解析、加载。
  • 通过对执行环境的修改,它实现了JS沙箱、样式隔离等特性。

乾坤的运行流程

案例: 采用React作为主应用基座,接入Vue 技术栈的微应用

image.png

  1. 脚手架搭建
  • npx create-react-app micro-main-app 创建主应用
  • npx vue create micro-sub-app-vue 创建子应用
  1. 主应用接入
  • src/index.js 增加以下内容
js
import { registerMicroApps, start } from "qiankun"; registerMicroApps([ { name: 'vueApp', entry: '//localhost:3001', container: '#micro-container', activeRule: '/app-vue', }, ]); start();
  • src/app.jsx mock 路由跳转
jsx
import { BrowserRouter as Router, Link } from 'react-router-dom' import { Menu } from 'antd' import './App.css' import { useState } from 'react' const menus = [ { key: '/', title: '主页', path: '/', label: <Link to="/">React主应用</Link>, }, { key: '/app-vue', route: '/app-vue', title: 'vue微应用', label: <Link to="/app-vue">vue微应用</Link>, }, ] function App() { const [selectedKey, setSelectKey] = useState(window.location.pathname) let style = { width: '100vw', height: '100vh', } return ( <Router> {/* <h1>主应用启动成功</h1> */} <div className="App"> <Menu selectedKeys={[selectedKey]} style={{ width: 256, }} theme="dark" mode="inline" items={menus} onSelect={e=>setSelectKey(e.key)} ></Menu> {selectedKey === '/' ? <div style={Object.assign({ display: 'flex', marginTop: '10vh', fontSize: '40px', justifyContent: 'center', }, style)}> React主应用 </div> : <div id="micro-container" style={style}></div> } </div> </Router> ) } export default App
  1. vue子应用接入
  • vue.config.js 增加 devServer:{port: '3001'}
  • src/app.js
js
if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } // 独立运行时 if (!window.__POWERED_BY_QIANKUN__) { render(); } export async function bootstrap() { console.log('[vue] app bootstraped'); } export async function mount(props) { console.log('[vue] props from main framework mount', props); render(props); } export async function unmount() { instance.$destroy(); instance = null; }
  1. 分别启动主应用和子应用,浏览器访问试试看

以上案例仅开发环境运行,生产环境还要微调一下代码

在生产环境中,我们一般通过nginx配置静态资源的访问,

  • 配置主应用资源及子应用资源可访问
  • 所有前端路由都找主应用的index.html
  • 匹配到子应用路由,主应用去加载子应用的index.html,解析html并运行

作为前端人,为了测试生产环境运行效果,我写了一个node脚本,模拟nginx的功能

javascript
const Koa = require('koa'); const serve = require('koa-static'); const path = require('path'); const fs = require('fs'); const app = new Koa(); // 主应用 app.use(serve(path.resolve(__dirname, './example/micro-main-app/build'))); // 子应用 资源文件 const app1Files = serve(path.resolve(__dirname, './example/micro-sub-app-vue/dist/')); app.use(async function (ctx, next) { if (/^\/app-vue\//.test(ctx.req.url) && path.extname(ctx.req.url)) { // 加载子应用资源 ctx.req.url = ctx.req.url.replace(/\/app-vue/, '') return await app1Files.apply(this, [ctx, next]) } else { // 前端路由都走主应用 let text = await new Promise((resolve, reject) => { fs.readFile(path.resolve(__dirname, './example/micro-main-app/build/index.html'), 'utf-8', function (error, data) { if (error) return reject(error) resolve(data) }) }) ctx.body = text next() } }) app.listen(8000, () => { console.log('app start at port 8000'); })

以上案例的完整代码 点击这里

浅谈微前端的原理

本人对微前端框架原理的研究还没有吃透,这里以自己对qiankunsingle-spa的理解,简单阐述微前端框架实现的关键技术点及demo。(完整的撸一遍没那个必要,拆分实现的关键技术点会容易记忆)

监听路由变化

假如我们可以监听路由变化,进而对路由进行劫持,

  • 路由变化时匹配子应用
  • 执行子应用的生命周期
  • 加载子应用

路由有两种模式 hash路由和history路由

hash路由的实现

  • hash路由,路由改变不请求服务端,监听 window.addEventListener('hashchange', onHashChange)
  • 改变URL的方式有以下几种 ,都会触发hashchange事件
    • 通过浏览器前进后退改变URL
    • 通过a标签改变URL
      • 补充:在vue-router中通过router-link跳转不会出发hashchange事件,这里要想其它办法,
    • 通过window.location改变URL
javascript
// 监听hashchange window.addEventListener('hashchange', handleUrlChange); function handleUrlChange () { doLifeCycle(); } function doLifeCycle() { console.log('todo', ` 判断路由改变是否从一个微服务到另一个微服务, 如果是,则卸载当前应用,加载下一个应用,执行前后应用的生命周期 `); } // 上述方法不支持拦截 vue-router 中的 router-link // 采用事件代理处理a标签 document.body.addEventListener('click', function(e) { if(e.target.tagName !== 'A') return; const href = e.target.getAttribute('href'); if(/#\//.test(href)) { doLifeCycle(); } })

history实现
history 有 .pushState .replaceState .go .forward .back五个方法,single-spa 好像只对前两个方法做了代理。
history提供类似hashchange事件的popstate事件,但popstate事件有些不同:

  • 通过浏览器前进后退改变URL时会触发popstate事件,
  • 通过pushStatereplaceState或标签改变URL不会出发popstate事件。好在可以拦截pushStatereplaceState的调用和标签点击事件来检测URL变化
javascript
// 拦截浏览器前进后退 window.addEventListener('popstate', function(e) { doLifeCycle(); }); // 拦截history跳转 const originPush = history.pushState; history.pushState = (...args) => { originPush.apply(window.history, args); doLifeCycle(); }; window.history.replaceState = (...args) => { originReplace.apply(window.history, args); doLifeCycle(); }; function doLifeCycle() { console.log('todo', ` 判断路由改变是否从一个微服务到另一个微服务, 如果是,则卸载当前应用,加载下一个应用,执行前后应用的生命周期 `); }

image.png

HTML解析

html解析使用的是一个npm包 import-html-entry

javascript
export const loadHTML = async (app) => { const { container, entry } = app; const { template, getExternalScripts, getExternalStyleSheets } = await importEntry(entry); const dom = document.querySelector(container); if (!dom) { throw new Error('容器不存在'); } dom.innerHTML = template; await getExternalStyleSheets(); const jsCode = await getExternalScripts(); jsCode.forEach((script) => { const lifeCycle = runJS(script, app); if (lifeCycle) { app.bootstrap = lifeCycle.bootstrap; app.mount = lifeCycle.mount; app.unmount = lifeCycle.unmount; } }); return app; };

样式隔离

在qiankun中有如下配置可以设置子应用的样式隔离

javascript
start({ sandbox: { strictStyleIsolation: true, experimentalStyleIsolation: true, } })
  • strictStyleIsolation   为每一个子应用包裹ShadowDOM节点,从而确保微应用的样式不会对全局造成影响。
  • experimentalStyleIsolation 改写子应用的样式,为所有样式规则增加一个特殊的选择器规则来限定其影响范围
    • .hello ----> div[data-qiankun="vueApp"] .hello

ShadowDOM隔离子应用样式示例代码

html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>使用Web Component 隔离样式</title> <style> .hello { background-color: aquamarine; border: 1px solid #ddd; margin: 10px; padding: 20px; font-size: 30px; } </style> </head> <body> <div class="hello">主应用</div> <div class="sub-hello"> 使用了子应用的class </div> <div id="container"></div> <script> function createElement(appContent) { const container = document.createElement('div'); container.innerHTML = appContent; const appElement = container.firstElementChild; const { innerHTML } = appElement; appElement.innerHTML = ''; let shadow; if (appElement.attachShadow) { shadow = appElement.attachShadow({ mode: 'open' }); } else { shadow = appElement.createShadowRoot(); } shadow.innerHTML = innerHTML; return appElement; } const subApp = `<div> <style>.sub-hello {color: blue}</style> <p class="sub-hello">子应用</p> <div>`; // document.getElementById('container').innerHTML = subApp; document.getElementById('container').appendChild(createElement(subApp)); </script> </body> </html>

补充知识,在一个复杂不熟悉的项目中,如何排除外部样式的影响?

css
.comp-root * { all: inital; all: revert; } .comp-root { /* 这里些你的样式 */ }

JS沙盒环境

qiankun的实现中,包含了两种沙箱,分别为基于Proxy实现的沙箱和快照沙箱,当浏览器不支持Proxy会降级为快照沙箱

快照沙箱SnapshotSandbox

  • 基于数据diff 备份和还原window。
  • 性能较差,主要用于不支持 Proxy 的低版本浏览器,而且也只适应单个子应用

单例沙箱legacySandbox

legacySandbox 设置了三个参数记录全局变量

  • addedPropsMapInSandbox 沙箱新增的全局变量
  • modifiedPropsOriginalValueMapInSandbox 沙箱更新的全局变量
  • currentUpdatedPropsValueMap 持续记录更新的(新增和修改的)全局变量。

image.png

javascript
window.sex= '男'; let LegacySandbox = new Legacy(); ((window) => { // 激活沙箱 LegacySandbox.active(); window.age = '22'; window.sex= '女'; console.log('激活', window.sex, window.age, LegacySandbox); })(LegacySandbox.proxy);

单利沙箱的源码很简单,熟悉Proxy,看一眼就能手写出来

javascript
class Legacy { constructor() { this.addedPropsMapInSandbox = {}; this.modifiedPropsOriginalValueMapInSandbox = {}; this.currentUpdatedPropsValueMap = {}; const rawWindow = window; const fakeWindow = Object.create(null); this.sandboxRunning = true; const proxy = new Proxy(fakeWindow, { set: (target, prop, value) => { if(this.sandboxRunning) { if(!rawWindow.hasOwnProperty(prop)) { this.addedPropsMapInSandbox[prop] = value; } else if(!this.modifiedPropsOriginalValueMapInSandbox[prop]) { const originValue = rawWindow[prop] this.modifiedPropsOriginalValueMapInSandbox[prop] = originValue; } this.currentUpdatedPropsValueMap[prop] = value; rawWindow[prop] = value; return true; } return true; }, get: (target, prop) => { return rawWindow[prop]; } }) this.proxy = proxy; } active() { if (!this.sandboxRunning) { for(const key in this.currentUpdatedPropsValueMap) { window[key] = this.currentUpdatedPropsValueMap[key]; } } this.sandboxRunning = true; } inactive() { for(const key in this.modifiedPropsOriginalValueMapInSandbox) { window[key] = this.modifiedPropsOriginalValueMapInSandbox[key]; } for(const key in this.addedPropsMapInSandbox) { delete window[key]; } this.sandboxRunning = false; } }

多例沙箱proxySandbox

javascript
class ProxySandbox { active() { this.sandboxRunning = true; } inactive() { this.sandboxRunning = false; } constructor() { const rawWindow = window; const fakeWindow = {}; const proxy = new Proxy(fakeWindow, { set: (target, prop, value) => { if(this.sandboxRunning) { target[prop] = value; return true; } }, get: (target, prop) => { // 如果fakeWindow里面有,就从fakeWindow里面取,否则,就从外部的window里面取 let value = prop in target ? target[prop] : rawWindow[prop]; return value } }) this.proxy = proxy; } } // 测试用例 window.sex = '男'; let proxy1 = new ProxySandbox(); ((window) => { proxy1.active(); console.log('修改前proxy1的sex', window.sex); window.sex = '111'; console.log('修改后proxy1的sex', window.sex); })(proxy1.proxy); console.log('外部window.sex', window.sex);

参考资料:


todo 探索 micro-app
https://github.com/micro-zoe/micro-app/issues/8

本文作者:郭敬文

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!