Bootstrap

微前端无界方案

微前端无界方案


介绍

无界利用 iframe 和 webcomponent 来搭建天然的 js 沙箱和 css 沙箱。

能够完善的解决适配成本、样式隔离、运行性能、页面白屏、子应用通信、子应用保活、多应用激活、vite 支持、应用共享等用户的核心诉求。

官方文档




无界的使用

主应用

1、引入
// 无框架时使用'wujie'
import Wujie from 'wujie';
// 当结合框架时使用'wujie-xxx'
// import Wujie from "wujie-vue2";
// import Wujie from "wujie-vue3";
// import Wujie from "wujie-react";

const { bus, setupApp, preloadApp, startApp, destroyApp } = Wujie;

提示

如果主应用是 vue 框架可直接使用 wujie-vue,react 框架可直接使用 wujie-react

2、设置子应用

【非必须】由于 preloadAppstartApp 的参数重复,为了避免重复输入,可以通过 setupApp 来统一设置默认参数。

setupApp({
  name: '唯一id',
  url: '子应用地址',
  exec: true,
  el: '容器',
  sync: true,
});
3-1、启动子应用
startApp({ name: '唯一id' });
3-2、预加载
preloadApp({ name: '唯一id' });
3-3、以组件形式调用

无界支持以组件的形式使用。

vue
安装
# vue2 框架
npm i wujie-vue2 -S
# vue3 框架
npm i wujie-vue3 -S
引入
// main.js

// vue2
import WujieVue from 'wujie-vue2';
// vue3
// import WujieVue from "wujie-vue3";

// 全局注册组件(以vue为例)
Vue.use(WujieVue);

const { bus, setupApp, preloadApp, startApp, destroyApp } = WujieVue;
使用

使用 组件,相当于使用了startApp来调用,因此可以忽略startApp的使用了!!

<template>
  <!-- 单例模式,name相同则复用一个无界实例,改变url则子应用重新渲染实例到对应路由 -->
  <WujieVue
    width="100%"
    height="100%"
    name="vue2"
    :url="vue2Url"
    :sync="true"
    :fetch="fetch"
    :props="props"
    :beforeLoad="beforeLoad"
    :beforeMount="beforeMount"
    :afterMount="afterMount"
    :beforeUnmount="beforeUnmount"
    :afterUnmount="afterUnmount"
  ></WujieVue>
  <!-- 子应用通过$wujie.bus.$emit(event, args)出来的事件都可以直接@event来监听 -->
</template>
<script>
  // import hostMap from "./hostMap";

  export default {
    computed: {
      vue2Url() {
        // 这里拼接成子应用的域名(例如://localhost:7200/home)
        return hostMap('//localhost:7200/') + `#/${this.$route.params.path}`;
      },
    },
  };
</script>
// hostMap.js
const map = {
  '//localhost:7100/': '//wujie-micro.github.io/demo-react17/',
  '//localhost:7200/': '//wujie-micro.github.io/demo-vue2/',
  '//localhost:7300/': '//wujie-micro.github.io/demo-vue3/',
  '//localhost:7500/': '//wujie-micro.github.io/demo-vite/',
};

export default function hostMap(host) {
  if (process.env.NODE_ENV === 'production') return map[host];
  return host;
}

WujieVue组件接收的参数如下:

WujieVue组件接收的参数基本上与startApp的一致。

不同之处在于startApphtmlel,没有widthheight

const wujieVueOptions = {
  name: 'WujieVue',
  props: {
    width: { type: String, default: '' },
    height: { type: String, default: '' },
    name: { type: String, default: '' },
    loading: { type: HTMLElement, default: undefined },
    url: { type: String, default: '' },
    sync: { type: Boolean, default: false },
    prefix: { type: Object, default: undefined },
    alive: { type: Boolean, default: false },
    props: { type: Object, default: undefined },
    replace: { type: Function, default: undefined },
    fetch: { type: Function, default: undefined },
    fiber: { type: Boolean, default: true },
    degrade: { type: Boolean, default: false },
    plugins: { type: Array, default: null },
    beforeLoad: { type: Function, default: null },
    beforeMount: { type: Function, default: null },
    afterMount: { type: Function, default: null },
    beforeUnmount: { type: Function, default: null },
    afterUnmount: { type: Function, default: null },
    activated: { type: Function, default: null },
    deactivated: { type: Function, default: null },
    loadError: { type: Function, default: null },
  },
};




子应用改造

无界对子应用的侵入非常小,在满足跨域条件下子应用可以不用改造。

1、前提

子应用的资源和接口的请求都在主域名发起,所以会有跨域问题,子应用必须做cors 设置

app.use((req, res, next) => {
  // 路径判断等等
  res.set({
    'Access-Control-Allow-Credentials': true,
    'Access-Control-Allow-Origin': req.headers.origin || '*',
    'Access-Control-Allow-Headers': 'X-Requested-With,Content-Type',
    'Access-Control-Allow-Methods': 'PUT,POST,GET,DELETE,OPTIONS',
    'Content-Type': 'application/json; charset=utf-8',
  });
  // 其他操作
});

2、生命周期改造

改造入口函数:

  • 将子应用路由的创建、实例的创建渲染挂载到window.__WUJIE_MOUNT函数上
  • 将实例的销毁挂载到window.__WUJIE_UNMOUNT
  • 如果子应用的实例化是在异步函数中进行的,在定义完生命周期函数后,请务必主动调用无界的渲染函数 window.__WUJIE.mount()
具体操作可以参考下面示例
// vue 2

if (window.__POWERED_BY_WUJIE__) {
  let instance;
  window.__WUJIE_MOUNT = () => {
    const router = new VueRouter({ routes });
    instance = new Vue({ router, render: (h) => h(App) }).$mount('#app');
  };
  window.__WUJIE_UNMOUNT = () => {
    instance.$destroy();
  };
} else {
  new Vue({ router: new VueRouter({ routes }), render: (h) => h(App) }).$mount('#app');
}
// vue 3

if (window.__POWERED_BY_WUJIE__) {
  let instance;
  window.__WUJIE_MOUNT = () => {
    const router = createRouter({ history: createWebHistory(), routes });
    instance = createApp(App);
    instance.use(router);
    instance.mount('#app');
  };
  window.__WUJIE_UNMOUNT = () => {
    instance.unmount();
  };
} else {
  createApp(App)
    .use(createRouter({ history: createWebHistory(), routes }))
    .mount('#app');
}
// vite

declare global {
  interface Window {
    // 是否存在无界
    __POWERED_BY_WUJIE__?: boolean;
    // 子应用mount函数
    __WUJIE_MOUNT: () => void;
    // 子应用unmount函数
    __WUJIE_UNMOUNT: () => void;
    // 子应用无界实例
    __WUJIE: { mount: () => void };
  }
}

if (window.__POWERED_BY_WUJIE__) {
  let instance: any;
  window.__WUJIE_MOUNT = () => {
    const router = createRouter({ history: createWebHistory(), routes });
    instance = createApp(App)
    instance.use(router);
    instance.mount("#app");
  };
  window.__WUJIE_UNMOUNT = () => {
    instance.unmount();
  };
  /*
    由于vite是异步加载,而无界可能采用fiber执行机制
    所以mount的调用时机无法确认,框架调用时可能vite
    还没有加载回来,这里采用主动调用防止用没有mount
    无界mount函数内置标记,不用担心重复mount
  */
  window.__WUJIE.mount()
} else {
  createApp(App).use(createRouter({ history: createWebHistory(), routes })).mount("#app");
}
// react

if (window.__POWERED_BY_WUJIE__) {
  window.__WUJIE_MOUNT = () => {
    ReactDOM.render(
      <React.StrictMode>
        <App />
      </React.StrictMode>,
      document.getElementById('root')
    );
  };
  window.__WUJIE_UNMOUNT = () => {
    ReactDOM.unmountComponentAtNode(document.getElementById('root'));
  };
} else {
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById('root')
  );
}
3、构建工具的配置说明
vue-cli

子应用的路有模式为 history 时,publicPath 不要带.,正确使用:publicPath: "/"
子应用的路有模式为 hash 时,publicPath 可以带.,正确使用:publicPath: "./"

// vue.config.js

module.exports = {
  outputDir: 'dist',
  // histotry模式设置publicPath不要带.
  publicPath: '/',
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Headers': '*',
      'Access-Control-Allow-Methods': '*',
    },
    port: '7201',
  },
};




无界 功能介绍

运行模式

无界有三种运行模式:单例模式保活模式重建模式

其中保活模式重建模式子应用无需做任何改造,而单例模式则需要做生命周期改造!!!

在微前端框架中,子应用会随着主应用页面的打开和关闭反复的激活和销毁(单例模式:生命周期模式)。而在无界微前端框架中,子应用还可以以其他方式进行处理(保活模式、重建模式),这样会进入完全不同的处理流程。

保活模式

子应用的 alive 设置为 true 时进入保活模式,内部的数据和路由的状态不会随着页面切换而丢失。

在保活模式下,子应用只会进行一次渲染,页面发生切换时承载子应用 domwebcomponent 会保留在内存中,当子应用重新激活时无界会将内存中的 webcomponent 重新挂载到容器上。

注意:

  1. 保活模式下改变 url 子应用的路由不会发生变化,需要采用 通信 的方式对子应用路由进行跳转。
  2. 保活的子应用的实例不会销毁,子应用被切走了也可以响应 bus 事件,非保活的子应用切走了监听的事件也会全部销毁,需要等下次重新 mount 后重新监听。
单例模式

子应用的 alivefalse 且进行了生命周期改造时进入单例模式

子应用页面如果切走,会调用 window.__WUJIE_UNMOUNT 销毁子应用当前实例,子应用页面如果切换回来,会调用 window.__WUJIE_MOUNT 渲染子应用新的实例。

在单例式下,改变 url 子应用的路由会发生跳转到对应路由。

注意:
如果主应用上有多个菜单栏用到了子应用的不同页面,在每个页面启动该子应用的时候将 name 设置为同一个,这样可以共享一个 wujie 实例,承载子应用 jsiframe 也实现了共享,不同页面子应用的 url 不同,切换这个子应用的过程相当于:销毁当前应用实例 => 同步新路由 => 创建新应用实例


重建模式(一般不使用,非常消耗资源)

子应用既没有设置为保活模式,也没有进行生命周期的改造则进入了重建模式,每次页面切换不仅会销毁承载子应用 domwebcomponent,还会销毁承载子应用 jsiframe,相应的 wujie 实例和子应用实例都会被销毁。

重建模式下改变 url 子应用的路由会跳转对应路由,但是在 路由同步 场景并且子应用的路由同步参数已经同步到主应用 url 上时则无法生效,因为改变 url 后会导致子应用销毁重新渲染,此时如果有同步参数则同步参数的优先级最高。




生命周期

无界提供的生命周期,与 vue 的生命周期设计非常类似。

其中,保活模式下,才会执行activated deactivated,其余的生命周期在单例模式下都会执行。

beforeLoad

类型: type lifecycle = (appWindow: Window) => any;
子应用开始加载静态资源前触发

beforeMount

类型: type lifecycle = (appWindow: Window) => any;
子应用渲染(调用 window.__WUJIE_MOUNT)前触发

afterMount

类型:type lifecycle = (appWindow: Window) => any;
子应用渲染(调用 window.__WUJIE_MOUNT)后触发

beforeUnmount

类型:type lifecycle = (appWindow: Window) => any;
子应用卸载(调用 window.__WUJIE_UNMOUNT)前触发

afterUnmount

类型:type lifecycle = (appWindow: Window) => any;
子应用卸载(调用 window.__WUJIE_UNMOUNT)后触发

activated

类型:type lifecycle = (appWindow: Window) => any;
子应用 保活模式 下,进入时触发

deactivated

类型:type lifecycle = (appWindow: Window) => any;
子应用 保活模式 下,离开时触发

loadError

类型:type loadErrorHandler = (url: string, e: Error) => any;
子应用加载资源失败后触发




通讯方式

无界提供三种方式进行通信

props 通信

主应用可以通过props注入数据和方法:

<WujieVue :props="{ data: xxx, methods: xxx }"></WujieVue>

子应用可以通过$wujie来获取:

const props = window.$wujie?.props; // {data: xxx, methods: xxx}

注意:
子应用是通过全局属性$wujie获取props,而不是在生命周期中获取!!!


window 通信

由于子应用运行的 iframesrc 和主应用是同域的,所以相互可以直接通信

主应用调用子应用的全局数据:

window.document.querySelector('iframe[name=子应用id]').contentWindow.xxx;

子应用调用主应用的全局数据:

window.parent.xxx;

eventBus 通信

无界提供一套去中心化的通信方案,主应用和子应用、子应用和子应用都可以通过这种方式方便的进行通信, 详见 EventBus Api

主应用使用方式:

// 如果使用wujie
import { bus } from "wujie";

// 如果使用wujie-vue
import WujieVue from "wujie-vue";
const { bus } = WujieVue;

// 如果使用wujie-react
import WujieReact from "wujie-react";
const { bus } = WujieReact;

// 主应用监听事件
bus.$on("事件名字", function (arg1, arg2, ...) {});
// 主应用发送事件
bus.$emit("事件名字", arg1, arg2, ...);
// 主应用取消事件监听
bus.$off("事件名字", function (arg1, arg2, ...) {});

子应用使用方式:

// 子应用监听事件
window.$wujie?.bus.$on("事件名字", function (arg1, arg2, ...) {});
// 子应用发送事件
window.$wujie?.bus.$emit("事件名字", arg1, arg2, ...);
// 子应用取消事件监听
window.$wujie?.bus.$off("事件名字", function (arg1, arg2, ...) {});




预加载

tips:预加载能力可以极大的提升子应用打开的首屏时间,但同时在首次渲染时阻塞主应用,当预加载多个子应用时,会出现比较长的白屏时间!!!

预加载

预加载指的是在应用空闲的时候 requestIdleCallback 将所需要的静态资源提前从网络中加载到内存中,详见 preloadApp

预执行

预执行指的是在应用空闲的时候将子应用提前渲染出来,可以进一步提升子应用打开时间。

只需要在 preloadApp 中将 exec 设置为 true 即可。

注意:由于子应用提前渲染可能会导致阻塞主应用的线程,所以无界提供了类似 react-fiber 方式来防止阻塞线程,详见 fiber




路由同步

路由同步

路由同步会将子应用路径的 path+query+hash 通过 window.encodeURIComponent 编码后挂载在主应用 url 的查询参数上,其中 key 值为子应用的 name。

开启路由同步后,刷新浏览器或者将 url 分享出去子应用的路由状态都不会丢失,当一个页面存在多个子应用时无界支持所有子应用路由同步,浏览器刷新、前进、后退子应用路由状态也都不会丢失

开启参数 sync

注意

只有无界实例在初次实例化的时候才会从 url 上读回路由信息,一旦实例化完成后续只会单向的将子应用路由同步到主应用 url

重点:

wujie 提供了路由同步的功能,主应用无需注册子应用路由,也可以实现跨应用的跳转动作。
此功能非常 nice,解决了像 qiankun 这样的微前端框架,父子应用之间路由注册的老大难问题。

短路径(路由别名)

无界提供短路径的能力,当子应用的 url 过长时,可以通过配置 prefix 来缩短子应用同步到主应用的路径,无界在选取短路径的时候,按照匹配最长路径原则选取短路径。

完成匹配后子应用匹配到的路径将被{短路径} + 剩余路径的方式挂载到主应用 url 上,注意在匹配路径的时候请不要带上域名。

示例:

<WujieVue
  width="100%"
  height="100%"
  name="xxx"
  :url="xxx"
  :sync="true"
  :prefix="{
    prod: '/example/prod',
    test: '/example/test',
    prodId: '/example/prod/debug?id=',
  }"
></WujieVue>

此时子应用不同路径将转换如下:

/example/prod/hello  => {prod}/hello

/example/test/name => {test}/name

/example/prod/debug?id=5&age=10 => {prodId}5&age=10




路由跳转

主应用为 history 模式

子应用 A 要打开子应用 B

以 vue 主应用为例,子应用 A 的 name 为 A, 主应用 A 页面的路径为/pathA,子应用 B 的 name 为 B,主应用 B 页面的路径为/pathB

主应用 A 页面:

<template>
  <!-- 子应用 A -->
  <wujie-vue name="A" url="//hostA.com" :props="{jump}" ></WujieVue>
</template>

<script>
export default {
  methods: {
    jump(location) {
      this.$router.push(location);
    }
  }
}
</script>

子应用 A 通过调用主应用传递的 jump 函数,跳转到子应用 B 的页面

// 子应用 A 点击跳转处理函数
function handleJump() {
  window.$wujie?.props.jump({ path: '/pathB' });
}
子应用 A 要打开子应用 B 的指定路由

上面的方法,A 应用只能跳转到应用 B 的在主应用的默认路由,如果需要跳转到 B 应用的指定路由比如 /test

  1. 子应用 B 开启路由同步能力
  2. 子应用的点击跳转函数:
// 子应用 A 点击跳转处理函数
function handleJump() {
  window.$wujie?.props.jump({ path: '/pathB', query: { B: '/test' } });
}

由于跳转后的链接的查询参数带上了 B 应用的路径信息,而子应用 B 开启了路由同步的能力,所以能从 url 上读回需要同步的路径,注意这种办法只有在 B 应用未曾激活过才生效。

子应用 B 为保活应用

如果子应用 B 是保活应用并且没有被打开过,也就是还没有实例化,上述的打开指定路由的方式可以正常工作,但如果子应用 B 已经实例化,保活应用的内部数据和路由状态都会保存下来不随子应用切换而丢失。

这时如果要打开子应用 B 的指定路由可以使用通信的方式 :

子应用 A 点击跳转处理函数

// 子应用 A 点击跳转处理函数
function handleJump() {
  window.$wujie?.bus.$emit('routeChange', '/test');
}

子应用 B

// 子应用 B 监听并跳转
window.$wujie?.bus.$on('routeChange', (path) => this.$router.push({ path }));
主应用为 hash 模式

当主应用为 hash 模式时,主应用路由的 query 参数会挂载到 hash 的值后面,而无界路由同步读取的是 url 的 query 查询参数,所以需要手动的挂载查询参数

子应用 A 要打开子应用 B

同上

子应用 A 要打开子应用 B 的指定路由
  1. 主应用 的 jump 修改:
<template>
  <wujie-vue name="A" url="//hostA.com" :props="{jump}"></wujie-vue>
</template>

<script>
  export default {
    methods: {
      jump(location, query) {
        // 跳转到主应用B页面
        this.$router.push(location);
        const url = new URL(window.location.href);
        url.search = query
        // 手动的挂载url查询参数
        window.history.replaceState(null, "", url.href);
      }
  }
</script>
  1. 子应用 B 开启路由同步能力

  2. 子应用的点击跳转函数:

function handleJump() {
  window.$wujie?.props.jump({ path: "/pathB" } , `?B=${window.encodeURIComponent("/test")}`});
}
子应用 B 为保活应用

同上




插件系统

无界的插件体系主要是方便用户在运行时去修改子应用代码从而避免去改动仓库代码,详见API

html-loader

无界提供插件在运行时对子应用的 html 文本进行修改

  • 示例
const plugins = [
  {
    // 对子应用的template进行的aaa替换成bbb
    htmlLoader: (code) => {
      return code.replace('aaa', 'bbb');
    },
  },
];
js-excludes

如果用户想加载子应用的时候,不执行子应用中的某些js文件

那么这些工作可以放置在js-excludes中进行

  • 示例
const plugins = [
  // 子应用的 http://xxxxx.js 或者符合正则 /test\.js/ 脚本将不在子应用中进行
  { jsExcludes: ['http://xxxxx.js', /test\.js/] },
];
js-ignores

如果用户想子应用自己加载某些js文件(通过script标签),而非框架劫持加载(通常会导致跨域)

那么这些工作可以放置在js-ignores中进行

  • 示例
const plugins = [
  // 子应用的 http://xxxxx.js 或者符合正则 /test\.js/ 脚本将由子应用自行加载
  { jsIgnores: ['http://xxxxx.js', /test\.js/] },
];

警告
jsIgnores 中的 js 文件由于是子应用自行加载没有对 location 进行劫持,如果有对 window.location.href 进行操作复制请务必替换成 window.$wujie.location.href 的操作,否则子应用的沙箱会被取代掉

js-before-loaders

如果用户想在html中所有的js之前做:

  1. 在子应用运行一个src="http://xxxxx"的脚本
  2. 在子应用中运行一个内联的 js 脚本<script>content</script>
  3. 执行一个回调函数

那么这些工作可以放置在js-before-loaders中进行

  • 示例
const plugins = [
  {
    // 在子应用所有的js之前
    jsBeforeLoaders: [
      // 插入一个外联脚本
      { src: 'http://xxxx.js' },
      // 插入一个内联监本
      { content: 'console.log("test")' },
      // 执行一个回调,打印子应用名字
      {
        callback(appWindow) {
          console.log('js-before-loader-callback', appWindow.__WUJIE.id);
        },
      },
    ],
  },
];
js-loader

如果用户想将子应用的某个js脚本的代码进行替换,可以在这个地方进行处理

  • 示例
const plugins = [
  {
    // 将url为aaa.js的脚本中的aaa替换成bbb
    // code 为脚本代码、url为脚本的地址(内联脚本为'')、base为子应用当前的地址
    jsLoader: (code, url, base) => {
      if (url === 'aaa.js') return code.replace('aaa', 'bbb');
    },
  },
];

警告

  • 对于 esm 脚本不会经过 js-loader 插件处理
  • 对于 js-ignores 脚本不会经过 js-loader 插件处理
js-after-loader

如果用户想在html中所有的js之后做:

  1. 在子应用运行一个src="http://xxxxx"的脚本
  2. 在子应用中运行一个内联的 js 脚本<script>content</script>
  3. 执行一个回调函数

那么这些工作可以放置在js-after-loaders中进行

  • 示例
const plugins = [
  {
    jsAfterLoaders: [
      // 插入一个外联脚本
      { src: 'http://xxxx.js' },
      // 插入一个内联监本
      { content: 'console.log("test")' },
      // 执行一个回调,打印子应用名字
      {
        callback(appWindow) {
          console.log('js-after-loader-callback', appWindow.__WUJIE.id);
        },
      },
    ],
  },
];
css-excludes

如果用户想加载子应用的时候,不加载子应用中的某些css文件

那么这些工作可以放置在css-excludes中进行

  • 示例
const plugins = [
  // 子应用的 http://xxxxx.css 脚本将不在子应用中加载
  { cssExcludes: ["http://xxxxx.css" /test\.css/] },
];
css-ignores

如果用户想子应用自己加载某些css文件(通过link标签),而非框架劫持加载(通常会导致跨域)

那么这些工作可以放置在css-ignores中进行

  • 示例
const plugins = [
  // 子应用的 http://xxxxx.css 或者符合正则 /test\.css/ 脚本将由子应用自行加载
  { cssIgnores: ['http://xxxxx.css', /test\.css/] },
];
css-before-loaders

如果用户想在html中所有的css之前做:

  1. 插入一个src="http://xxxxx"的外联样式脚本
  2. 插入一个<style>content</style>的内联样式脚本

那么这些工作可以放置在css-before-loaders中进行

  • 示例
const plugins = [
  {
    // 在子应用所有的css之前
    cssBeforeLoaders: [
      //在加载html所有的样式之前添加一个外联样式
      { src: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css' },
      //在加载html所有的样式之前添加一个内联样式
      { content: 'img{width: 300px}' },
    ],
  },
];
css-loader

无界提供插件在运行时对子应用的css文本进行修改

  • 示例
const plugins = [
  {
    // 对css脚本动态的进行替换
    // code 为样式代码、url为样式的地址(内联样式为'')、base为子应用当前的地址
    cssLoader: (code, url, base) => {
      console.log('css-loader', url, code.slice(0, 50) + '...');
      return code;
    },
  },
];
css-after-loaders

如果用户想在html中所有的css之后做:

  1. 插入一个src="http://xxxxx"的外联样式脚本
  2. 插入一个<style>content</style>的内联样式脚本

那么这些工作可以放置在css-after-loaders中进行

  • 示例
const plugins = [
  {
    cssAfterLoaders: [
      //在加载html所有样式之后添加一个外联样式
      { src: 'https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css' },
      //在加载html所有样式之后添加一个内联样式
      { content: 'img{height: 300px}' },
    ],
  },
];
windowAddEventListenerHook

子应用的window添加监听事件时执行的回调函数

  • 示例

无界子应用的dom渲染在webcomponent中,jsiframe中运行,往往子应用在外部的容器滚动,所以监听windowscroll事件是无效的,可以将处理windowscroll事件绑定在滚动容器中

const plugins = [
  {
    windowAddEventListenerHook(iframeWindow, type, handler, options) {
      container.addEventListener(type, handler, options);
    },
  },
];
windowRemoveEventListenerHook

子应用的window移除监听事件时执行的回调函数

  • 示例
const plugins = [
  {
    windowAddEventListenerHook(iframeWindow, type, handler, options) {
      container.addEventListener(type, handler, options);
    },
    windowRemoveEventListenerHook(iframeWindow, type, handler, options) {
      container.removeEventListener(type, handler, options);
    },
  },
];
documentAddEventListenerHook

子应用的document添加监听事件时执行的回调函数

  • 示例

无界子应用的dom渲染在webcomponent中,jsiframe中运行,往往子应用在外部的容器滚动,所以监听documentscroll事件是无效的,可以将处理documentscroll事件绑定在滚动容器中

const plugins = [
  {
    documentAddEventListenerHook(iframeWindow, type, handler, options) {
      container.addEventListener(type, handler, options);
    },
  },
];
documentRemoveEventListenerHook

子应用的document移除监听事件时执行的回调函数

  • 示例
const plugins = [
  {
    documentAddEventListenerHook(iframeWindow, type, handler, options) {
      container.addEventListener(type, handler, options);
    },
    documentRemoveEventListenerHook(iframeWindow, type, handler, options) {
      container.removeEventListener(type, handler, options);
    },
  },
];
appendOrInsertElementHook

子应用往bodyhead插入元素后执行的回调函数

  • 示例
const plugins = [
  {
    // element 为真正插入的元素,iframeWindow 为子应用的 window, rawElement为原始插入元素
    appendOrInsertElementHook(element, iframeWindow, rawElement) {
      console.log(element, iframeWindow, rawElement);
    },
  },
];
patchElementHook

子应用创建元素后执行的回调函数

  • 示例
const plugins = [
  {
    patchElementHook(element, iframeWindow) {
      console.log(element, iframeWindow);
    },
  },
];




降级处理

无界提供无感知的降级方案

在非降级场景下,子应用的domwebcomponent中,运行环境在iframe中,iframedom的操作通过proxy来代理到webcomponent上,而webcomponentproxy IE都无法支持,这里采用另一个的iframe替换webcomponent,用Object.defineProperty替换proxy来做代理的方案

注意

无界并没有对 es6 代码进行 polyfill,因为每个用户对浏览器的兼容程度是不一样的引入的 polyfill 也不一致,如果需要在较低版本的浏览器中运行,需要用户自行 通过 babel 来添加 polyfill。

优点:

  1. 降级的行为由框架判断,当浏览器不支持时自动降级
  2. 降级后,应用之间也保证了绝对的隔离度
  3. 代码无需做任何改动,之前的预加载、保活还有通信的代码都生效,用户不需要为了降级做额外的代码改动导致降级前后运行的代码不一致
  4. 用户也可以强制降级,比如说当前浏览器对 webcomponentproxy 是支持的,但是用户还是想将 dom 运行在 iframe 中,就可以将 degrade 设置为 true

缺点:

  1. 弹窗只能在子应用内部
  2. 由于无法使用 proxy,无法劫持子应用的 location,导致访问 window.location.host 的时候拿到的是主应用的 host,子应用可以从 $wujie.location 中拿到子应用正确的 host




应用共享依赖

一个微前端系统可能同时运行多个子应用,不同子应用之间可能存在相同的包依赖,那么这个依赖就会在不同子应用中重复打包、重复执行造成性能和内存的浪费

这里提供一种工程上的策略结合无界的插件能力,可以有效的解决这个问题

以这个场景举例:主应用使用到了 lodash,子应用 A 也使用到了相同版本的 lodash

警告

应用共享原理是主应用和子应用运行 iframe 沙箱同域可以共享内存,对于组件库这样有副作用的第三方包,可能无法共享

子应用只运行在微前端框架

主应用:

  1. 修改主应用的 index.js,将共享包挂载到主应用的 window 对象上
// index.js
import lodash from 'lodash';

// 将需要共享的包挂载到主应用全局
window.lodash = lodash;
  1. 加载子应用时注入插件,将主应用的 lodash 赋值到子应用的 window 对象上
<WujieVue
  name="A"
  url="xxxxx"
  :plugins="[
    { 
      jsBeforeLoaders: [{ content: 'window.lodash = window.parent.lodash' }] 
    }
  ]"
></WujieVue>

子应用: webpack 设置 externals

module.exports = {
  externals: {
    lodash: {
      root: 'lodash',
      commonjs: 'lodash',
      commonjs2: 'lodash',
      amd: 'lodash',
    },
  },
};
子应用需要单独运行

由于上面步骤将 lodash 使用 externals 去掉了,所以子应用如果需要单独运行的话,会报错。为了让子应用可以单独运行需要做如下步骤:

  1. 在子应用的htmlhead中,插入脚本标签,使用cdn的外连接,将lodash.js放进去。
  2. <script> 标签内添加 ignore 标志。无界执行子应用时,这个标签会忽略,但子应用单独运行时不受 ignore 影响会执行:
<head>
  <script src="xxxx/cdn/lodash.js" ignore >
<head>

当然也可以不添加 ignore 标志而采用无界的插件将这个脚本排除在外:

<WujieVue
  name="A"
  url="xxxxx"
  :plugins="[
    {
      jsExcludes: ['xxxx/cdn/lodash.js']
    }
  ]"
></WujieVue>




API 说明

主应用

setupApp
  • 类型: Function

  • 参数: cacheOptions

  • 返回值:void

type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;

type baseOptions = {
  /** 唯一性用户必须保证 */
  name: string;
  /** 需要渲染的url */
  url: string;
  /** 需要渲染的html, 如果用户已有则无需从url请求 */
  html?: string;
  /** 代码替换钩子 */
  replace?: (code: string) => string;
  /** 自定义fetch */
  fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  /** 注入给子应用的属性 */
  props?: { [key: string]: any };
  /** 自定义运行iframe的属性 */
  attrs?: { [key: string]: any };
  /** 自定义降级渲染iframe的属性 */
  degradeAttrs?: { [key: string]: any };
  /** 子应用采用fiber模式执行 */
  fiber?: boolean;
  /** 子应用保活,state不会丢失 */
  alive?: boolean;
  /** 子应用采用降级iframe方案 */
  degrade?: boolean;
  /** 子应用插件 */
  plugins?: Array<plugin>;
  /** 子应用生命周期 */
  beforeLoad?: lifecycle;
  beforeMount?: lifecycle;
  afterMount?: lifecycle;
  beforeUnmount?: lifecycle;
  afterUnmount?: lifecycle;
  activated?: lifecycle;
  deactivated?: lifecycle;
  loadError?: loadErrorHandler;
};

type preOptions = baseOptions & {
  /** 预执行 */
  exec?: boolean;
};

type startOptions = baseOptions & {
  /** 渲染的容器 */
  el: HTMLElement | string;
  /**
   * 路由同步开关
   * 如果false,子应用跳转主应用路由无变化,但是主应用的history还是会增加
   * https://html.spec.whatwg.org/multipage/history.html#the-history-interface
   */
  sync?: boolean;
  /** 子应用短路径替换,路由同步时生效 */
  prefix?: { [key: string]: string };
  /** 子应用加载时loading元素 */
  loading?: HTMLElement;
};

type optionProperty = 'url' | 'el';

/**
 * 合并 preOptions 和 startOptions,并且将 url 和 el 变成可选
 */
type cacheOptions = Omit<preOptions & startOptions, optionProperty> & Partial<Pick<startOptions, optionProperty>>;
  • 详情: setupApp设置子应用默认属性,非必须。startApppreloadApp 会从这里获取子应用默认属性,如果有相同的属性则会直接覆盖




startApp
  • 类型: Function

  • 参数: startOption

  • 返回值:Promise<Function>

type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;

type startOption {
  /** 唯一性用户必须保证 */
  name: string;
  /** 需要渲染的 url */
  url: string;
  /** 需要渲染的 html, 如果用户已有则无需从 url 请求 */
  html?: string;
  /** 渲染的容器 */
  el: HTMLElement | string;
  /** 子应用加载时 loading 元素 */
  loading?: HTMLElement;
  /** 路由同步开关, false 刷新无效,但是前进后退依然有效 */
  sync?: boolean;
  /** 子应用短路径替换,路由同步时生效 */
  prefix?: { [key: string]: string };
  /** 子应用保活模式,state 不会丢失 */
  alive?: boolean;
  /** 注入给子应用的数据 */
  props?: { [key: string]: any };
  /** js 采用 fiber 模式执行 */
  fiber?: boolean;
  /** 子应用采用降级 iframe 方案 */
  degrade?: boolean;
  /** 自定义运行 iframe 的属性 */
  attrs?: { [key: string]: any };
  /** 自定义降级渲染 iframe 的属性 */
  degradeAttrs?: { [key: string]: any };
  /** 代码替换钩子 */
  replace?: (codeText: string) => string;
  /** 自定义 fetch,资源和接口 */
  fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  /** 子应插件 */
  plugins: Array<plugin>;
  /** 子应用生命周期 */
  beforeLoad?: lifecycle;
  /** 没有做生命周期改造的子应用不会调用 */
  beforeMount?: lifecycle;
  afterMount?: lifecycle;
  beforeUnmount?: lifecycle;
  afterUnmount?: lifecycle;
  /** 非保活应用不会调用 */
  activated?: lifecycle;
  deactivated?: lifecycle;
  /** 子应用资源加载失败后调用 */
  loadError?: loadErrorHandler
};
  • 详情: startApp启动子应用,异步返回 destroy 函数,可以销毁子应用,一般不建议用户调用,除非清楚的理解其作用

警告
一般情况下不需要主动调用destroy函数去销毁子应用,除非主应用再也不会打开这个子应用了,子应用被主动销毁会导致下次打开该子应用有白屏时间
namereplacefetchalivedegrade这五个参数在preloadAppstartApp中须保持严格一致,否则子应用的渲染可能出现异常

name
  • 类型: String

  • 详情: 子应用唯一标识符

技巧

如果主应用上有多个菜单栏用到了子应用的不同页面,在每个页面启动该子应用的时候建议将 name 设置为同一个,这样可以共享一个实例

url
  • 类型: String

  • 详情: 子应用的路径地址

    • 如果子应用为 单例模式 ,改变 url 则可以让子应用跳转到对应子路由
    • 如果子应用为 保活模式,改变 url 则无效,需要采用 通信 的方式对子应用路由进行跳转
    • 如果子应用为 重建模式,改变 url 子应用的路由会跳转对应路由,但是在 路由同步 场景并且子应用的路由同步参数已经同步到主应用 url 上时则无法生效,因为改变 url 后会导致子应用销毁重新渲染,此时如果有同步参数则同步参数的优先级最高
html
  • 类型: String

  • 详情: 子应用的 html,设置后子应用将直接读取该值,没有设置则子应用通过 url 请求获取

el
  • 类型: HTMLElement | string

  • 详情: 子应用渲染容器,子应用渲染容器的最好设置好宽高防止渲染问题,在 webcomponent 元素上无界还设置了 wujie_iframe 的 class 方便用户自定义样式

loading
  • 类型: HTMLElement

  • 详情: 自定义的 loading 元素,如果不想出现默认加载,可以赋值一个空元素:document.createElement('span')

sync
  • 默认值: false

  • 类型: Boolean

  • 详情: 路由同步模式,开启后无界会将子应用的 name 作为一个 url 查询参数,实时同步子应用的路径作为这个查询参数的值,这样分享 URL 或者刷新浏览器子应用路由都不会丢失。

警告

这个同步是单向的,只有打开 URL 或者刷新浏览器的时候,子应用才会从 URL 中读回路由,假如关闭路由同步模式,浏览器前进后退可以正常作用到子应用,但是浏览器刷新后子应用的路由会丢失

prefix
  • 类型: {[key: string]: string }

  • 详情: 短路径的能力,当子应用开启路由同步模式后,如果子应用链接过长,可以采用短路径替换的方式缩短同步的链接。

alive
  • 默认值: false

  • 类型: Boolean

  • 详情:

    保活模式,子应用实例 instancewebcomponent 都不会销毁,子应用的状态和路由都不会丢失,切换子应用只是对 webcomponent 的热插拔

    如果子应用不想做生命周期改造,子应用切换又不想有白屏时间,可以采用保活模式

    如果主应用上有多个菜单栏跳转到子应用的不同页面,此时不建议采用保活模式。因为子应用在保活状态下 startApp 无法更改子应用路由,不同菜单栏无法跳转到指定子应用路由,推荐单例模式

    技巧

    预执行模式结合保活模式可以实现类似 ssr 的效果,包括页面数据的请求和渲染全部提前完成,用户可以瞬间打开子应用

props
  • 类型: { [key: string]: any }

  • 详情: 注入给子应用的数据

fiber
  • 默认值: true

  • 类型: Boolean

  • 详情:

    js 的执行模式,由于子应用的执行会阻塞主应用的渲染线程,当设置为 true 时 js 采取类似 react fiber 的模式方式间断执行,每个 js 文件的执行都包裹在 requestidlecallback 中,每执行一个 js 可以返回响应外部的输入,但是这个颗粒度是 js 文件,如果子应用单个 js 文件过大,可以通过拆包的方式降低达到 fiber 模式效益最大化

    技巧

    打开主应用就需要加载的子应用可以将 fiber 设置为 false 来加快加载速度

    其他场景建议采用默认值

degrade
  • 默认值: false

  • 类型: Boolean

  • 详情:

    主动降级设置,无界方案采用了 proxy 和 webcomponent 等技术,在有些浏览器上可能出现不兼容的情况,此时无界会自动进行降级,采用一个的 iframe 替换 webcomponent,用 Object.defineProperty 替换 proxy,理论上可以兼容到 IE 9,但是用户也可以将 degrade 设置为 true 来主动降级

    警告

    一旦采用降级方案,弹窗由于在 iframe 内部将无法覆盖整个应用

attrs
  • 类型: { [key: string]: any }

  • 详情: 自定义 iframe 属性,子应用运行在 iframe 内,attrs 可以允许用户自定义 iframe 的属性

replace
  • 类型: (codeText: string) => string

  • 详情: 全局代码替换钩子

    技巧

    replace 函数可以在运行时处理子应用的代码,如果子应用不方便修改代码,可以在这里进行代码替换,子应用的 html、js、css 代码均会做替换

fetch
  • 类型: (input: RequestInfo, init?: RequestInit) => Promise<Response>

  • 详情: 自定义 fetch,添加自定义 fetch 后,子应用的静态资源请求和采用了 fetch 的接口请求全部会走自定义 fetch

    技巧

    对于需要携带 cookie 的请求,可以采用自定义 fetch 方式实现:(url, options) => window.fetch(url, { …options, credentials: “include” })

plugins
  • 类型: Array<plugin>
interface ScriptObjectLoader {
  /** 脚本地址,内联为空 */
  src?: string;
  /** 脚本是否为 module 模块 */
  module?: boolean;
  /** 脚本是否为 async 执行 */
  async?: boolean;
  /** 脚本是否设置 crossorigin */
  crossorigin?: boolean;
  /** 脚本 crossorigin 的类型 */
  crossoriginType?: 'anonymous' | 'use-credentials' | '';
  /** 内联 script 的代码 */
  content?: string;
  /** 执行回调钩子 */
  callback?: (appWindow: Window) => any;
}

interface StyleObjectLoader {
  /** 样式地址, 内联为空 */
  src?: string;
  /** 样式代码 */
  content?: string;
}

type eventListenerHook = (iframeWindow: Window, type: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => void;

interface plugin {
  /** 处理 html 的 loader */
  htmlLoader?: (code: string) => string;
  /** js 排除列表 */
  jsExcludes?: Array<string | RegExp>;
  /** js 忽略列表 */
  jsIgnores?: Array<string | RegExp>;
  /** 处理 js 加载前的 loader */
  jsBeforeLoaders?: Array<ScriptObjectLoader>;
  /** 处理 js 的 loader */
  jsLoader?: (code: string, url: string, base: string) => string;
  /** 处理 js 加载后的 loader */
  jsAfterLoaders?: Array<ScriptObjectLoader>;
  /** css 排除列表 */
  cssExcludes?: Array<string | RegExp>;
  /** css 忽略列表 */
  cssIgnores?: Array<string | RegExp>;
  /** 处理 css 加载前的 loader */
  cssBeforeLoaders?: Array<StyleObject>;
  /** 处理 css 的 loader */
  cssLoader?: (code: string, url: string, base: string) => string;
  /** 处理 css 加载后的 loader */
  cssAfterLoaders?: Array<StyleObject>;
  /** 子应用 window addEventListener 钩子回调 */
  windowAddEventListenerHook?: eventListenerHook;
  /** 子应用 window removeEventListener 钩子回调 */
  windowRemoveEventListenerHook?: eventListenerHook;
  /** 子应用 document addEventListener 钩子回调 */
  documentAddEventListenerHook?: eventListenerHook;
  /** 子应用 document removeEventListener 钩子回调 */
  documentRemoveEventListenerHook?: eventListenerHook;
  /** 子应用 向 body、head 插入元素后执行的钩子回调 */
  appendOrInsertElementHook?: <T extends Node>(element: T, iframeWindow: Window) => void;
  /** 子应用劫持元素的钩子回调 */
  patchElementHook?: <T extends Node>(element: T, iframeWindow: Window) => void;
  /** 用户自定义覆盖子应用 window 属性 */
  windowPropertyOverride?: (iframeWindow: Window) => void;
  /** 用户自定义覆盖子应用 document 属性 */
  documentPropertyOverride?: (iframeWindow: Window) => void;
}
  • 详情: 无界插件,在运行时动态的修改子应用代理。
beforeLoad
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,加载子应用前调用

beforeMount
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,子应用 mount 之前调用

afterMount
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,子应用 mount 之后调用

beforeUnmount
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,子应用 unmount 之前调用

afterUnmount
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,子应用 unmount 之后调用

activated
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,保活子应用进入时触发

deactivated
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,保活子应用离开时触发

loadError
  • 类型: (url: string, e: Error) => any

  • 详情: 生命周期钩子,子应用加载资源失败后触发

注意

如果子应用没有做生命周期改造,beforeMount、afterMount、beforeUnmount、afterUnmount 这四个生命周期都不会调用,非保活子应用 activated、deactivated 这两个生命周期不会调用




preloadApp
  • 类型: Function

  • 参数: preOption

type lifecycle = (appWindow: Window) => any;
type loadErrorHandler = (url: string, e: Error) => any;

type preOptions {
  /** 唯一性用户必须保证 */
  name: string;
  /** 需要渲染的 url */
  url: string;
  /** 需要渲染的 html, 如果用户已有则无需从 url 请求 */
  html?: string;
  /** 注入给子应用的数据 */
  props?: { [key: string]: any };
  /** 自定义运行 iframe 的属性 */
  attrs?: { [key: string]: any };
  /** 自定义降级渲染 iframe 的属性 */
  degradeAttrs?: { [key: string]: any };
  /** 代码替换钩子 */
  replace?: (code: string) => string;
  /** 自定义 fetch,资源和接口 */
  fetch?: (input: RequestInfo, init?: RequestInit) => Promise<Response>;
  /** 子应用保活模式,state 不会丢失 */
  alive?: boolean;
  /** 预执行模式 */
  exec?: boolean;
  /** js 采用 fiber 模式执行 */
  fiber?: boolean;
  /** 子应用采用降级 iframe 方案 */
  degrade?: boolean;
  /** 子应插件 */
  plugins: Array<plugin>;
  /** 子应用生命周期 */
  beforeLoad?: lifecycle;
  /** 没有做生命周期改造的子应用不会调用 */
  beforeMount?: lifecycle;
  afterMount?: lifecycle;
  beforeUnmount?: lifecycle;
  afterUnmount?: lifecycle;
  /** 非保活应用不会调用 */
  activated?: lifecycle;
  deactivated?: lifecycle;
  /** 子应用资源加载失败后调用 */
  loadError?: loadErrorHandler
};
  • 详情: 预加载可以极大的提升子应用首次打开速度

警告

  • 资源的预加载会占用主应用的网络线程池
  • 资源的预执行会阻塞主应用的渲染线程
  • namereplacefetchalivedegrade 这五个参数在 preloadAppstartApp 中须保持严格一致,否则子应用的渲染可能出现异常
name
  • 类型: String

  • 详情: 子应用唯一标识符

    技巧

    如果主应用上有多个菜单栏用到了子应用的不同页面,在每个页面启动该子应用的时候建议将 name 设置为同一个,这样可以共享一个实例

url
  • 类型: String

  • 详情: 子应用的路径地址

html
  • 类型: String

  • 详情: 子应用的 html,设置后子应用将直接读取该值,没有设置则子应用通过 url 请求获取

props
  • 类型: { [key: string]: any }

  • 详情: 注入给子应用的数据

    警告

    exectrue此时子应用代码会预执行,如果子应用运行依赖props的数据则须传入props或者子应用做好兼容props不存在,否则子应用运行可能报错

attrs
  • 类型: { [key: string]: any }

  • 详情: 自定义 iframe 属性,子应用运行在 iframe 内,attrs 可以允许用户自定义 iframe 的属性

replace
  • 类型: (codeText: string) => string

  • 详情: 全局代码替换钩子

    技巧

    replace 函数可以在运行时处理子应用的代码,如果子应用不方便修改代码,可以在这里进行代码替换,子应用的 html、js、css 代码均会做替换

fetch
  • 类型: (input: RequestInfo, init?: RequestInit) => Promise<Response>

  • 详情: 自定义 fetch,添加自定义 fetch 后,子应用的静态资源请求和采用了 fetch 的接口请求全部会走自定义 fetch

    技巧

    对于需要携带 cookie 的请求,可以采用自定义 fetch 方式实现:(url, options) => window.fetch(url, { ...options, credentials: "include" })

alive
  • 默认值: false

  • 类型: Boolean

  • 详情:
    保活模式,子应用实例 instancewebcomponent 都不会销毁,子应用的状态和路由都不会丢失,切换子应用只是对 webcomponent 和容器的热插拔

    如果子应用不想做生命周期改造,子应用切换又不想有白屏时间,可以采用保活模式

    如果主应用上有多个菜单栏跳转到子应用的不同页面,此时不建议采用保活模式。因为子应用在保活状态下 startApp 无法更改子应用路由,不同菜单栏无法跳转到指定子应用路由,推荐单例模式

    技巧

    预执行模式结合保活模式可以实现类似 ssr 的效果,包括页面数据的请求和渲染全部提前完成,用户可以瞬间打开子应用

exec
  • 默认值: false

  • 类型: Boolean

  • 详情: 预执行模式,为 false 时只会预加载子应用的资源,为 true 时会预执行子应用代码,极大的加快子应用打开速度

fiber
  • 默认值: true

  • 类型: Boolean

  • 详情:

    js 的执行模式,由于子应用的执行会阻塞主应用的渲染线程,当设置为 true 时 js 采取类似 react fiber 的模式方式间断执行,每个 js 文件的执行都包裹在 requestidlecallback 中,每执行一个 js 可以返回响应外部的输入,但是这个颗粒度是 js 文件,如果子应用单个 js 文件过大,可以通过拆包的方式降低达到 fiber 模式效益最大化

    技巧

    打开主应用就需要加载的子应用可以将 fiber 设置为 false 来加快加载速度

    其他场景建议采用默认值

degrade
  • 默认值: false

  • 类型: Boolean

  • 详情:

    主动降级设置,无界方案采用了 proxywebcomponent 等技术,在有些浏览器上可能出现不兼容的情况,此时无界会自动进行降级,采用一个的 iframe 替换 webcomponent,用 Object.defineProperty 替换 proxy,理论上可以兼容到 IE 9,但是用户也可以将 degrade 设置为 true 来主动降级

    警告

    一旦采用降级方案,弹窗由于在 iframe 内部将无法覆盖整个应用

plugins
  • 类型: Array<plugin>
interface ScriptObjectLoader {
  /** 脚本地址,内联为空 */
  src?: string;
  /** 脚本是否为 module 模块 */
  module?: boolean;
  /** 脚本是否为 async 执行 */
  async?: boolean;
  /** 脚本是否设置 crossorigin */
  crossorigin?: boolean;
  /** 脚本 crossorigin 的类型 */
  crossoriginType?: 'anonymous' | 'use-credentials' | '';
  /** 内联 script 的代码 */
  content?: string;
  /** 执行回调钩子 */
  callback?: (appWindow: Window) => any;
}

interface StyleObjectLoader {
  /** 样式地址, 内联为空 */
  src?: string;
  /** 样式代码 */
  content?: string;
}

type eventListenerHook = (iframeWindow: Window, type: string, handler: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions) => void;

interface plugin {
  /** 处理 html 的 loader */
  htmlLoader?: (code: string) => string;
  /** js 排除列表 */
  jsExcludes?: Array<string | RegExp>;
  /** js 忽略列表 */
  jsIgnores?: Array<string | RegExp>;
  /** 处理 js 加载前的 loader */
  jsBeforeLoaders?: Array<ScriptObjectLoader>;
  /** 处理 js 的 loader */
  jsLoader?: (code: string, url: string, base: string) => string;
  /** 处理 js 加载后的 loader */
  jsAfterLoaders?: Array<ScriptObjectLoader>;
  /** css 排除列表 */
  cssExcludes?: Array<string | RegExp>;
  /** css 忽略列表 */
  cssIgnores?: Array<string | RegExp>;
  /** 处理 css 加载前的 loader */
  cssBeforeLoaders?: Array<StyleObject>;
  /** 处理 css 的 loader */
  cssLoader?: (code: string, url: string, base: string) => string;
  /** 处理 css 加载后的 loader */
  cssAfterLoaders?: Array<StyleObject>;
  /** 子应用 window addEventListener 钩子回调 */
  windowAddEventListenerHook?: eventListenerHook;
  /** 子应用 window removeEventListener 钩子回调 */
  windowRemoveEventListenerHook?: eventListenerHook;
  /** 子应用 document addEventListener 钩子回调 */
  documentAddEventListenerHook?: eventListenerHook;
  /** 子应用 document removeEventListener 钩子回调 */
  documentRemoveEventListenerHook?: eventListenerHook;
  /** 子应用 向 body、head 插入元素后执行的钩子回调 */
  appendOrInsertElementHook?: <T extends Node>(element: T, iframeWindow: Window) => void;
  /** 子应用劫持元素的钩子回调 */
  patchElementHook?: <T extends Node>(element: T, iframeWindow: Window) => void;
  /** 用户自定义覆盖子应用 window 属性 */
  windowPropertyOverride?: (iframeWindow: Window) => void;
  /** 用户自定义覆盖子应用 document 属性 */
  documentPropertyOverride?: (iframeWindow: Window) => void;
}
  • 详情: 无界插件,在运行时动态的修改子应用代理。
beforeLoad
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,加载子应用前调用

beforeMount
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,子应用 mount 之前调用

afterMount
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,子应用 mount 之后调用

beforeUnmount
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,子应用 unmount 之前调用

afterUnmount
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,子应用 unmount 之后调用

activated
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,保活子应用进入时触发

deactivated
  • 类型: (appWindow: Window) => any

  • 详情: 生命周期钩子,保活子应用离开时触发

loadError
  • 类型: (url: string, e: Error) => any

  • 详情: 生命周期钩子,子应用加载资源失败后触发

    注意

    如果子应用没有做生命周期改造,beforeMount、afterMount、beforeUnmount、afterUnmount 这四个生命周期都不会调用,非保活子应用 activated、deactivated 这两个生命周期不会调用




destroyApp
  • 类型: Function

  • 参数: string,子应用name

  • 返回值: void

主动销毁子应用,承载子应用的 iframeshadowRoot 都会被销毁,无界实例也会被销毁,相当于所有的缓存都被清空,除非后续不会再使用子应用,否则都不应该主动销毁。




子应用

全局变量

无界会在子应用的window对象中注入一些全局变量:

declare global {
  interface Window {
    // 是否存在无界
    __POWERED_BY_WUJIE__?: boolean;
    // 子应用公共加载路径
    __WUJIE_PUBLIC_PATH__: string;
    // 原生的querySelector
    __WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__: typeof Document.prototype.querySelector;
    // 原生的querySelectorAll
    __WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__: typeof Document.prototype.querySelectorAll;
    // 原生的window对象
    __WUJIE_RAW_WINDOW__: Window;
    // 子应用沙盒实例
    __WUJIE: WuJie;
    // 子应用mount函数
    __WUJIE_MOUNT: () => void;
    // 子应用unmount函数
    __WUJIE_UNMOUNT: () => void;
    // 注入对象
    $wujie: {
      bus: EventBus;
      shadowRoot?: ShadowRoot;
      props?: { [key: string]: any };
      location?: Object;
    };
  }
}
window.__POWERED_BY_WUJIE__

是否存在无界。
返回值:Boolean

window.__WUJIE_PUBLIC_PATH__

子应用公共加载路径
返回值:String

window.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR__

原生的 querySelector
返回值:typeof Document.prototype.querySelector

window.__WUJIE_RAW_DOCUMENT_QUERY_SELECTOR_ALL__

原生的 querySelectorAll
返回值:typeof Document.prototype.querySelectorAll

window.__WUJIE_RAW_WINDOW__

原生的 window 对象
返回值:Window 对象

window.__WUJIE

子应用沙盒实例
返回值:WuJie 实例

window.__WUJIE_MOUNT

子应用 mount 函数
返回值:() => void

window.__WUJIE_UNMOUNT

子应用 unmount 函数
返回值:() => void

window.$wujie

无界对子应用注入了 w u j i e 对象,可以通过 wujie对象,可以通过 wujie对象,可以通过wujie 或者 window.$wujie 获取

  • 类型:
    {
      bus: EventBus;
      shadowRoot?: ShadowRoot;
      props?: { [key: string]: any };
      location?: Object;
    }
    
window.$wujie.bus

同 bus

window.$wujie.shadowRoot
  • 类型:ShadowRoot

子应用的渲染容器 shadow DOM

window.$wujie.props
  • 类型:{ [key: string]: any }

主应用注入的数据

window.$wujie.location
  • 类型:Object

  • 由于子应用的 location.host 拿到的是主应用的 host,无界提供了一个正确的 location 挂载到挂载到$wujie 上

  • 当采用 vite 编译框架时,由于 script 的标签 typemodule,所以无法采用闭包的方式将 location 劫持代理,子应用所有采用 window.location.host 的代码需要统一修改成$wujie.location.host

  • 当子应用发生降级时,由于 proxy 无法正常工作导致 location 无法代理,子应用所有采用 window.location.host 的代码需要统一修改成$wujie.location.host

  • 当采用非 vite 编译框架时,proxy 代理了 window.location,子应用代码无需做任何更改。




公共使用

bus
  • 类型: EventBus
type callback = (...args: Array<any>) => any;

export declare class EventBus {
  private id;
  private eventObj;
  constructor(id: string);
  $on(event: string, fn: callback): EventBus;
  /** 任何$emit都会导致监听函数触发,第一个参数为事件名,后续的参数为$emit的参数 */
  $onAll(fn: (event: string, ...args: Array<any>) => any): EventBus;
  $once(event: string, fn: callback): void;
  $off(event: string, fn: callback): EventBus;
  $offAll(fn: callback): EventBus;
  $emit(event: string, ...args: Array<any>): EventBus;
  $clear(): EventBus;
}
  • 详情: 去中心化的事件平台,类 Vue 的事件 api,支持链式调用。
$on
  • 类型: (event: string, fn: callback) => EventBus

  • 参数:

    • {string} event 事件名
    • {callback} fn 回调参数
  • 详情: 监听事件并提供回调

$onAll
  • 类型: (fn: (event: string, ...args: Array<any>) => any) => EventBus

  • 参数:

    • {callback} fn 回调参数
  • 详情: 监听所有事件并提供回调,回调函数的第一个参数是事件名

$once
  • 类型: (event: string, fn: callback) => void

  • 参数:

    • {string} event 事件名
    • {callback} fn 回调参数
  • 详情: 一次性的监听事件

$off
  • 类型: (event: string, fn: callback) => EventBus

  • 参数:

    • {string} event 事件名
    • {callback} fn 回调参数
  • 详情: 取消事件监听

$offAll
  • 类型: (fn: callback) => EventBus

  • 参数:

    • {callback} fn 回调参数
  • 详情: 取消监听所有事件

$emit
  • 类型: (event: string, ...args: Array<any>) => EventBus

  • 参数:

    • {string} event 事件名
    • {Array<any>} args 其他回调参数
  • 详情: 触发事件

$clear
  • 类型: Function

  • 详情: 清空 EventBus 实例下所有监听事件

警告
子应用在被销毁或者重新渲染(非保活状态)时框架会自动调用清空上次渲染所有的订阅事件
子应用内部组件的渲染可能导致反复订阅(比如在 mounted 生命周期调用 w u j i e . b u s . wujie.bus. wujie.bus.on),需要用户在 unmount 生命周期内手动调用 w u j i e . b u s . wujie.bus. wujie.bus.off 来取消订阅




原理

设计原理

渲染子应用步骤:

  1. 创建和主应用同源的 iframe,路径携带了子路由的路由信息
    同源是为了方便应用间的通信,子应用需要能支持跨域;iframe 实例化完成后需立即中断加载 html,防止进入主应用的路由逻辑污染子应用
  2. 解析子应用的入口 html
    识别出 html 部分,分离 style 和 js;处理 css 重新注入 html ;创建 webComponent 并挂载 html
  3. 创建 script 标签,并插入到 iframe 的 head 中
  4. 在 iframe 中拦截 document 对象,统一将 dom 指向 shadowRoot
    这样弹窗或者冒泡组件就可以正常覆盖主应用

接下来的三步分别解决 iframe 的三个缺点:

✅ 通信非常困难的问题:iframe 和主应用是同域的,天然的共享内存通信,而且无界提供了一个去中心化的事件机制
✅ dom 割裂严重的问题:主应用提供一个容器给到 shadowRoot 插拔,shadowRoot 内部的弹窗也就可以覆盖主应用
✅ 路由状态丢失的问题:浏览器的前进后退可以天然的作用到 iframe 上,此时监听 iframe 的路由变化并同步到主应用,如果刷新浏览器,就可以从 url 读回保存的路由

将这套机制封装进无界框架:

我们可以发现:
✅ 首次白屏的问题:wujie 实例可以提前实例化,包括 shadowRoot、iframe 的创建、js 的执行,以此来加快子应用首次打开的时间
✅ 切换白屏的问题:一旦 wujie 实例可以缓存下来,子应用的切换成本变的极低,如果采用保活模式,那么相当于 shadowRoot 的插拔

会带来一定对内存开销:未激活子应用的 shadowRoot 和 iframe 内存常驻,保活模式每个页面都需要独占一个 wujie 实例



沙箱机制

无界在底层采用 proxy + Object.defineproperty 的方式将 js-iframe 中对 dom 操作劫持代理到 webcomponent shadowRoot 容器中,开发者无感知也无需关心。

应用加载机制和 js 沙箱机制

将子应用的js注入主应用同域的iframe中运行。

iframe是一个原生的window沙箱,内部有完整的historylocation接口,子应用实例instance运行在iframe中,路由也彻底和主应用解耦,可以直接在业务组件里面启动应用。

/**
 * iframe插入脚本
 * @param scriptResult script请求结果
 * @param iframeWindow
 * @param rawElement 原始的脚本
 */
export function insertScriptToIframe(scriptResult: ScriptObject | ScriptObjectLoader,
  iframeWindow: Window,
  rawElement ? : HTMLScriptElement) {
  const { src, module, content, crossorigin, crossoriginType, async, attrs, callback, onload } = scriptResult as ScriptObjectLoader;
  // ...
  if (!iframeWindow.__WUJIE.degrade && !module) {
      code = `(function(window, self, global, location) {
    ${code}
    }).bind(window.__WUJIE.proxy)(
      window.__WUJIE.proxy,
      window.__WUJIE.proxy,
      window.__WUJIE.proxy,
      window.__WUJIE.proxyLocation,
    );`;
  }
  // ...
}

收益

  • 天然 js 沙箱,不会污染主应用环境
    不用修改主应用window任何属性,只在iframe内部进行修改(注意点:无界的 js 沙箱是借助iframe实现)

  • 应用切换没有清理成本
    由于不污染主应用,子应用销毁也无需做任何清理工作


iframe 连接机制 和 css 沙箱机制

css 沙箱机制
采用 webcomponent 创建一个 wujie 自定义元素来实现页面的样式隔离,将子应用的完整结构渲染在内部。

iframe 连接机制
子应用的实例 instanceiframe 内运行,dom 在主应用容器下的 webcomponent 内,通过代理 iframedocumentwebcomponent,可以实现两者的互联;将 document 的查询类接口(getElementsByTagName、getElementsByClassName、getElementsByName、getElementById、querySelector、querySelectorAll、head、body)全部代理到 webcomponent,这样 instancewebcomponent 就精准的链接起来。

shadowRoot 的插拔
当子应用发生切换,iframe 保留下来,子应用的容器可能销毁,但 webcomponent 依然可以选择保留,这样等应用切换回来将 webcomponent 再挂载回容器上,子应用可以获得类似 vuekeep-alive 的能力

收益

  • 天然 css 沙箱
    直接物理隔离,样式隔离子应用不用做任何修改(注意点:无界的 css 沙箱是借助webcomponent实现)

  • 完整的 DOM 结构 > webcomponent保留了子应用完整的html结构,样式和结构完全对应,子应用不用做任何修改

  • 天然适配弹窗问题 > document.bodyappendChild或者insertBefore会代理直接插入到webcomponent,子应用不用做任何改造(注意点:像子应用弹窗这类最外层的 DOM,依然是处于容器之内)

  • 子应用保活
    子应用保留iframewebcomponent,应用内部的state不会丢失(注意点:子应用的保活是由于保留了 DOM)




常见问题及解决方案

1、请求资源报错

请求报错为:Access to fetch at *** from origin *** has been blocked by CORS policy: No 'Access-Control-Allow-Origin'

原因: 子应用跨域或者请求子应用资源没有携带 cookie

解决方案:

  1. 可能是跨域导致的错误,子应用需要配置跨域

  2. 可能是求资源没有携带 cookie(一般请求返回码是 302 跳转到登录页),需要通过自定义 fetchfetchcredentials设置为include,这样cookie才会携带上去

warning 警告
credentials设置为include时,服务端的Access-Control-Allow-Origin不能设置为*,原因详见,服务端可以这样设置:

ctx.set("Access-Control-Allow-Origin", ctx.headers.origin);

2、第三方包已经引入,使用时报错

原因: 脚本本来在全局执行,此时第三方包定义的全局变量(比如var xxx)会直接挂载到window上。但是wujie将所有的脚本都包裹在一个闭包内运行方便劫持修改location,所以这些全局变量会留在闭包内,无法挂载到window上,子应用的异步脚本会在另一个闭包内运行,所以拿不到这些全局变量。

解决方案:

1、方式一:需要将第三方包定义的全局变量显式的挂载到window上(比如window.xxx),或者修改第三方包webpackoutput.libraryTarget

2、方式二:如果用户不想修改代码可以通过插件的形式在运行时将全局定义的代码 xxx=替换成window.xxx=

3、子应用的字体没有生效

原因: @font-face不会在shadow内部加载,详见

已解决: 框架会将子应用的@font-face放到shadow外部执行,注意子应用的自定义字体名和主应用的自定义字体名不能重复,否则可能存在覆盖问题

4、冒泡系列组件(比如下拉框)弹出位置不正确

原因: 比如element-plus采用了popper.js2.0 版本,这个版本计算位置会递归元素一直计算到window.visualViewport,但是子应用的dom挂载在shadowRoot上,并没有window.visualViewport这部分滚动量,导致偏移计算失败

解决方案: 将子应用将body设置为position: relative即可

5、子应用处理异步处理事件时,e.target 变成了 wujie-app

原因: 这个问题是浏览器原生的处理,详见

解决方案: 在异步处理时,获取 e.target 的方式需要修改成:
(e.target.shadowRoot && e.composed) ? (e.composedPath()[0] || e.target) : e.target

6、css 样式内部的相对地址相对的是主应用的域名

已解决: 框架已处理,默认将相对地址转换成绝对地址

7、子应用使用 module federation 引用远程模块报错

原因: 原因同 2,都是由于闭包执行脚本导致脚本内的全局变量在其他脚本中无法读取

解决方案:ModuleFederationPlugin插件中设置librarytypewindow

  library: { type: 'window', name: '保持和name一致' }

8、子应用 iframe 初始化时加载、执行了主应用的资源

原因: 原因详见issue

解决方案:

  • 主应用提供一个路径比如说 https://host/empty ,这个路径返回不包含任何内容,子应用设置 attr{src:'https://host/empty'},这样 iframe 的 src 就是 https://host/empty
  • 在主应用 template 的 head 第一个元素插入一个<script>if(window.parent !== window) {window.stop()}</script>这样的标签应该可以避免主应用代码污染

9、子应用 window 是一个代理对象,如何获取子应用的真实对象

原因: 为何采用代理,原因详见issue

解决方案:

  • 采用 window.__WUJIE_RAW_WINDOW__ 获取真实的 window 对象

10、DOMException: Blocked a frame with origin from accessing a cross-origin frame 报错

可能原因: 子应用的沙箱被替换掉了,有三个原因:

  1. 子应用运行在一个空白的、src 为主应用 hostiframe 中,这个 host 地址会发生 302 之类的跳转导致沙箱被弄掉了
  2. 子应用为 vite 应用,修改了 window.location.href 导致沙箱被替换掉了
  3. 子应用添加了 jsIgnoresplugin,对应的 js 文件修改了 window.location.href

解决方案:

  1. 主应用提供一个路径比如说 https://host/empty ,这个路径返回不包含任何内容也不会跳转,子应用设置 attr{src:'https://host/empty'},这样 iframesrc 就是 https://host/empty
  2. vite 子应用所有的 location 操作都必须采用 window.$wujie.location
  3. jsIgnores 对应的 js 文件所有的 location 操作都必须采用 window.$wujie.location

11、子应用的相对地址图片没有替换成绝对地址

原因: 子应用通过 v-htmlinnerHtml或者在template中动态添加style时,框架默认的plugin无法处理这种场景

解决办法: 在子应用入口main文件最上面 import "./config"config具体代码:

if (window.__POWERED_BY_WUJIE__) {
  // eslint-disable-next-line
  window.__webpack_public_path__ = window.__WUJIE_PUBLIC_PATH__;
}

12、vite4 子应用样式切换丢失

具体原因和解决办法详见issue




小结

无界的优势

  • 组件式的使用方式
    无需注册,更无需路由适配,在组件内使用,跟随组件装载、卸载。

  • 应用级别的 keep-alive
    子应用开启保活模式后,应用发生切换时整个子应用的状态可以保存下来不丢失,结合预执行模式可以获得类似ssr的打开体验

  • 多应用同时激活在线
    框架具备同时激活多应用,并保持这些应用路由同步的能力

  • 纯净无污染

    • 无界利用iframewebcomponent来搭建天然的js隔离沙箱和css隔离沙箱
    • 利用iframehistory和主应用的history在同一个top-level browsing context来搭建天然的路由同步机制
    • 副作用局限在沙箱内部,子应用切换无需任何清理工作,没有额外的切换成本
  • 性能和体积兼具

    • 子应用执行性能和原生一致,子应用实例instance运行在iframewindow上下文中,避免with(proxyWindow){code}这样指定代码执行上下文导致的性能下降,但是多了实例化iframe的一次性的开销,可以通过 preload 提前实例化
    • 体积比较轻量,借助iframewebcomponent来实现沙箱,有效的减小了代码量
;