微前端-乾坤(初学)

936 阅读5分钟

什么是微前端?

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

image.png

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

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

基于这个呢出现了一个相对完善的微前端架构系统qiankun

特性

  • 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 技术栈无关,任意应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
  • 样式隔离,确保微应用之间样式互相不干扰。
  • JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。

项目地址

实践开始

参考官方的 examples 代码,项目根目录下有基座main和其他子应用sub-vuesub-react,搭建后的初始目录结构如下:

├── common     //公共模块
├── main       // 基座
├── react      // react子应用
└── vue        // vue子应用

基座配置(main)

基座应该保持简洁(qiankun官方demo甚至直接使用原生html搭建),不应该做涉及业务的操作。

所以按照官网的来搭建后的目录结构如下:

├── node_modules 
├── index.html
├── index.js   // 子应用的配置
└── webpack.config.js  // webpack的一些配置
└── package.json

但是 我们的项目肯定是要用到UI库的,所以vue脚手架走起

// 在此之前我们需要做一些先手工作
// 初始化一个package.json 用来安装依赖
npm init 

$ yarn add qiankun # 或者 npm i qiankun -S
// 为简化启动配置
$ npm install npm-run-all --save-dev 或者 yarn add npm-run-all --dev

vue create demo

修改 main.js

import Vue from 'vue'
import App from './App.vue'
import { registerMicroApps, start, setDefaultMountApp } from 'qiankun';

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

/**
 * Step1 注册子应用
 */
registerMicroApps(
  [
    {
      name: 'react1',
      entry: '//localhost:7100',
      container: '#subapp-viewport',
      activeRule: '/react1',
    },
    {
      name: 'vue1',
      entry: '//localhost:7101',
      container: '#subapp-viewport',
      activeRule: '/vue1',
    },
  ],
  {
    beforeLoad: [
      app => {
        console.log('[LifeCycle] before load %c%s', 'color: green;', app.name);
      },
    ],
    beforeMount: [
      app => {
        console.log('[LifeCycle] before mount %c%s', 'color: green;', app.name);
      },
    ],
    afterUnmount: [
      app => {
        console.log('[LifeCycle] after unmount %c%s', 'color: green;', app.name);
      },
    ],
  },
);

/**
 * Step2 设置默认进入的子应用
 */
setDefaultMountApp('/react1');

/**
 * Step3 启动应用
 */
start();

App.vue中,需要声明main.js配置的子应用挂载div(注意id一定要一致),修改App.vue

<template>
  <div class="mainapp">
    <header class="mainapp-header">
      <h1>这里是个头部公用</h1>
    </header>
    <div class="mainapp-main">
      <!-- 侧边栏 -->
      <ul class="mainapp-sidemenu">
        <li @click="goToUrl('/react1')">react1</li>
        <li @click="goToUrl('/vue1')">Vue1</li>
        <li @click="goToUrl('/jquery1')">jquery1</li>
      </ul>
      <!-- 子应用  -->
      <main id="subapp-viewport"></main>
    </div>
  </div>
</template>
<script>
  export default {
    name: 'mainapp',
    methods: {
      goToUrl(subapp) {
        history.pushState(null, subapp, subapp)
      }
    },
  }
</script>

这样,基座就算配置完成了。项目启动后,子应用将会挂载到<main id="subapp-viewport"></main>中。

子应用配置

vue子应用

为了方便管理项目与基座同目录创建子目录

Vue-cli在项目根目录新建一个叫vue1的子应用,子应用的名称最好与父应用在基座main中main.js配置的名称一致(这样可以直接使用package.json中的name作为output)。

新增vue.config.jsdevServer的端口改为与主应用配置的一致,且加上跨域headersoutput配置.

const path = require('path');
const { name } = require('./package');

function resolve(dir) {
  return path.join(__dirname, dir);
}

const port = 7101; // dev port

module.exports = {

  outputDir: 'dist',
  assetsDir: 'static',
  filenameHashing: true,
  devServer: {
    hot: true,
    disableHostCheck: true,
    port,
    overlay: {
      warnings: false,
      errors: true,
    },
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
  // 自定义webpack配置
  configureWebpack: {
    resolve: {
      alias: {
        '@': resolve('src'),
      },
    },
    output: {
      // 把子应用打包成 umd 库格式
      library: `${name}-[name]`,
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  },
};

新增src/public-path.js

if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

改造main.js

import './public-path'; // 引入
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';

Vue.config.productionTip = false;

let router = null;
let instance = null;
function render(props = {}) {
  const { container } = props;
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/vue1' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    render: h => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app');
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

export async function bootstrap() {
  console.log('vue 启动视图');
}
export async function mount(props) {
  console.log('来自vue', props);
  render(props);
}
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}

至此,基础版本的vue子应用配置好了。

react子应用
$ npx create-react-app react1

新增 .env文件添加PORT变量,端口号与父应用配置的保持一致。

.env文件

SKIP_PREFLIGHT_CHECK=true
BROWSER=none
PORT=7100
WDS_SOCKET_PORT=7100

安装插件 @rescripts/cli,当然也可以选择其他的插件,例如 react-app-rewired。

$ npm install react-app-rewired --save-dev 或者 npm i -D @rescripts/cli

根目录新建config-overrides.js来填写一些weback的配置

const { name } = require('./package.json');

module.exports = {
  webpack: function override(config, env) {
    // 解决主应用接入后会挂掉的问题:https://github.com/umijs/qiankun/issues/340
    config.entry = config.entry.filter(
      (e) => !e.includes('webpackHotDevClient')
    );

    config.output.library = `${name}-[name]`;
    config.output.libraryTarget = 'umd';
    config.output.jsonpFunction = `webpackJsonp_${name}`;
    return config;
  },
  devServer: (configFunction) => {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      config.open = false;
      config.hot = false;
      config.headers = {
        'Access-Control-Allow-Origin': '*',
      };
      return config;
    };
  },
};

或者根目录新增 .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;
  },
};
-   "start": "react-scripts start",
+   "start": "rescripts start",
-   "build": "react-scripts build",
+   "build": "rescripts build",
-   "test": "react-scripts test",
+   "test": "rescripts test",
-   "eject": "react-scripts eject"

.rescriptsrc.js 或者 config-overrides.js 存在一个即可

src 下新建 public-path.js

if (window.__POWERED_BY_QIANKUN__) {
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

修改index.js

import './public-path';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

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'));
}

到这里react的基础版本也配置好了

最后修改最外层的package.json

"scripts": {
  "install": "npm-run-all --serial install:*",
  "install:main": "cd main && npm i",
  "install:vue1": "cd vue1 && npm i",
  "install:react1": "cd react1 && npm i",
  "start": "npm-run-all --parallel start:*",
  "start:react1": "cd react1 && npm start",
  "start:vue1": "cd vue1 && npm start",
  "start:main": "cd main && npm start",
  "test": "echo \"Error: no test specified\" && exit 1"
},
/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log('react app bootstraped');
}
/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  ReactDOM.render(<App />, props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}
/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount(props) {
  ReactDOM.unmountComponentAtNode(props.container ? props.container.querySelector('#root') : document.getElementById('root'));
}
/**
 * 可选生命周期钩子,仅使用 loadMicroApp(手动加载微应用) 方式加载微应用时生效
 */
 export async function update(props) {
  console.log('update props', props);
}

通信

qiankun 内部提供了 initGlobalState 方法用于注册 MicroAppStateActions 实例用于通信,该实例有三个方法,分别是:

  • onGlobalStateChange: (callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) => void, 在当前应用监听全局状态,有变更触发 callback,fireImmediately = true 立即触发 callback
  • setGlobalState: (state: Record<string, any>) => boolean, 按一级属性设置全局状态,微应用中只能修改已存在的一级属性
  • offGlobalStateChange: () => boolean,移除当前应用的状态监听,微应用 umount 时会默认调用

首先,我们在主应用中注册一个 MicroAppStateActions 如下

// main/src/action/actions.ts
import { initGlobalState, MicroAppStateActions } from "qiankun";

const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);

export default actions;

注册 MicroAppStateActions 实例后,我们在需要通信的组件中使用该实例,并注册 观察者 函数

在App.vue中写入方法

mounted() {
  actions.onGlobalStateChange((state, prevState) => {
    console.log("改变前 ", prevState.userInfo);
    console.log("改变后",  state.userInfo);
  });
},
methods: {
  goToUrl(subapp) {
    history.pushState(null, subapp, subapp)
  },
  writeBtn() {
    let userInfo = {
      name: '曲小强',
      age: 27,
      sex: '男',
      desc: '肥宅快乐水,活力一整天。'
    }
    actions.setGlobalState({userInfo});
  }
}

image.png

如上监听到数据的变化了

子应用的改造

// vue1/src/action/actions.js
function emptyAction() {
  // 警告:提示当前使用的是空 Action
  console.warn("当前使用的是空 Action");
}

class Actions {
  // 默认值为空 Action
  actions = {
    onGlobalStateChange: emptyAction,
    setGlobalState: emptyAction
  };

  /**
   * 设置 actions
   */
  setActions(actions) {
    this.actions = actions;
  }

  /**
   * 映射
   */
  onGlobalStateChange(...args) {
    return this.actions.onGlobalStateChange(...args);
  }

  /**
   * 映射
   */
  setGlobalState(...args) {
    return this.actions.setGlobalState(...args);
  }
}

const actions = new Actions();
export default actions;

我们创建 actions 实例后,我们需要为其注入真实 Actions。我们在入口文件 main.jsrender 函数中注入,代码实现如下

/**
 * 渲染函数
 * 主应用生命周期钩子中运行/子应用单独启动时运行
 */
let instance = null;
function render(props) {
  if (props) {
    // 注入 actions 实例
    actions.setActions(props);
  }

  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

后续补充中...

参考文献

qiankun官网 https://qiankun.umijs.org/zh/guide/getting-started

github地址:https://github.com/umijs/qiankun