刘小标 时间:2020-11-13
微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。
微前端的核心在于拆,拆完再合。
面临问题:
思考:我们是不是可以将一个应用划分成若干个子应用,将子应用打包成一个个的 lib。当路径切换时加载不同的子应用。这样每个子应用都是独立的,技术栈也不用做限制了!从而解决了前端协同开发问题!
2018 年 Single-SPA 诞生了, single-spa
是一个用于前端微服务化的JavaScript
前端解决方案 (本身没有处理样式隔离,js
执行隔离) 实现了路由劫持和应用加载
2019 年 qiankun
基于 Single-SPA, 提供了更加开箱即用的 API
(single-spa
+ sandbox
+ import-html-entry
) 做到了,技术栈无关、并且接入简单(像 iframe
一样简单)
总结:子应用可以独立构建,运行时动态加载,主子应用完全解耦,技术栈无关,靠的是协议接入(子应用必须导出 bootstrap、mount、unmount 方法)
这里先回答大家肯定会问的问题:
这不是iframe
吗?
iframe
,iframe
中的子应用切换路由时用户刷新页面就尴尬了。应用通信:
CustomEvent
实现通信Redux
进行通信公共依赖:
CDN
- externalswebpack
联邦模块vue-cli 创建子应用,安装single-spa-vue
vue create single-spa-child-one
yarn add single-spa-vue 或者 npm install --save single-spa-vue
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;
router/index.js
路由路径自定义(接入主应用的路径规则):
配置子路由基础路径
const router = new VueRouter({
mode: "history",
base: "/child",
routes,
});
添加配置文件vue.config.js
:
子应用打包规则,打包为 lib 库 library:应用名称,唯一值 libraryTarget:打包库类型,“umd”
const package = require("./package.json");
module.exports = {
configureWebpack: {
output: {
library: package.name,
libraryTarget: "umd",
},
devServer: {
port: 6601,
},
},
};
src/App.vue
添加子应用挂载的节点:
特别注意:此处路由路径和子应用基础路径一致哦,以及挂载节点 ID 和子应用保持一致
<template>
<div id="app">
<div id="nav">
<router-link to="/child">加载子应用</router-link>
<div id="child"></div>
</div>
</div>
</template>
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");
single-spa : 提供了路由劫持和应用加载,可应对简单接入子程序的需求
缺点: 1、不够灵活,不能动态加载子应用 JS 文件 2、样式不隔离,没有 js 沙箱机制
1)主应用编写
主应用安装 qiankun:
$ yarn add qiankun # 或者 npm i qiankun -S
src/App.vue
添加挂载子应用的节点vueOne
,vueTwo
:
<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>
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)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();
}
2)路由适配
const router = new VueRouter({
mode: "history",
base: "/vueOne",
routes,
});
3)添加vue.config.js
关键设置: 1、子程序端口 2、支持跨域 3、打包规则
module.exports = {
devServer: {
port: 7100,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
configureWebpack: {
output: {
library: "vueOne",
libraryTarget: "umd",
},
},
};
CSS
隔离方案子应用之间样式隔离:
Dynamic Stylesheet
动态样式表,当应用切换时移除老应用样式,添加新应用样式主应用和子应用之间的样式隔离:
BEM
(Block Element Modifier) 约定项目前缀,简单有效,前提是大家都遵守约定
CSS-Modules
打包时生成不冲突的选择器名,比较主流的方式
Shadow DOM
真正意义上的隔离(qiankun使用这种
)css-in-js
不建议使用(古老项目中
)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);
shadow DOM 可以实现真正的隔离机制
JS
沙箱机制沙箱应用的两种场景: 1、单应用(图左) 2、多应用(图右)
当运行子应用时应该跑在内部沙箱环境中
modifyPropsMap
中,并用快照还原 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];
}
}
}
}
}
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);
快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决,只能通过 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);
每个应用都创建一个 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);