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,大前端太大,各种概念、各种技术层出不穷,容易让人摸不着头脑,其实不必要过分追逐,需要用到自然会去学习,想要了解自然就会去学习!!!

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


目录
相关文章
|
1月前
|
Web App开发 前端开发 JavaScript
前端应用实现 image lazy loading 的原理介绍
前端应用实现 image lazy loading 的原理介绍
45 0
|
2月前
|
编解码 前端开发 开发者
现代前端开发中的响应式设计原理与实践
传统的网页设计通过固定的布局方式难以适应不同设备的屏幕尺寸,而响应式设计则能够使网页在各种终端上都能良好呈现。本文将深入探讨现代前端开发中响应式设计的原理和实践,帮助开发者更好地理解和应用响应式设计技术。
|
1月前
|
前端开发 数据可视化 JavaScript
探索前端可视化开发:低代码平台原理与实践
【4月更文挑战第7天】本文探讨了低代码平台在前端开发中的应用,介绍了其模型驱动、组件化和自动化部署的原理,强调了提升效率、降低技术门槛、灵活适应变更和保证一致性等优势。建议开发者明确适用场景,选择合适平台,并培养团队低代码技能,同时规划与现有技术栈的融合,实施持续优化治理。低代码平台正改变开发格局,为业务创新和数字化转型提供新途径。
58 0
|
2天前
|
自然语言处理 前端开发 Java
深入浅出JVM(六)之前端编译过程与语法糖原理
深入浅出JVM(六)之前端编译过程与语法糖原理
|
4天前
|
前端开发 测试技术
前端自动化测试中的快照测试原理
快照测试用于前端自动化测试,通过比较当前应用状态与预存预期快照来检测UI变化。流程包括设置测试环境、捕获屏幕快照、保存预期快照、比较快照及处理差异。当快照比较出现差异时,测试工程师审查判断是否为预期变化或错误,确保应用一致性。这种方法在重构、样式更改和跨浏览器测试时提供有效回归测试,减少手动验证工作。
|
11天前
|
JavaScript 前端开发 数据安全/隐私保护
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(二)
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(二)
|
11天前
|
JavaScript 前端开发 Python
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(一)
优秀的前端框架vue,原理剖析与实战技巧总结【干货满满】(一)
|
15天前
|
移动开发 前端开发 应用服务中间件
前端——html拖拽原理
前端——html拖拽原理
19 0
|
15天前
|
前端开发 JavaScript
前端 富文本编辑器原理——从javascript、html、css开始入门(二)
前端 富文本编辑器原理——从javascript、html、css开始入门
26 0
前端 富文本编辑器原理——从javascript、html、css开始入门(二)
|
15天前
|
前端开发 JavaScript 索引
前端 富文本编辑器原理——从javascript、html、css开始入门(一)
前端 富文本编辑器原理——从javascript、html、css开始入门
36 0