微前端架构和主流框架的使用

3,103 阅读8分钟

微前端架构

微前端的好处是不言而喻的,有效的拆分应用,实现敏捷开发和部署。

相比较于微服务的概念,微服务的重点大概有两点:

  1. 子应用有自己的堆栈,包括数据库和数据模型
  2. 各子应用可以实现轻量的相互通信

微前端也是一样,我们可以把数据库的概念换成dom,加上前端应用的特点,可以得出微前端的重点:

  1. 元素隔离:各子应用间的dom操作互不干扰
  2. 样式隔离:各子应用的css规则只在内部生效
  3. 数据隔离:保证自己的代码执行的结果只会在内部生效
  4. 数据通信:各子应用可以相互通信
  5. 框架无关:子应用自由使用框架

该架构落地的方式有iframe、npm包、WebComponent以及现有成熟框架等。

微前端框架的基本工作原理

现有框架有single-spa、qiankun、MicroApp,qiankun是基于single-spa开发的,将这两者合并讲解。

这两个框架的基本思路有较大区别:

  • qiankun:通过监听url change事件,在路由变化时匹配到渲染的子应用,获取到相关资源并进行渲染。
  • MicroApp:基于WebComponent的思路将子应用以组件化形式接入主应用中。

github上有个mini版微前端教学项目,实现方式虽不完全等于上面两个框架,但却是一个比较好的思路,推荐学习。下文记录了各部分实现的基本思路以及比较完整的工作流程,这里实现了元素隔离、样式隔离、JS沙箱、数据通信。

摘要:

  1. 子应用接入方式:参照qiankun ,监听hashchange和popstate事件,并且重写replaceState和pushState,在前端路由变化时根据子应用当前的状态加载或卸载。
  2. 元素隔离:参照MicroApp,拦截了底层原型链上元素的方法,保证子应用只能对自己内部的元素进行操作
  3. 样式隔离:参照qiankun,对子应用的link和style元素的css内容进行格式化处理,创建元素时加入特殊属性,重写css选择器
  4. JS沙箱:参照qiankun,通过Proxy代理子应用的window对象,防止应用之间全局变量的冲突,加载时记录或卸载时清空子应用的全局副作用函数
  5. 数据通信:发布订阅模式的一种实现

具体流程见下图

XTH0Zd.png

主流框架基本使用

qiankun

qiankun文档的指导手册写的很清晰,不同类型的子应用都有示例

下面给出我的demo。

主应用

import { registerMicroApps, start } from 'qiankun';

const reactAppConfig = {
  name: 'react app',
  entry: 'http://localhost:8002',
  container: '#react-app',
  activeRule: location => location.pathname.startsWith('/react-app'),
};
const vueAppConfig = {
  name: 'vue app',
  entry: 'http://localhost:8001',
  container: '#vue-app',
  activeRule: location => location.pathname.startsWith('/vue-app'),
};

registerMicroApps([reactAppConfig, vueAppConfig]);

start();

React子应用

  1. 入口文件
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import './public-path';

let root;
function render(props) {
  root = ReactDOM.createRoot(
    props.container
      ? props.container.querySelector('#root')
      : document.getElementById('root')
  );
  root.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
}
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}
/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log('react app bootstraped');
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log('react app mount');
  render(props);
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount(props) {
  console.log('react app unmount');
  root?.unmount();
}

/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {
  console.log('react app update props', props);
}

  1. history路由模式设置basename
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/app-react' : '/'}>
  1. src目录增加public-path.js,在入口文件引入,主要是避免子应用的静态资源使用相对地址时加载失败的情况
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. webpack,这里我使用create-react-app创建项目,不修改wenpack配置也可以正常运行,如果需要修改,可按照以下两种方式:
  • eject 出webpack配置文件修改
  • @rescripts/cli修改(当然还有类似的其他库),安装@rescripts/cli之后根目录添加.rescriptsrc.js
const { name } = require('./package');

module.exports = {
  webpack: config => {
    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    config.output.globalObject = 'window';

    return config;
  },

  devServer: _ => {
    const config = _;

    config.headers = {
      'Access-Control-Allow-Origin': '*',
    };
    config.historyApiFallback = true;

    config.hot = false;
    config.watchContentBase = false;
    config.liveReload = false;

    return config;
  },
};

这里说三点配置:

  • historyApiFallback :解决单页的 404 问题
  • Access-Control-Allow-Origin :解决主应用访问子应用的跨域问题
  • umd模式:只在初次渲染时执行所有js
  1. 配置文件,根目录新建env文件,主要是为了固定端口,也可以采用其他方式
SKIP_PREFLIGHT_CHECK=true
BROWSER=none
PORT=8002
WDS_SOCKET_PORT=8002

Vue子应用

大体和React子应用一样

  1. 入口文件,里面也有history路由模式设置base的内容
/* eslint-disable */
import { createApp } from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import './public-path';

let app, router;

function render(props) {
  app = createApp(App);
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue/' : '/',
    mode: 'history',
    routes,
  });
  app.mount(
    props.container
      ? props.container.querySelector('#app')
      : document.getElementById('app')
  );
}
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}
/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log('vue app bootstraped');
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  render(props);
  console.log('vue app mount');
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  app.unmount();
  console.log('vue app unmount');
}

/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {
  console.log('vue app update props', props);
}

  1. src目录增加public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  1. webpack,修改vue.config.js
const packageName = require('./package.json').name;

module.exports = {
  configureWebpack: {
    devtool: 'source-map',
    output: {
      library: `${packageName}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${packageName}`,
    },
  },
  devServer: {
    port: 8001,
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
    historyApiFallback: true,
  },
  publicPath: '//localhost:8001/',
};

同时渲染多个子应用

使用loadMicroApp

// 加载多个子应用
let reactLoadApp, vueLoadApp;
history.listen(history => {
  if (history.pathname === '/multiple-app') {
    if (!vueLoadApp && !reactLoadApp) {
      reactLoadApp = loadMicroApp(reactAppConfig);
      vueLoadApp = loadMicroApp(vueAppConfig);
    }
  } else {
    reactLoadApp?.unmount();
    vueLoadApp?.unmount();
    reactLoadApp = null;
    vueLoadApp = null;
  }
});

上述代码的效果是当路由跳转到/multiple-app,就会显示React和Vue子应用。这里要注意,在卸载之后要将reactLoadAppvueLoadApp置为null,否则会出问题。

数据通信

主应用,initGlobalState初始化一个对象,通过onGlobalStateChange属性监听变化,setGlobalState属性设置数据。

import { initGlobalState } from 'qiankun';

// 初始化 state
const state = { message: 'message' };
const actions = initGlobalState(state);
actions.onGlobalStateChange((state, prev) => {
  // state: 变更后的状态; prev 变更前的状态
  console.log('主应用监听到变化', state, prev);
});
setTimeout(() => {
  actions.setGlobalState({ message: 'message-main' });
}, 2000);

子应用,一般在mount里接收,props上可以接收到onGlobalStateChangesetGlobalState

// 一般在mount里接收
export async function mount(props) {
  console.log('react app mount');
  render(props);
  props.onGlobalStateChange((state, prev) => {
    // state: 变更后的状态; prev 变更前的状态
    console.log('react子应用监听到变化', state, prev);
  });
  setTimeout(() => {
    props.setGlobalState({ message: 'message-react' });
  });
}

效果

qiankun_demo2.gif

常见错误

最常见的大概就是这个错误了

Application died in status LOADING_SOURCE_CODE: You need to export the functional lifecycles in xxx entry

文档给出了很全面的解答

MicroApp

接入成本比qiankun低,官网有很详细的手把手教的例子。

下面给出我的demo。

主应用

入口文件

import microApp from '@micro-zoe/micro-app';

microApp.start();

新建ReactMicroApp组件

import React from 'react';

export default function ReactMicroApp() {
  return (
    <micro-app
      name="react-app"
      url="http://localhost:8002"
      baseroute="/react-app"
    />
  );
}

新建VueMicroApp组件

import React from 'react';

export default function VueMicroApp() {
  return (
    <micro-app
      name="vue-app"
      url="http://localhost:8001"
      baseroute="/vue-app"
    />
  );
}

子应用

和接入qiankun的某些步骤是一样的,这里不写具体代码了:

  1. 新建public-path.js文件,入口文件引入
  2. 子应用路由设置base
  3. webpack的devServer设置跨域访问Access-Control-Allow-Origin

不需要子应用导出任何函数

同时渲染多个子应用

本身就是组件化方式引入,多个子应用引入多个组件即可

import React from 'react';

export default function MultipleMicroApp() {
  return (
    <>
      <micro-app
        name="react-app"
        url="http://localhost:8002"
        baseroute="/react-app"
      />
      <micro-app
        name="vue-app"
        url="http://localhost:8001"
        baseroute="/vue-app"
      />
    </>
  );
}

数据通信

这里以react子应用为例,其他子应用相同使用方式

主传子

主应用,简单的microApp.setData

import React from 'react';
import microApp from '@micro-zoe/micro-app';

export default function ReactMicroApp() {
  const sendMessageToMicro = () => {
    // 传入的数据必须是对象
    microApp.setData('react-app', {
      message: `父给子传的数据 ${Math.random()}`,
    });
  };
  return (
    <>
      <button onClick={sendMessageToMicro}>给Reacr子应用传消息</button>
      <micro-app
        name="react-app"
        url="http://localhost:8002"
        baseroute="/react-app"
      />
    </>
  );
}

子应用,设置监听函数window.microApp.addDataListener

import { useEffect } from 'react';
import './App.css';

function App() {
  useEffect(() => {
    if (window.microApp) {
      const dataListener = data => {
        alert('主应用传来的数据:' + JSON.stringify(data));
      };

      window.microApp.addDataListener(dataListener);
      return () => {
        window.microApp.clearDataListener();
      };
    }
  }, []);

  return (
    <div className="App">
      ...
    </div>
  );
}

export default App;
子传主

子应用,简单的window.microApp.dispatch

import { useEffect } from 'react';
import './App.css';

function App() {
  useEffect(() => {
    if (window.microApp) {
      const dataListener = data => {
        alert('主应用传来的数据:' + JSON.stringify(data));
      };

      window.microApp.addDataListener(dataListener);
      return () => {
        window.microApp.clearDataListener();
      };
    }
  }, []);

  const sendMeaasgeToMain = () => {
    window.microApp.dispatch({
      message: `react-app子应用传来的数据 ${Math.random()}`,
    });
  };

  return (
    <div className="App">
      ...
      <button onClick={sendMeaasgeToMain}>子应用给主应用传消息</button>
    </div>
  );
}

export default App;

主应用,给组件加onDataChange属性。一定要导入jsxCustomEvent,而且注释也必须加

import React from 'react';
import microApp from '@micro-zoe/micro-app';
/** @jsxRuntime classic */
/** @jsx jsxCustomEvent */
import jsxCustomEvent from '@micro-zoe/micro-app/polyfill/jsx-custom-event';

export default function ReactMicroApp() {
  const sendMessageToMicro = () => {
    microApp.setData('react-app', {
      message: `父给子传的数据 ${Math.random()}`,
    });
  };

  const onDataChange = e => {
    console.log(e);
    alert('子应用传来的消息' + JSON.stringify(e.detail));
  };

  return (
    <>
      <button onClick={sendMessageToMicro}>给Reacr子应用传消息</button>
      <micro-app
        name="react-app"
        url="http://localhost:8002"
        baseroute="/react-app"
        onDataChange={onDataChange}
      />
    </>
  );
}

效果

microApp_demo.gif

操作对比

对上面两个框架的demo操作过程进行一个简单的对比,可见MicroApp的侵入性较低。

以上两个demo都是最简版的,更多高级操作请参见官方文档。

主应用

qiankunMicroApp
子应用位置是Container element子应用位置是添加自定义标签元素
registerMicroApps不需要注册,普通的路由切换

子应用

qiankunMicroApp
更改 public path更改 public path
指定history模式路由的 basename指定history模式路由的 basename
入口文件导出生命周期-
配置跨域访问配置跨域访问
配置 Webpack 的 output-

demo地址

如果你觉得我写的还不错,欢迎关注我的公众号【HxY码迹】。