阿里云微前端原理剖析

2,692 阅读7分钟

关于微前端

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

微前端架构具备以下几个核心价值:

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

阿里云微前端(原名 ConsoleOS,现改为 Alfa)

产品官网:alfajs.io/docs/intro.…

在了解阿里云微前端原理之前我们先来看下面2个问题:

问题1:阿里云为什么需要微前端方案? 阿里云 是一个提供各种云服务的平台,上面有超 200 个云产品的控制台页面,每个控制台都是自己独立开发、部署、运维;但是各个控制台之前业务是存在关联关系的,如果 A 控制台要调用 B 控制台的某个功能,通常情况是跳转到 B 控制台,频繁跳出容易打断用户的操作思路,极大的降低用户体验。因此,急需一种方案让用户能够在A 控制台不用跳出,也能操作 B 控制台的功能。

问题2:阿里云为什么要自己实现一套微前端? 阿里云团队开始提出微前端方案是在2019年上半年,这时候业界还没有一款比较成熟的微前端方案,大家都是在摸石头过河,我记得当时在阿里巴巴内部还掀起了一场“华山论剑”的大讨论,各个团队聚在一起讨论到底哪种方案才是实现微前端的最优解。

了解完阿里云微前端诞生的背景以后我们要开始进入正题了。。。

体系概览

1646287964636-b6bcfd61-d41a-4e4b-ada4-07c3472738a7.png

应用的加载与渲染

微前端要解决的首要问题就是如何在一个应用中动态的加载和渲染一个远程独立部署的应用。

1646292929945-7b7927ee-b3e2-48d2-8033-278e60033126.png

  • 每个微应用都会经过云构建,将自己应用中的资源文件推送到 cdn 的同时会生成一份定义好的配置文件(manifest.json)到配置平台。生成好的配置文件结构如下:
{
  "name": "ali-alfa-cloud-dms-app-security-center-list",
  "resources": {
    "index.css": "//g.alicdn.com/alfa/cloud-dms-app-security-center-list/0.1.1/index.css",
    "index.js": "//g.alicdn.com/alfa/cloud-dms-app-security-center-list/0.1.1/index.js"
  },
  "externals": {
    "@alicloud/console-os-environment": {
      "commonjs2": "@alicloud/console-os-environment",
      "amd": "@alicloud/console-os-environment",
      "commonjs": "@alicloud/console-os-environment",
      "root": "aliOSEnvironment"
    }
  },
  "runtime": {},
  "entrypoints": {
    "index": {
      "css": [
        "//g.alicdn.com/alfa/cloud-dms-app-security-center-list/0.1.1/index.os.css"
      ],
      "js": [
        "//g.alicdn.com/alfa/cloud-dms-app-security-center-list/0.1.1/index.js"
      ]
    }
  },
  "server_entrypoint": null
}
  • 在宿主应用中,首先会通过应用 ID 获取到对应的配置文件, 从而去加载应用的资源。每个应用会导出几个生命周期方法,bootstrap, mount, unmount。 应用在 mount 方法中会接受宿主分配的 dom 节点,来做自己的加载逻辑。mount 会自动判断环境从而选择加载的方式,如果你是在 ConsoleOS 的运行时,就会加载到宿主提供的节点,如果是单独运行的则是加载到子应用指定的 app 的节点。
// 子应用
import { mount, registerExposedModule } from '@alicloud/console-os-react-portal';

const App = (props) => {
  return (
    <Router>
      <div className="container">

        <p>注释掉 externalsVars 可以看到沙箱效果🤪</p>

        <p>window.title is : <b>{ window.title }</b></p>

        <ul>
          <li><Link to="/">Home</Link></li>
          <li><Link to="/about">About</Link></li>
          <li><Link to="/dashboard">Dashboard</Link></li>
        </ul>

        <hr />

      </div>
    </Router>
  );
}

// 若 os-example 存在,则挂载;若不存在则挂载到自身 app 节点
export default mount(App, document.getElementById('app'), 'os-example')
// 主应用
import React from "react";
import ReactDOM from "react-dom";

// 加载 console os 的依赖
import Application, { start } from "@alicloud/console-os-react-app";


function App() {
  return (
    <div className="App">
      <div className="react">
        {/* 渲染子应用 */}
        <Application
          id="os-example"
          sandbox={{
            initialPath: "/dashboard",
            disableFakeBody: true,
          }}
          appDidCatch={(e) => {
            console.log(e)
          }}
          manifest="http://localhost:8082/os-example.manifest.json"
        />
      </div>
    </div>
  );
}

start({
  // 沙箱配置
  sandbox: {
    // true: 关闭沙箱, false: 打开沙箱
    // 关闭沙箱之后,点击路由你可以看到路由发生了变化
    // 再次开启之后,可以看到路由没有发生变化
    disable: false,
    // 宿主变量白名单
    externalsVars: ["Zone"],
    // 沙箱初始地址
    // initialPath: '/'
  },
  // 注入应用依赖
  deps: {
    react: React,
    "react-dom": ReactDOM
  }
});

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

沙箱系统

1646309473561-58bc8ea0-796a-4221-b352-b67d6ca23b5b.png

Javascript 隔离方案

闭包 JS 隔离最重要的其实是隔离 JS 的原生对象。这里利用了闭包的能力,在应用代码构建的时候给子应用代码加上一层 wrap 代码,创建一个闭包,把需要隔离的浏览器原生对象变成从下面函数闭包中获取的,这样的话,JS 执行的过程中,访问到全局变量,像 window、location、document 就都是通过闭包传入的,而不是宿主对象里的原生对象。

  async loadScripts(url) {
    const resp = await fetch(url, {credentials: getFetchCredentials(url)}); // 请求js
    const code = await resp.text(); // 获取js的
    this.evalScript(code, url);
  }

  evalScript(code, url="") {
    const resolver = new Function(`
      return function ({window, location, history, document}){ 
        with(window.__CONSOLE_OS_GLOBAL_VARS_) {
          try {
            ${code}
          }
          catch(e) {
            console.log(e)
          }
        }
      }//@ sourceMappingURL=${url}`)
    resolver().call(this.window, {...this})
  }

原生对象模拟 接下来的问题就是如何实现沙箱环境里模拟的原生对象了。 我们都知道 iframe 具有天然的隔离优势,那可以给每一个子应用都创建一个iframe,通过 iframe.contentWindow 对象创建原生对象。

  // 创建iframe
  static create( conf ){
    return new Promise((resolve) => {
      const iframe = document.createElement( 'iframe' );

      // TODO: change src to a reasonable value.
      iframe.setAttribute( 'src', conf.url ? conf.url : '/api.json');
      iframe.style.cssText = 'position: absolute; top: -20000px; width: 100%; height: 1px;';

      document.body.appendChild( iframe );

      // the onload will no trigger when src is about:blank
      if (conf.url === 'about:blank') {
        return resolve(new this( conf, iframe ));
      }

      iframe.onload = () => {
        resolve(new this( conf, iframe ));
      }
    })
  }

1646310940569-6530fb66-c07e-47b0-a7cd-4cdde8b3c9cd.png Proxy 代理 在取出对应的 iframe 原生对象之后,就会对特定需要隔离的对象生成对应的 Proxy, 然后对属性获取和属性设置,比如 window.document 需要返回特定的沙箱 document, 而不是当前浏览器的 document。

/**
 * Window.js
 */
import { addEventListener, removeEventListener } from './events';
import { isConstructable, isBoundedFunction } from './utils/common';

const globalFnName = ['setTimeout', 'setInterval', 'clearInterval', 'clearTimeout'];
const defaultExternals = [
  'requestAnimationFrame',
  'webkitRequestAnimationFrame',
  'mozRequestAnimationFrame',
  'oRequestAnimationFrame',
  'msRequestAnimationFrame',
  'cancelAnimationFrame',
  'webkitCancelAnimationFrame',
  'mozCancelAnimationFrame',
  'oCancelAnimationFrame',
  'msCancelAnimationFrame',
];

class Window {
  constructor( options = {}, context, frame ){
    const externals = [
      ...defaultExternals,
      ...(options.externals || [])
    ];
    const __CONSOLE_OS_GLOBAL_VARS_ = {};

    globalFnName.forEach((name) => {
      if (externals.includes(name)) {
        return;
      }
      __CONSOLE_OS_GLOBAL_VARS_[name] = frame.contentWindow[name].bind(frame.contentWindow);
    })

    return new Proxy(frame.contentWindow, {
      set( target, name, value ){
        target[ name ] = value;
        __CONSOLE_OS_GLOBAL_VARS_[ name ] = value
        return true;
      },

      get( target, name ){
        if (externals.includes(name)){
          const windowValue = window[ name ];
          if (typeof windowValue === 'function' && !isBoundedFunction(windowValue) && !isConstructable(windowValue)) {
            const bindFn = windowValue.bind(window)
            for (const key in windowValue) {
              bindFn[key] = windowValue[key];
            }
            return bindFn;
          } else {
            return windowValue;
          }
        }

        switch( name ){
          case 'document':
            return context.document;
          case 'location':
            return context.location;
          case 'history':
            return context.history;
          case '__CONSOLE_OS_GLOBAL_VARS_':
            return __CONSOLE_OS_GLOBAL_VARS_;
          case 'addEventListener':
            return addEventListener(context)
          case 'removeEventListener':
            return removeEventListener(context)
        }

        if (__CONSOLE_OS_GLOBAL_VARS_[name]) {
          return __CONSOLE_OS_GLOBAL_VARS_[name];
        }

        const value = target[ name ];
        if (typeof value === 'function' && !isBoundedFunction(value) && !isConstructable(value)){
          return value.bind && value.bind(target);
        } else {
          return value;
        }
      }
    } );
  }
}

export default Window;

这样就实现了一个 JS 沙箱系统啦。

1646312118518-d5ad0b1e-9f23-4c9d-aa60-e014a1bfac67.png

CSS 隔离方案

css 隔离方案相对来说比较常规,常见的有:

  • CSS Module
  • Shadow Dom
  • Dynamic StyleSheet
  • 添加 css 的 namespace

经过实践,最终选择 css module + 添加 css 的 namespace结合的方式。css module 保证的是应用业务样式不冲突,namespace 保证公共库不冲突。我们实现了一个 postcss 插件,会在应用构建的时候给所有的样式都加上应用前缀包括应用公共库的 css。(这样方便做到不同 fusion 版本样式的兼容)

路由

在微前端中路由核心要解决的是路由同步问题:

  • 页面切换,地址栏要相应的变化
  • 页面刷新,要能定位到原来的页面

在 ConsoleOS 中推荐的做法是开启应用沙箱,无论子应用路由如何变化,变动的都是沙箱内的路由。如果有同步的需求,可以接受沙箱里面的事件,宿主选择自己同步地址栏。

// 子应用 microappA
history.push('/a');

// 宿主的地址栏会被同步成 /microappA/a
eventBus.on(`microappA:history-change`, (location) => {
  // 只会变动地址栏,不会影响 react 的状态
  window.history.push(undefined, undefined, `/microappA/${location.href}`);
})

那如何在浏览器刷新的时候定位到微应用里面的页面呢,以 react 为例:

const router = () => {
  <Route path={'/microappA/:path'} component={MicroAppA} />
}
 
// MicroAppA.jsx
const MicroAppA =  () => (
  <Application
    manifest='https://dev.g.alicdn.com/microappA.manifest.json'
    id="microappA"
    initPath={'/a'}
  />
);

路由部分就到这里了,由于篇幅有限,关于微前端的治理、性能、工程化部分本文就不继续展开了。

写在最后

在笔者看来,不管沙箱系统做得有多完美,始终都会存在不安全因素,比较容易出现逃逸,比如访问某些变量的时候不带window.,直接使用变量名访问,就会报 undefined 。 使用微前端方案也有很多约定需要遵守,如果文档不全,很容易就踩坑。我相信目前的微前端方案只是一个中间态,未来一定会有更加完美的解决方案,比如 web components?、Webpack 5的模块联邦?