2022 你还不会微前端吗 (下) — 揭秘微前端核心原理(三)

简介: 2022 你还不会微前端吗 (下) — 揭秘微前端核心原理

快照(Snapshot)沙箱

所谓 快照沙箱 其实就是基于 diff 方式实现的沙箱:

  • 激活子应用 时优先将当前的 window 对象进行拷贝存储,再从上一次记录的 modifyPropsMap 中恢复该应用 上次的修改window
  • 离开子应用 时会与原有的 window 与 快照对象 windowSnapshot 进行 diff,将 变更的属性 保存到 modifyPropsMap 中,便与下次该 应用激活时 进行数据恢复,即把有变更的属性值同步之前的状态
// qiankun\src\sandbox\snapshotSandbox.ts
/**
 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
 */
export default class SnapshotSandbox implements SandBox {
  省略代码
  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    this.type = SandBoxType.Snapshot;
  }
  active() {
    // 记录当前快照
    this.windowSnapshot = {} as Window;
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });
    // 恢复之前的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });
    this.sandboxRunning = true;
  }
  inactive() {
    this.modifyPropsMap = {};
    iter(window, (prop) => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });
    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
    }
    this.sandboxRunning = false;
  }
}
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
  for (const prop in obj) {
    // 出于兼容原因,为 clearInterval 打补丁
    if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
      callbackFn(prop);
    }
  }
}
复制代码

应用通信

qiankun 中应用通信可以通过 initGlobalState(state) 的方式实现,它用于定义全局状态,并返回通信方法,官方建议在主应用使用,微应用通过 props 获取通信方法。

原理是什么呢,相信你下面用法,即便不看源码也猜得到是怎么实现的:

// 主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';
const actions: MicroAppStateActions = initGlobalState(state);// 初始化 state
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
// 微应用
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log(state, prev);
  });
  props.setGlobalState(state);
}
复制代码

这不就是妥妥的 发布订阅模式 嘛!!!

是的,毕竟发布订阅模式非常适用于需要通信的场景,就和在 vue2 中使用的 EventBus 核心原理是一样的。

实现微前端框架

这里还是直接沿用在上篇文章中创建的项目内容,具体可见 2022 你还不会微前端吗 (上) — 从巨石应用到微应用,下面一步步开始实现自己的微前端框架吧!

点此查看源代码

前置处理

简单回顾一下项目的大致结构:

  • single-spa
  • vue2-main-app(基座应用)
  • vue3-micro-app(子应用)
  • react-micro-app(子应用)

基座应用 — 入口文件

首先要做的就是修改基座应用的入口文件,将里面用于导入 registerMicroApps、start 方法的部分替换成自己定义的微前端模块 micro-fe,具体如下:

// single-spa\vue2-main-app\src\registerApplication.ts
//- import { registerMicroApps, start } from 'qiankun';
+ import { registerMicroApps, start } from './micro-fe';
// 默认子应用
export const applications = [
    {
        name: 'singleVue3', // app name registered
        entry: 'http://localhost:5000',
        container: '#micro-content',
        activeRule: '/vue3-micro-app',
      },
      {
        name: 'singleReact', // app name registered
        entry: 'http://localhost:3000',
        container: '#micro-content',
        activeRule: '/react-micro-app',
      },
]
// 注册子应用
export const registerApps = (apps: any[] = applications) => {
    registerMicroApps(apps);
    start();
}
复制代码

定义 micro-fe

src 目录下新建 micro-fe 目录,其中 index.ts 为入口,文件内容如下:

// single-spa\vue2-main-app\src\micro-fe\index.ts
export const registerMicroApps = (apps?: any[]) => {
    ...
}
export const start = () => {
    ...
}
复制代码

注册应用 — registerMicroApps

registerMicroApps 函数核心要做的事情很简单,注册子应用 其实就是 保存子应用,这里把外部传入的子应用保存在全局变量 _apps 中,并向外部提供一个可以访问 _apps 的方法 getApps,具体如下:

// single-spa\vue2-main-app\src\micro-fe\index.ts
// 保存子应用
let _apps: any[]
// 获取子应用
export const getApps = () => _apps
// 注册子应用
export const registerMicroApps = (apps: any[] = []) => {
    _apps = apps;
}
复制代码

启动子应用 — start

启动子应用需要做的核心内容如下:

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

监听路由变化 & 匹配子应用路由

通过注册 hashchangepopstate 事件就可以实现对 hash 路由 和 history 路由 的监听,这里以 history 路由来实现,因为对其需要做特殊处理。

popstate 事件 可以监听window.history.[go | forward | back]() 等方法引起的路由变化,但 无法监听window.history.[pushState | replaceState]() 等方法引起的路由变化

因此,需要对 window.history.[pushState | replaceState]() 这两个方法进行 重写,便于在外部调用这两个方法时,也能达到路由监听的效果。

同时需要定义 子应用路由匹配路逻辑,需要在上述重写的方法和监听路由变化的事件中执行,并且匹配到对应的子应用后就需要进行 子应用的加载

这里将与 history 路由相关的内容都放置到了 src\micro-fe\historyRoute.ts 中:

// src\micro-fe\historyRoute.ts
import { getApp } from './index'
import { loadApp } from './application'
// 监听路由变化
export const listenHistoryRoute = () => {
    // 监听路由变化
    window.addEventListener('popstate', () => {
        // 匹配路由
        matchHistoryRoute()
    })
    // 重写 pushState
    const rawPushState = window.history.pushState;
    window.history.pushState = (...args) => {
        // 调用原始方法
        rawPushState.apply(window.history, args)
        // 匹配路由
        matchHistoryRoute()
    }
    // 重写 replaceState
    const rawReplaceState = window.history.pushState;
    window.history.replaceState = (...args) => {
        // 调用原始方法
        rawReplaceState.apply(window.history, args)
        // 匹配路由
        matchHistoryRoute()
    }
}
// 匹配路由
export const matchHistoryRoute = () => {
    const apps = getApp();
    const { pathname } = window.location;
    const app = apps.find(item => pathname.startsWith(item.activeRule))
    if (!app) return
    // 加载子应用
    loadApp(app)
}
复制代码

加载子应用

这里将和应用相关的内容放到 src\micro-fe\application.ts 文件中。

加载子应用 实际上对应的是上述的 loadApp(app) 方法,而它需要做的内容如下:

  • 加载子应用 html 模板
  • 加载并执行 html 中的 JS 脚本,包含内联和外链的脚本
  • 加载其他资源文件,比如 cssimg

qiankun 中使用的是 import-html-entry 这个库来处理的,这里我们也自己来实现一下,并将相关内容存放在 src\micro-fe\importHtmlEntry.ts 文件中

加载子应用 HTML 模板

加载子应用可以通过 fetch 和 注册子应用时配置的 entry 来实现,具体如下:

// src\micro-fe\importHtmlEntry.ts
import { fectResource } from './fetch'
import type { MicroApp } from './type'
export const importEntry = async (app: MicroApp) => {
    // 获取模板
    const html = await fectResource(app.entry)
    // 字符串模板 -> DOM 结构,目的是方便使用 DOM API
    const template = document.createElement('div')
    template.innerHTML = html
    // 加载模板中对应的 script 脚本内容
    function getExternalScripts() {
    }
    // 执行模板中的 script 脚本内容
    function execScripts() {
    }
    return {
        template,
        getExternalScripts,
        execScripts
    }
}
// src\micro-fe\fetch.ts
export const fectResource = (url:string) => {
   return fetch(url).then(res => res.text())
}
复制代码

加载并执行JS 脚本

获取到模板内容之后,把模板内容作为一个 DOM 节点 innerHTML 的内容,方便通过 DOM API 的方式直接获取所有需要的 script 标签,而不需要通过字符串或正则匹配的模式来进行这个操作,具体如下:

  • getExternalScripts 方法负责获取模板中所有的 script 标签,并根据其 src 属性是否有值区分 内联外链 脚本
  • 内联脚本 直接获取其对应的脚本字符串,即通过 innerHTML 的方式直接获取
  • 外链脚本 要区分第三方链接和当前微应用的链接,本质还是通过 fetch 去加载对应的脚本内容
  • execScripts 方法负责执行获取到的脚本内容,这里为了简便选择通过 eval 的方式执行上述获取到的代码字符串,当然也可以通过 new Function 的形式
// src\micro-fe\importHtmlEntry.ts
import { fectResource } from './fetch'
import type { MicroApp } from './type'
const Noop = (props?: any) => props
export const importEntry = async (app: MicroApp) => {
    // 获取模板
    const html = await fectResource(app.entry)
    // 字符串模板 -> DOM 结构,目的是方便使用 DOM API
    const template = document.createElement('div')
    template.innerHTML = html
    // 获取模板中所有的 scripts
    const scripts = Array.from(template.querySelectorAll('script'))
    // 加载模板中对应的 script 脚本内容 
    function getExternalScripts() {
        return Promise.all(scripts.map(script => {
            const src = script.getAttribute('src')
            if (!src) return Promise.resolve(script.innerHTML)
            return fectResource(src.indexOf('//') > -1 ? src : app.entry + src)
        }))
    }
    // 执行模板中对应的 script 脚本内容
    async function execScripts() {
        const scripts = await getExternalScripts();
        window.__Micro_App__ = true;
        // 手动构建 CommonJS 规范
        const module = { exports: { bootstrap: Noop, mount: Noop, unmount: Noop } }
        const exports = module.exports
        scripts.forEach((code) => {
            eval(code)
        });
        return module.exports
    }
    return {
        template,
        getExternalScripts,
        execScripts
    }
}
复制代码

加载其他资源文件

其他资源文件其实就是外链的样式、图片等,通常情况下只要配置了对应的微应用的 publicPath 自然就能够被正确加载,这一点在 qiankun 中其实有提及,其实还是通过 webpack 来设置运行时的 publicPath

值得注意的是,微应用的样式和基座应用冲突的问题,而这个其实也很好解决:

  • shadow DOM 可将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,最简单的隔离方案
  • 为每个微应用定义一个唯一的 css 选择器(如:app.name)来限定样式的作用范围
  • 可以在微应用中就定义好这个唯一标识
  • 可以在基座应用加载微应用时在动态为其定义唯一标识
  • css in js 本质是通过 JavaScript 来声明,维护样式
  • 方式一:styled-components
const Button = styled.button`
  border-radius: 3px;
  padding: 0.25em 1em; 
  color: palevioletred;
  border: 2px solid palevioletred; 
`;
function Buttons() {
  return (
    <Button>Normal Button</Button>
    <Button primary>Primary Button</Button>
  );
}
复制代码
  • 方式二:内联样式
var styles = { base: { color: '#fff', }, primary: { background: '#0074D9' }, warning: { background: '#FF4136' } }; 
class Button extends React.Component { 
    render() { 
        return ( <button style={[ styles.base, styles[this.props.kind] ]}> {this.props.children} </button> ); 
    } 
}
复制代码

扩展:为什么要手动构造 CommonJS 规范?

在实现微应用 HTML 模板解析后,需要执行对应的微应用脚本时,人为的手动构造了符合 CommonJS 规范的环境,其目的是为了更普适的获取在微应用中暴露出来的 bootstrap、mount、unmount 等生命周期钩子,便于在特定时机去执行。

// src\micro-fe\importHtmlEntry.ts
import { fectResource } from './fetch'
import type { MicroApp } from './type'
const Noop = (props?: any) => props
export const importEntry = async (app: MicroApp) => {
    // 执行模板中对应的 script 脚本内容
    async function execScripts() {
        const scripts = await getExternalScripts();
        window.__Micro_App__ = true;
        // 手动构建 CommonJS 规范
        const module = { exports: { bootstrap: Noop, mount: Noop, unmount: Noop } }
        const exports = module.exports
        // 执行代码
        scripts.forEach((code) => {
            eval(code)
        });
        return module.exports
    }
}
复制代码

但毕竟这里选择了 eval 的方式来执行脚本,那么该如何获取其中的导出方法呢?

首先这和在微应用中设置的打包格式为 umd 的方式有关,不防先看一看打包后的具体内容是什么样子的,这里以 vue3-micro-app 项目为例,运行 npm run build 后在 dist 目录下查看和 app.xxx.js 相关的内容:

// dist\js\app.6fa1a50e.js
;(function (t, n) {
  // CommonJS 规范
  'object' === typeof exports && 'object' === typeof module
    ? (module.exports = n())
    // AMD 规模
    : 'function' === typeof define && define.amd
            ? define([], n)
            : 'object' === typeof exports
                // ESModule 规范
                ? (exports['vue3-micro-app'] = n())
                // 全局属性
                : (t['vue3-micro-app'] = n())
})(window, function () {
  return (function () {
      return ...
  })()
})
复制代码

会发现 umd 的格式对当前运行时环境做了各种判断:

  • 是否符合 CommonJS 规范,若符合则把其内部的返回值赋值给 module.exports,若不符合进入下一个判断
  • 是否符合 AMD 规范,若符合则通过 define([], n) 实现数据传递,若不符合进入下一个判断
  • 是否符合 ESModule 规范,若符合则把其内部的返回值赋值给 exports[ouput.library],若不符合进入下一个判断
  • 上述条件不符合则会直接通过把返回值赋值给 window[ouput.library]

看起来,要获取微应用入口文件中导出的生命周期钩子方式很多呀,为什么要选则 CommonJS 的方式呢?

  • AMD 规范很少使用了,直接排除
  • ESModule 规范 和 window[ouput.library] 的方式,都非常依赖于在微应用和 webpack 打包输出时指定的 ouput.library 的值,意味着若在微前端框架内部不知道微应用真正的 ouput.library 的值,那岂不是没办法获取到其导出的内容了

综上,其实只有 CommonJS 的方式满足不需要提前知道微应用的导出内容时真正对应的名称,也可以获取到其返回值的结果,但运行时环境复杂,并不一定是支持 CommonJS 规范,于是需要手动提供 module.exportsexports 对象来达到目的。

效果演示

点此查看源代码

image.png

最后

本篇文章的结束也算是抓住了 2022 的尾巴,把微前端的内容过了一遍,也算是完成了自己定下的一个 flag,大前端太大,各种概念、各种技术层出不穷,容易让人摸不着头脑,其实不必要过分追逐,需要用到自然会去学习,想要了解自然就会去学习!!!

希望本篇文章对你有所帮助!!!


目录
相关文章
|
人工智能 前端开发 JavaScript
前端架构思考 :专注于多框架的并存可能并不是唯一的方向 — 探讨大模型时代前端的分层式微前端架构
随着前端技术的发展,微前端架构成为应对复杂大型应用的流行方案,允许多个团队使用不同技术栈并将其模块化集成。然而,这种设计在高交互性需求的应用中存在局限,如音视频处理、AI集成等。本文探讨了传统微前端架构的不足,并提出了一种新的分层式微前端架构,通过展示层与业务层的分离及基于功能的横向拆分,以更好地适应现代前端需求。
381 0
|
11月前
|
移动开发 缓存 前端开发
深入理解前端路由:原理、实现与应用
本书《深入理解前端路由:原理、实现与应用》全面解析了前端路由的核心概念、工作原理及其实现方法,结合实际案例探讨了其在现代Web应用中的广泛应用,适合前端开发者和相关技术人员阅读。
|
前端开发 开发者
本文将深入探讨 BEM 的概念、原理以及其在前端开发中的应用
BEM(Block-Element-Modifier)是一种前端开发中的命名规范和架构方法,旨在提高代码的可维护性和复用性。通过将界面拆分为独立的模块,BEM 提供了一套清晰的命名规则,增强了代码的结构化和模块化设计,促进了团队协作。本文深入探讨了 BEM 的概念、原理及其在前端开发中的应用,分析了其优势与局限性,为开发者提供了宝贵的参考。
432 8
|
监控 前端开发 jenkins
Jenkins 在前端项目持续部署中的应用,包括其原理、流程以及具体的实现方法
本文深入探讨了Jenkins在前端项目持续部署中的应用,涵盖其基本原理、流程及具体实现方法。首先介绍了Jenkins的基本概念及其在自动化任务中的作用,随后详细解析了从前端代码提交到生产环境部署的全过程,包括构建、测试、部署等关键步骤。最后,强调了持续部署中的代码质量控制、环境一致性、监控预警及安全管理等注意事项,旨在帮助开发者高效、安全地实施持续部署。
293 5
|
缓存 前端开发 JavaScript
JavaScript前端路由的实现原理及其在单页应用中的重要性,涵盖前端路由概念、基本原理、常见实现方式
本文深入解析了JavaScript前端路由的实现原理及其在单页应用中的重要性,涵盖前端路由概念、基本原理、常见实现方式(Hash路由和History路由)、优点及挑战,并通过实际案例分析,帮助开发者更好地理解和应用这一关键技术,提升用户体验。
467 1
|
前端开发 API UED
深入理解微前端架构:构建灵活、高效的前端应用
【10月更文挑战第23天】微前端架构是一种将前端应用分解为多个小型、独立、可复用的服务的方法。每个服务独立开发和部署,但共同提供一致的用户体验。本文探讨了微前端架构的核心概念、优势及实施方法,包括定义服务边界、建立通信机制、共享UI组件库和版本控制等。通过实际案例和职业心得,帮助读者更好地理解和应用微前端架构。
|
存储 监控 前端开发
掌握微前端架构:构建未来前端应用的基石
【10月更文挑战第12天】随着前端技术的发展,传统的单体应用架构已无法满足现代应用的需求。微前端架构通过将大型应用拆分为独立的小模块,提供了更高的灵活性、可维护性和快速迭代能力。本文介绍了微前端架构的概念、核心优势及实施步骤,并探讨了其在复杂应用中的应用及实战技巧。
|
前端开发 API UED
拥抱微前端架构:构建灵活、高效的前端应用
【10月更文挑战第17天】微前端架构是一种将前端应用拆分为多个小型、独立、可复用的服务的方法,每个服务可以独立开发、部署和维护。本文介绍了微前端架构的核心概念、优势及实施步骤,并分享了业界应用案例和职业心得,帮助读者理解和应用这一新兴架构模式。
|
缓存 JavaScript 前端开发
拿下奇怪的前端报错(三):npm install卡住了一个钟- 从原理搞定安装的全链路问题
本文详细分析了 `npm install` 过程中可能出现的卡顿问题及解决方法,包括网络问题、Node.js 版本不兼容、缓存问题、权限问题、包冲突、过时的 npm 版本、系统资源不足和脚本问题等,并提供了相应的解决策略。同时,还介绍了开启全部日志、使用替代工具和使用 Docker 提供 Node 环境等其他处理方法。
9702 2
|
存储 安全 前端开发
在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
1417 0

热门文章

最新文章

  • 1
    前端如何存储数据:Cookie、LocalStorage 与 SessionStorage 全面解析
  • 2
    前端工程化演进之路:从手工作坊到AI驱动的智能化开发
  • 3
    Vue 3 + TypeScript 现代前端开发最佳实践(2025版指南)
  • 4
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(五):背景属性;float浮动和position定位;详细分析相对、绝对、固定三种定位方式;使用浮动并清除浮动副作用
  • 5
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(六):全方面分析css的Flex布局,从纵、横两个坐标开始进行居中、两端等元素分布模式;刨析元素间隔、排序模式等
  • 6
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(一):CSS发展史;CSS样式表的引入;CSS选择器使用,附带案例介绍
  • 7
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(八):学习transition过渡属性;本文学习property模拟、duration过渡时间指定、delay时间延迟 等多个参数
  • 8
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(九):强势分析Animation动画各类参数;从播放时间、播放方式、播放次数、播放方向、播放状态等多个方面,完全了解CSS3 Animation
  • 9
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(四):元素盒子模型;详细分析边框属性、盒子外边距
  • 10
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(二):CSS伪类:UI伪类、结构化伪类;通过伪类获得子元素的第n个元素;创建一个伪元素展示在页面中;获得最后一个元素;处理聚焦元素的样式