# 微前端入门

刘小标 时间:2020-11-13

# 1 什么是微前端?(What)

微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。

img

微前端的核心在于,拆完再

# 2 为什么用微前端?(Why)

面临问题:

  • 不同团队间开发同一个应用技术栈不同怎么破?
  • 希望每个团队都可以独立开发,独立部署怎么破?
  • 项目中还需要老的应用代码怎么破?

思考:我们是不是可以将一个应用划分成若干个子应用,将子应用打包成一个个的 lib。当路径切换时加载不同的子应用。这样每个子应用都是独立的,技术栈也不用做限制了!从而解决了前端协同开发问题!

# 3 微前端如何落地?(How)

2018 年 Single-SPA 诞生了, single-spa是一个用于前端微服务化的JavaScript前端解决方案 (本身没有处理样式隔离,js执行隔离) 实现了路由劫持应用加载

2019 年 qiankun基于 Single-SPA, 提供了更加开箱即用的 APIsingle-spa + sandbox + import-html-entry) 做到了,技术栈无关、并且接入简单(像 iframe一样简单)

总结:子应用可以独立构建,运行时动态加载,主子应用完全解耦,技术栈无关,靠的是协议接入(子应用必须导出 bootstrap、mount、unmount 方法)

这里先回答大家肯定会问的问题:

这不是iframe吗?

  • 如果使用iframeiframe中的子应用切换路由时用户刷新页面就尴尬了。

应用通信:

  • 基于 URL 来进行数据传递,但是传递消息能力弱
  • 基于CustomEvent实现通信
  • 基于 props 主子应用间通信
  • 使用全局变量、Redux进行通信

公共依赖:

  • CDN - externals
  • webpack联邦模块

# 3.1 Single-SPA 实践

# 3.1.1 构建子应用

vue-cli 创建子应用,安装single-spa-vue

vue create single-spa-child-one
yarn add single-spa-vue 或者 npm install --save single-spa-vue
1
2

src/main.js代码整改:

子应用必须导出 bootstrap、mount、unmount协议方法

import singleSpaVue from "single-spa-vue";
const appOptions = {
  el: "#child",
  router,
  render: (h) => h(App),
};
// 在非子应用中正常挂载应用
if (!window.singleSpaNavigate) {
  delete appOptions.el;
  new Vue(appOptions).$mount("#app");
} else {
  //告诉子应用在这个地址加载静态资源,否则会去基座应用的域名下加载
  __webpack_public_path__ = "http://localhost:8061/";
}

const vueLifeCycle = singleSpaVue({
  Vue,
  appOptions,
});
// 子应用必须导出 以下生命周期 bootstrap、mount、unmount
export const bootstrap = vueLifeCycle.bootstrap;
export const mount = vueLifeCycle.mount;
export const unmount = vueLifeCycle.unmount;
export default vueLifeCycle;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

router/index.js路由路径自定义(接入主应用的路径规则):

配置子路由基础路径

const router = new VueRouter({
  mode: "history",
  base: "/child",
  routes,
});
1
2
3
4
5

添加配置文件vue.config.js

子应用打包规则,打包为 lib 库 library:应用名称,唯一值 libraryTarget:打包库类型,“umd”

const package = require("./package.json");
module.exports = {
  configureWebpack: {
    output: {
      library: package.name,
      libraryTarget: "umd",
    },
    devServer: {
      port: 6601,
    },
  },
};
1
2
3
4
5
6
7
8
9
10
11
12

# 3.1.2 构建主应用

src/App.vue添加子应用挂载的节点:

特别注意:此处路由路径和子应用基础路径一致哦,以及挂载节点 ID 和子应用保持一致

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/child">加载子应用</router-link>
      <div id="child"></div>
    </div>
  </div>
</template>
1
2
3
4
5
6
7
8

src/main.js接入子应用:

yarn add single-spa 或者 npm install --save single-spa

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import { registerApplication, start } from "single-spa";

Vue.config.productionTip = false;

async function createScript(url) {
  return new Promise((resolve, reject) => {
    let script = document.createElement("script");
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

function loadApp(url, appName) {
  return async () => {
    console.log("加载应用:" + appName);
    await createScript(url + "/js/chunk-vendors.js");
    await createScript(url + "/js/app.js");
    return window[appName];
  };
}

const apps = [
  {
    //应用名称自定义
    name: "vueChild",
    app: loadApp("http://localhost:6601", "single-spa-child-one"),
    activeWhen: (location) => location.pathname.startsWith("/child"),
    customProps: {},
  },
];

apps.forEach((app) => {
  registerApplication(app);
});

start();

new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

# 3.1.3 小结

single-spa : 提供了路由劫持应用加载,可应对简单接入子程序的需求

缺点: 1、不够灵活,不能动态加载子应用 JS 文件 2、样式不隔离,没有 js 沙箱机制

# 3.2 qiankun 实践

# 3.2.1 主应用

1)主应用编写

主应用安装 qiankun:

$ yarn add qiankun # 或者 npm i qiankun -S
1

src/App.vue添加挂载子应用的节点vueOnevueTwo:

<template>
  <div>
    <el-menu
      :default-active="defaultIndex"
      :router="isRouter"
      mode="horizontal"
    >
      <!-- 基座中可以放自己的路由 -->
      <el-menu-item index="/">Home</el-menu-item>
      <!-- 引用其他子应用路由 -->
      <el-menu-item index="/vueOne">Child One</el-menu-item>
      <el-menu-item index="/vueTwo">Child Two</el-menu-item>
    </el-menu>
    <router-view></router-view>
    <div id="vueOne"></div>
    <div id="vueTwo"></div>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

2)注册子应用 src/main.js注册子应用:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import ElementUI from "element-ui";
import "element-ui/lib/theme-chalk/index.css";
import { registerMicroApps, start } from "qiankun";

Vue.use(ElementUI);
Vue.config.productionTip = false;

const apps = [
  {
    name: "vueOne", //应用名称
    entry: "//localhost:7100", //子应用必须支持跨域
    container: "#vueOne", //挂载的容器
    activeRule: "/vueOne", //激活路径
  },
  {
    name: "vueTwo",
    entry: "//localhost:7200",
    container: "#vueTwo",
    activeRule: "/vueTwo",
  },
];

registerMicroApps(apps);
start();

new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

# 3.2.2 子应用

1)src/main.js适配

特别注意: 1、导出生命周期钩子 2、配置__webpack_public_path__ 3、子程序独立运行

import Vue from "vue";
import App from "./App.vue";
import router from "./router";

Vue.config.productionTip = false;

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

if (window.__POWERED_BY_QIANKUN__) {
  //动态添加publicPath
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

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

//导出qiankun生命周期钩子
export async function bootstrap(props) {
  console.log("child vue one bootstraped");
}

export async function mount(props) {
  render(props);
}

export async function unmount(props) {
  instance.$destroy();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

2)路由适配

const router = new VueRouter({
  mode: "history",
  base: "/vueOne",
  routes,
});
1
2
3
4
5

3)添加vue.config.js

关键设置: 1、子程序端口 2、支持跨域 3、打包规则

module.exports = {
  devServer: {
    port: 7100,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    output: {
      library: "vueOne",
      libraryTarget: "umd",
    },
  },
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 4 CSS隔离方案

子应用之间样式隔离

  • Dynamic Stylesheet动态样式表,当应用切换时移除老应用样式,添加新应用样式

主应用和子应用之间的样式隔离

  • BEM(Block Element Modifier) 约定项目前缀,简单有效,前提是大家都遵守约定
  • CSS-Modules 打包时生成不冲突的选择器名,比较主流的方式
  • Shadow DOM 真正意义上的隔离(qiankun使用这种
  • css-in-js 不建议使用(古老项目中

img

let shadowDom = shadow.attachShadow({ mode: "open" });
let pElement = document.createElement("p");
pElement.innerHTML = "hello world";
let styleElement = document.createElement("style");
styleElement.textContent = `
    p{color:red}
`;
shadowDom.appendChild(pElement);
shadowDom.appendChild(styleElement);
1
2
3
4
5
6
7
8
9

shadow DOM 可以实现真正的隔离机制

# 5.JS沙箱机制

沙箱应用的两种场景: 1、单应用(图左) 2、多应用(图右)

img

当运行子应用时应该跑在内部沙箱环境中

  • 快照沙箱,在应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)
  • Proxy 代理沙箱,不影响全局环境

# 5.1.1 快照沙箱

  • 1.激活时将当前 window 属性进行快照处理
  • 2.失活时用快照中的内容和当前 window 属性比对
  • 3.如果属性发生变化保存到modifyPropsMap中,并用快照还原 window 属性
  • 4.在次激活时,再次进行快照,并用上次修改的结果还原 window
class SnapshotSandbox {
  constructor() {
    this.proxy = window;
    this.modifyPropsMap = {}; // 修改了那些属性
    this.active();
  }
  active() {
    this.windowSnapshot = {}; // window对象的快照
    for (const prop in window) {
      if (window.hasOwnProperty(prop)) {
        // 将window上的属性进行拍照
        this.windowSnapshot[prop] = window[prop];
      }
    }
    Object.keys(this.modifyPropsMap).forEach((p) => {
      window[p] = this.modifyPropsMap[p];
    });
  }
  inactive() {
    for (const prop in window) {
      // diff 差异
      if (window.hasOwnProperty(prop)) {
        // 将上次拍照的结果和本次window属性做对比
        if (window[prop] !== this.windowSnapshot[prop]) {
          // 保存修改后的结果
          this.modifyPropsMap[prop] = window[prop];
          // 还原window
          window[prop] = this.windowSnapshot[prop];
        }
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let sandbox = new SnapshotSandbox();
((window) => {
  window.a = 1;
  window.b = 2;
  window.c = 3;
  console.log(a, b, c);
  sandbox.inactive();
  console.log(a, b, c);
})(sandbox.proxy);
1
2
3
4
5
6
7
8
9

快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,只能通过 proxy 代理沙箱来实现

# 5.1.2 Proxy 代理沙箱

class ProxySandbox {
  constructor() {
    const rawWindow = window;
    const fakeWindow = {};
    const proxy = new Proxy(fakeWindow, {
      set(target, p, value) {
        target[p] = value;
        return true;
      },
      get(target, p) {
        return target[p] || rawWindow[p];
      },
    });
    this.proxy = proxy;
  }
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
  window.a = "hello";
  console.log(window.a);
})(sandbox1.proxy);
((window) => {
  window.a = "world";
  console.log(window.a);
})(sandbox2.proxy);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

每个应用都创建一个 proxy 来代理 window,好处是每个应用都是相对独立,不需要直接更改全局 window 属性!

5.1.3 Proxy 代理

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。

let student = {
  name: "张三",
  age: 28,
};

let studentProxy = new Proxy(student, {
  set(target, property, value) {
    target[property] = value;
    return true;
  },
  get(target, property) {
    if (property in target) {
      return target[property];
    } else {
      console.warn(`${property}属性不在当前对象上`);
      return "/(ㄒoㄒ)/~~";
    }
  },
});

console.log(studentProxy.name, studentProxy.age, studentProxy.sex);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21