【造轮子】qiankun详解和手写

说到微前端,现在最火的方案就是qiankun。qiankun的特点是易用性和完备性很高。说白了就是能很方便、快速的接入,同时bug少,功能强大。

介绍

微前端已经火了一段时间了,就不介绍了,直接贴图得了。

什么是微前端

话不多少,本次主要做两件事情:

  1. 拆解和解析qiankun源码

  2. 尝试qiankun造轮子

分析qiankun原理

截止现在,有很多大神已经将qiankun剖析得一清二楚。从各个层面去讲的都有,参考资料如下:

作者介绍qiankun

介绍Html Entry

介绍JavaScript沙盒

介绍CSS沙盒

清除副作用

应用间通信方案

参考这么多的文档资料,以及自己阅读源码之后,我对qiankun主要功能的理解大概有这些:

基于single-spa

qiankun基于single-spa,在它的基础上增加了很多功能。single-spa是基于Js Entry的方案的:需要将整个微应用打包成一个JS文件,包括图片、css等资源(缺点:体积庞大、无法并行加载资源、接入成本高)。而Html Entry则由于保留了html,通过html作为入口处理js、css等,天然的规避了这些问题。

qiankun使用single-spa做了两件事情:

  • 注册和管理微应用(single-spa内部存储了apps信息)

    qiankun的app注册接口,会调用single-sparegisterApplication接口,保留它的路由规则和部分字段。single-sparegisterApplication接口,app参数需要传入一个包含生命周期函数的对象或工厂函数。

    registerApplication({
         
      name: 'app1',
      activeWhen: '/app1',
      app: () => import("src/app1/main.js") // 即Js Entry。其中main.js需要导出四个生命周期函数
    })
    

    而qiankun由于不使用Js Entry,所以在app()异步函数里,qiankun不是来导入js文件,而是从Html Entry开始:*fetch html文件 => 创建和设置子应用dom => 创建沙盒 => 执行子应用js文件(此时可获取到子应用导出的lifecycles) => 包装lifecycles并返回 *。

    参考代码:qiankun调用single-spa注册apploadApp()里做了什么

  • 根据路由规则切换微应用(触发微应用状态变更,需要自己实现各个状态回调)

    single-spa已经实现了hash路由和history路由的监听,然后根据子应用的激活路由规则,来调用对应子应用的生命周期函数。qiankun就拿来直接用,反正最后都会进入前面我们提到的生命周期函数。

从Html Entry入手

前面提到,qiankun在app()函数里不是导入js而是处理的Html Entry。qiankun作者将这一部分封装成了一个独立的库import-html-entry。这里面包括的功能有:

  • fetch html文件
  • 处理html内容转为template字符串
  • 获取scripts,包括内部和外部scripts
  • 对script进行包装,控制js在指定的window中运行(js沙箱的基础)
  • 获取内部和外部样式表

可以看出这个库主要就是从Html Entry入手处理template、script和style,都只是处理,并没有执行,执行在qiankun源码中。不再细说,细节可以看它的源码或者其他大牛的解读。

Js沙箱

Js沙箱的目的是为了让子应用拥有自己私有的全局环境。防止子应用修改的全局变量影响到主应用或其他子应用。qiankun实现了三种Js沙箱

快照沙箱 - snapshotSandbox

snapshotSandbox是在不支持Proxy的浏览器环境下才会使用的。原理是在子应用挂载时,备份当前window存入snapshot(同时恢复上一次卸载时最后的状态);卸载时恢复window为snapshot的状态(同时记录应用变更了哪些状态)。

由于它会遍历window对象,所以性能最差。同时它修改了window对象,所以它无法做到主应用和子应用的隔离,只做到了单个子应用相互之间的隔离,多个子应用同时加载时也不行。

单例代理沙箱 - legacySandBox

legacySandBox是一个使用Proxy的沙箱。它的原理和快照沙箱一样是记录变更和恢复:创建一个window的proxy(子应用操作的是这个proxy),监听set()并收集更新(set仍然会同步window,get直接从window取)。在子应用挂载时,恢复window为上一次卸载时状态;卸载时恢复为挂载前状态。只不过由于使用了代理,它能准确知道哪些变量发生了变化,而不用像快照沙箱那样遍历整个window对象才能知道变化。

所以它的性能会好于快照沙箱,但是它仍然操作的是window全局对象,所以也无法做到主应用和子应用的隔离,多个子应用同时加载也一样有问题。

多例代理沙箱 - proxySandBox

proxySandBox也是一个使用Proxy技术的沙箱。只不过它是完全代理了window并替代之。每个子应用都使用的是自己的proxy而不是全局的window。它的原理是创建一个window的proxy。把window的属性都拷贝上去,完全就是一个副本。每个子应用唯一。子应用操作的一直都是这个proxy,所以挂载和卸载都不需要做什么处理。

由于window全局属性太多,处理的异常也非常多,有的函数和变量还必须从原始window获取。所以这部分代码还是很复杂的,作者估计也是做了非常多的bugfix。

好处也是非常明显,主应用window不会被污染,子应用之间也完全隔离,多个子应用同时加载也互不影响。

其中代理沙箱提供了window的代理proxy。qiankun会让子应用的js代码运行在这个proxy对象下(比如window、self、top、document、location等等)。这一部分是import-html-entry插件在处理javascript脚本时实现的。说白了就是eval + function + with让全局变量都从proxy中查询,并越过了严格模式的安全性错误。

Css隔离

Css隔离的目的是为了让子应用的Css与主应用或其他子应用的隔离开,防止相互影响。qiankun有三类隔离方式:

默认方式

qiankun的默认处理是将动态导入的样式表,比如使用webpack的style-loader、Vite中导入.css文件、使用css-in-js框架等,劫持appendChild等方法,将它们添加到子应用的dom容器下,卸载时则随着dom一起被移除。如果不做这个劫持的处理。那子应用的样式表将会出现在根节点<head>标签下,假如开启了Shadom DOM模式,子应用就找不到这些样式了。

对于子应用的内部样式表<style>、外部样式表<link ref="stylesheet">,不需要做拦截处理。因为它们会随着html被挂载到子应用的dom容器下。

函数调用关系:[src/loader.ts].loadApp => [src/sandbox/index].createSandboxContainer => [src/sandbox/patchers/index].patchAtBootstrapping源码位置)。


对于快照沙箱单例代理沙箱,使用patchLooseSandbox。它是通过劫持Head和Body元素的appendChildinsertBefore方法(源码),并把要添加的<style>和<link>元素插入到子应用的dom容器下(源码)。当然它只会在子应用激活时才会生效。


对于多例代理沙箱,使用patchStrictSandbox,也是和上面一样劫持了appendChildinsertBefore方法。由于多例代理沙箱下每个子应用使用自己的window代理,在这个方法中,对document也做了代理,此时仅使用document代理创建的样式表才能被插入到子应用容器下。

默认方式不需要手动开启。它能保证单例场景下子应用之间的样式隔离,但是无法保证主应用和子应用,及多实例时子应用之间的样式隔离。

Shadom DOM

qiankun支持使用Shadom DOM实现严格样式隔离,它会在子应用dom根节点创建Shadow DOM,就能确保子应用和主应用,子应用之间的样式污染问题。但是它并不是完美方案。缺点就是一些弹窗需要挂载到document.body下,这是就跳出了Shadow DOM边界,弹窗样式也就无法起作用了。

通过start({ sandbox: { strictStyleIsolation: true } })启用。

生成shadowDom的源码位置:loader.createElement

样式改写

qiankun会对子应用添加的样式改写,在子应用的根部dom节点增加一个data-qiankun属性,并对子应用的所有样式规则,添加一个div[data-qiankun="xxx"]的属性选择器,这样来保证只有子应用的dom树下,这些样式才会生效。*(额外的,改写会替换body、html、:root选择器)*能保证主应用和子应用,子应用之间的样式隔离。

通过start({ sandbox: { experimentalStyleIsolation: true } })启用。

html entry中的内联和外联样式会在loader.createElement中被处理,最终会调用ScopedCSS.process方法:它会对<style>标签内的样式进行重写,并使用MutationObserver监听该标签内的内容变更,假如<style>中后续增删CSS规则,同样进行重写。

此外,对于动态添加的<style>标签,也会在劫持方法内对样式内容进行重写。

其他

非qiankun框架提供。在项目中我们可以有其他确保样式隔离的方案:唯一的css命名前缀、css modules、CSS-in-js。

样式重建

比如webpack和vite中,应用首次加载时,可能会有很多样式是动态导入的。正如上一节所说,qiankun默认会把这些样式插入到子应用的dom容器下。随着应用被卸载,它们也一并被移除。但是子应用再次加载的时候呢?如果qiankun不去对样式进行重建,会出现什么问题?我们分析一下。

  • 子应用首次加载。应用从html entry开始fetch,全部代码都会被执行。正如子应用在新tab里独立运行时一样。此过程中,对于动态导入的样式会被插入到dom中。等所有代码执行完毕后qiankun会调用子应用的mount钩子。让框架如ReactVue去接管dom。

  • 子应用卸载时,子应用的dom容器会被清空。此时,所有的<style>标签也会被移除。

  • 子应用再次加载时,qiankun不会让子应用和首次加载一样,从头fecth并执行,这样实在是太低效了。取而代之的是,qiankun会直接调用子应用的mount钩子。

这就带来问题了。只调用mount钩子,那所有的<style>标签已经被移除了,不恢复不就乱套了吗?

当然是这样,所以qiankun需要对样式进行重建。qiankun定义了三个类型:Patcher、Freer、RebuilderPatcher函数的返回值是Freer函数Freer函数的返回值是Rebuilder函数。分别代表给环境打补丁、还原打补丁前的环境、重建操作

可以看到patchLooseSandboxpatchStrictSandbox都是Patcher函数。我们重点关注它们的Rebuilder函数,它们俩代码都一样:在Rebuilder函数内,重建CSS规则,即恢复<style>标签。

清除副作用

子应用使用window.addEventListener或者setInterval等全局api时,如果子应用卸载时不移除掉,则会对其他应用带来副作用。

代码是在patchers.patchAtMounting方法中。调用patchIntervalpatchWindowListener来清除副作用的。patch方法内部,拦截了原生的方法,每次调用时记录下来。patch方法返回free()函数,用于在子应用卸载时,清除副作用

通信方案
  • 官方Actions方案

官方是事件监听的形式,监听全局状态的变更。主应用初始化状态,通过mount(props)生命周期下发到子应用。子应用可以监听和set。

实现原理是主应用负责初始化和存储全局states,提供接口到子应用,子应用添加listener,主应用管理listeners。在任意应用调用setState接口时,都触发listener回调。

  • SharedState方案

更常见的情况是,项目中已经集成了状态管理库ReduxZustand或其他。这时候就可以使用官方Actions方案当作桥梁,打通主应用、子应用的状态数据同步。并且子应用在独立运行的时候仍然使用自身的状态管理库获取数据,在嵌入主应用时,使用全局状态数据。主应用或子应用使用全局状态时,只需要使用一个hooks:

// 获取和设置全局状态,响应式
const [app, setApp] = useSharedState('AppInfo');

实现原理就是主应用和子应用都可以创建自己的SharedState,即中间层,在这里去实现针对不同状态管理库的状态获取、设置和监听。子应用如果是在qiankun环境,则从主应用获取SharedState重载掉自己本地的。然后再提供一个Hooks Api。

/**
 * 操作某一个共享状态的Hooks API。
 * 使用方法类似于React.useState,返回[state, setter]。
 */
export function useSharedState<K extends keyof SharedState>(stateName: K): readonly [StateTypeOf<SharedState[K]>, (d: StateTypeOf<SharedState[K]>) => void] {
   
  const so: SharedState[K] = state[stateName];
  // 使用useState,让其转为响应式的状态
  const [d, setD] = useState(so.get());
  // 监听主应用状态变化,并setState
  useEffect(() => {
   
    return so.subscribe(setD); // 组件销毁时,停止监听
  }, [so]);
  // 有共享状态时
  return [d, so.set] as const;
}

这里写得比较简单。有时间我再单独出一期方案详解。

自己造一个qiankun

qiankun原理介绍就这些,下面我们开始正题!自己模仿造一个qiankun轮子出来,抛开健壮性不谈,至少能用!

项目结构

保持qiankun的功能模块设计,特拆分几个文件如下:

- qiankun.ts            # qiankun的主功能
- single-spa.ts         # 类似于single-spa的功能
- import-html-entry.ts  # 类似于import-html-entry的功能
- sandbox               # 存放沙箱
  - index.ts            # 沙箱容器,控制沙箱创建、加载、卸载
  - LegacySandbox.ts    # 单例代理沙箱
  - ProxySandbox.ts     # 多例代理沙箱
  - SnapshotSandbox.ts  # 快照沙箱
- patchers              # 存放副作用补丁
  - intervals.ts        # intervals副作用补丁
  - listeners.ts        # listeners副作用补丁
- globalState.ts        # 全局state状态
single-spa.ts

功能:管理注册应用、根据路由切换应用。写完后代码如下:

export function registerApplication(appConfig: AppConfig) {
   
  _apps.push({
   
    status: AppStatus.NOT_LOADED,
    ...appConfig,
  });
}

let isStarted = false;

export function start() {
   
  if (isStarted) return;

  isStarted = true;
  reroute();
}

let _prevRoute: string, _nextRoute: string;

function reroute() {
   
  const tryFetchActiveApp = async (pathname: string) => {
   
    _prevRoute = _nextRoute;
    _nextRoute = pathname;
    const prevApp = _apps.find((app) => _prevRoute?.startsWith(app.activeWhen));
    // find the active app
    const activeApp = _apps.find((app) => pathname.startsWith(app.activeWhen));
    // if the previous app is the same as the active app, do nothing
    if (prevApp && activeApp && prevApp.name === activeApp.name) return;

    // unmount the previous app
    if (prevApp) {
   
      if (prevApp.status === AppStatus.MOUNTED)
        prevApp.status = AppStatus.NOT_LOADED;
        callOrArrayCall(prevApp.unmount, {
   });
    }
    // fetch the active app
    if (activeApp) {
   
      if (activeApp?.status === AppStatus.NOT_LOADED) {
   
        activeApp.status = AppStatus.MOUNTING;
        activeApp.app().then((lifeCycles) => {
   
          Object.assign(activeApp, lifeCycles);
          activeApp.status = AppStatus.MOUNTED;
          callOrArrayCall(activeApp.bootstrap, {
   });
          callOrArrayCall(activeApp.mount, {
   });
        });
      } else {
   
        callOrArrayCall(activeApp.bootstrap, {
   });
        callOrArrayCall(activeApp.mount, {
   });
      }
    }
  };

  function callOrArrayCall<ExtraProps extends any>(
    func: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>> | undefined,
    props: any
  ) {
   
    if (Array.isArray(func)) {
   
      func.forEach((fn) => fn(props));
    } else {
   
      func?.(props);
    }
  }

  // history.forward() or history.back() or history.go()
  window.addEventListener("popstate", () => {
   
    tryFetchActiveApp(window.location.pathname);
  });

  window.history.pushState = new Proxy(window.history.pushState, {
   
    apply(target, thisArg, args) {
   
      const pathname = args[2];
      tryFetchActiveApp(pathname);
      // @ts-ignore
      return target.apply(thisArg, args);
    },
  });

  window.history.replaceState = new Proxy(window.history.replaceState, {
   
    apply(target, thisArg, args) {
   
      const pathname = args[2];
      tryFetchActiveApp(pathname);
      // @ts-ignore
      return target.apply(thisArg, args);
    },
  });

  // 首次加载
  setTimeout(() => {
   
    tryFetchActiveApp(window.location.pathname);
  }, 400);
}
import-html-entry.ts

主要功能:导入html,加载css和javascript。写完后代码如下:

export async function importEntry(entry: string) {
   
  return importHtml(entry);
}

async function importHtml(entry: string) {
   
  // fetch html entry of app
  const response = await fetch(entry);
  const text = await response.text();
  const html = document.createElement("div");
  html.innerHTML = text;

  // script will not be executed when html is appended to the DOM
  // so we need to create script elements and append them to the DOM
  const scripts = html.querySelectorAll("script");
  const scriptContents = await fetchScript(entry, ...scripts);

  return {
   
    template: html,
    execScripts: (proxy: Window) => {
   
      scriptContents.forEach((scriptContent) => {
   
        const code = getExecutableScript(
          scriptContent.src,
          scriptContent.code,
          {
    proxy }
        );
        evalCode
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值