聊一聊微前端以及qiankun源码

一、微前端背景

1,什么是微前端

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。 微前端架构具备以下几个核心价值:

  • 技术栈无关
    主框架不限制接入应用的技术栈,微应用具备完全自主权
  • 独立开发、独立部署
    微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 增量升级
    在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
  • 独立运行时
    每个微应用之间状态隔离,运行时状态不共享

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

2,why not iframe

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。 其实这个问题之前这篇也提到过,这里再单独拿出来回顾一下好了。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

3,为什么笔者要尝试微前端

(1)突然交接的项目为vue,而团队中对于react已经有所积累,新的需求需要在react中开发。
(2)项目交接较频繁,需要将各个项目切分清晰,以防频繁的切割代码仓库等情况。
(3)当前一个项目中包含了多个不同的业务,且代码量较多,需要进行切分。
(4)团队技术探索。

二、微前端框架qiankun介绍

1,qiankun获取微服务及主要渲染流程

qiankun渲染流程.png

2,qiankun的基本使用

(1)base服务注册微服务

import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
  {
    name: 'reactApp',
    entry: '//localhost:3000',
    container: '#container',
    activeRule: '/app-react',
  },
  {
    name: 'vueApp',
    entry: '//localhost:8080',
    container: '#container',
    activeRule: '/app-vue',
  },
  {
    name: 'angularApp',
    entry: '//localhost:4200',
    container: '#container',
    activeRule: '/app-angular',
  },
]);
// 启动 qiankun
start();
复制代码

name: 微服务标识,如基座应用就是通过该值找到了微服务的生命周期函数
entry: html地址,未使用js entry主要是因为js的hash值不断变更且会丢置很多源信息
container: 渲染在基座应用的容器
activeRule: 基座应用路由匹配信息

(2)微服务编写生命周期

function render(props) {
  const { container } = props;
  ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root'));
}


if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}


export async function bootstrap() {
  console.log('[react16] react app bootstraped');
}


export async function mount(props) {
  console.log('[react16] props from main framework', props);
  render(props);
}


export async function unmount(props) {
  const { container } = props;
  ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') :   document.querySelector('#root'));
}
复制代码

(3) 微服务开启跨域且更改library

const { name } = require('./package');
module.exports = {
  devServer: {
    headers: {
      // 开启跨域
      'Access-Control-Allow-Origin': '*',
    },
  },
  configureWebpack: {
    output: {
      // 让基座应用获取生命周期
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
    },
  },
};
复制代码

三、微前端的核心功能及qiankun的实现

1, 路由劫持

当基座应用更改url的时候,需要匹配到相应微服务,也就是当路由路径符合activeRules的时候自动触发渲染entry里面的内容。qiankun框架借助了single-spa的实现,single-spa重新写了window.history.pushstate和replace的方法。

window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
window.history.replaceState = patchedUpdateState(window.history.replaceState, "replaceState");

function shouldBeActive(app) {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    handleAppError(err, app, SKIP_BECAUSE_BROKEN);
    return false;
  }
}
复制代码

2,获取entry

根据entry获取到整个文本内容(开启跨域),将文本内容添加到container中

// get the entry html content and script executor
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

const appContent = getDefaultTplWrapper(appInstanceId)(template);
...

let initialAppWrapperElement: HTMLElement | null = createElement(
  appContent,
  strictStyleIsolation,
  scopedCSS,
  appInstanceId,
);
...

function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  
  // 静态内容添加
  containerElement.innerHTML = appContent;
  ...
}
复制代码

3, 解析加载js内容

步骤2中已经将文本添加到container中,但是此时的js并没有执行,所以还需要将js文件单独获取后,通过new Function或eval等函数执行一遍。包括src的script标签(获取内容后执行),不包括src的则直接执行。并在执行之后,通过document.createComment的方式,将原有代码注释掉。

case SCRIPT_TAG_NAME: {
  const { src, text } = element as HTMLScriptElement;
  // some script like jsonp maybe not support cors which should't use execScripts
  if (excludeAssetFilter && src && excludeAssetFilter(src)) {
    return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
  }

  const mountDOM = appWrapperGetter();
  const { fetch } = frameworkConfiguration;
  const referenceNode = mountDOM.contains(refChild) ? refChild : null;

  if (src) {
    execScripts(null, [src], proxy, {
      fetch,
      strictGlobal,
      ...
    });
    
    // 注释原有代码
    const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
    dynamicScriptAttachedCommentMap.set(element, dynamicScriptCommentElement);
    ...
  }

  // inline script never trigger the onload and onerror event
  execScripts(null, [`<script>${text}</script>`], proxy, { strictGlobal });
  
  // 注释原有代码
  const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
  dynamicScriptAttachedCommentMap.set(element, dynamicInlineScriptCommentElement);
  return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
}
复制代码

4,获取css内容

qiankun通过使用动态加载和卸载的方式,在渲染的时候加载style在不渲染时卸载style。来隔离css样式文件。也包含了style和link两种情况。css.process函数为添加样式方法。

case STYLE_TAG_NAME: {
  ...
  const mountDOM = appWrapperGetter();

  if (scopedCSS) {
    // exclude link elements like <link rel="icon" href="favicon.ico">
    const linkElementUsingStylesheet =
      element.tagName?.toUpperCase() === LINK_TAG_NAME &&
      (element as HTMLLinkElement).rel === 'stylesheet' &&
      (element as HTMLLinkElement).href;
    if (linkElementUsingStylesheet) {
      const fetch =
        typeof frameworkConfiguration.fetch === 'function'
          ? frameworkConfiguration.fetch
          : frameworkConfiguration.fetch?.fn;
      stylesheetElement = convertLinkAsStyle(
        element,
        (styleElement) => css.process(mountDOM, styleElement, appName),
        fetch,
      );
      dynamicLinkAttachedInlineStyleMap.set(element, stylesheetElement);
    } else {
      css.process(mountDOM, stylesheetElement, appName);
    }
  }

  ...
}

// 添加样式文件
process(styleNode: HTMLStyleElement, prefix: string = '') {
  if (styleNode.textContent !== '') {
    const textNode = document.createTextNode(styleNode.textContent || '');
    this.swapNode.appendChild(textNode);
    const sheet = this.swapNode.sheet as any; // type is missing
    const rules = arrayify<CSSRule>(sheet?.cssRules ?? []);
    const css = this.rewrite(rules, prefix);
    // eslint-disable-next-line no-param-reassign
    styleNode.textContent = css;

    // 删除原来的文件
    this.swapNode.removeChild(textNode);
    return;
  }
}
复制代码

四、核心问题

1, 如何获取到微服务的生命周期函数

将生命周期函数挂在到window[app.name]中,基座应用便可以直接获得mount,boostrap,unmout函数。

const { name } = require('./package');
module.exports = {
  configureWebpack: {
    output: {
      library: `${name}-[name]`,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
    },
  },
};
复制代码

2,如何实现js隔离

qiankun创建了沙盒,将window下的对象通过proxy代理的方式返回,并在下一个微服务mount的时候重新代理。这里又分为快照沙盒和代理沙盒
快照沙盒,如下,在微服务加载对当前的window进行拍照,在inactive的时候进行还原。该方法在不支持proxy的浏览器中使用,但是因为window的对象过于庞大,进行快照十分消耗性能。

export default class SnapshotSandbox implements SandBox {
  proxy: WindowProxy;

  name: string;

  type: SandBoxType;

  sandboxRunning = true;

  private windowSnapshot!: Window;

  private modifyPropsMap: Record<any, any> = {};

  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;
  }
}
复制代码

Proxy沙盒,内部维护一个updateValueMap对象,通过代理window,来动态返回该对象中的值,起到保存window的作用。

// 代理沙盒
const proxy = new Proxy(fakeWindow, {
  set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
    if (this.sandboxRunning) {
      ...
      if (variableWhiteList.indexOf(p) !== -1) {
        // @ts-ignore
        globalContext[p] = value;
      }
      ...
    }
   },
   get: (target: FakeWindow, p: PropertyKey): any => {
      ...
      const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
      return getTargetValue(boundTarget, value);
   },
    
});

// inactive还原
inactive() {
  ...
  variableWhiteList.forEach((p) => {
    if (this.proxy.hasOwnProperty(p)) {
      // @ts-ignore
      delete this.globalContext[p];
    }
  });
  this.sandboxRunning = false;
}
复制代码

3,CSS隔离

当前css隔离一般有如下几种方式:
(1)css module,无法保证引入的第三方依赖,比如第三方修改了全局的样式。
(2)shadow dom,因为只在当前dom下有效,而实际场景中往往会插入到全局样式中,如Modal,就会造成样式丢失。
(3)动态加载/卸载CSS,也是qiankun采用的方式,该方法无法隔离基座应用和当前的微服务,以及一页面多微服务的场景。

4,如何进行通信

目前推荐的方法为自定义事件的方式,且不赞成微服务之间的通信。

5,如何一页面加载多微服务

qiankun教程中的方式为一页面一微服务,但我们可以通过手动加载APP的方式来达到一页面多微服务的应用。

import { loadMicroApp } from 'qiankun';

this.microApp = loadMicroApp({
      name: 'app1',
      entry: '//localhost:1234',
      container: this.containerRef.current,
      props: { brand: 'qiankun' },
});

复制代码

五、参考资料

1,qiankun官网
2,云雀
3,qiankun源码
4, single-spa源码
5,有知B站分享

猜你喜欢

转载自juejin.im/post/7077210357541896200
今日推荐