微前端实战总结篇
微前端现有的落地方案可以分为三类,自组织模式、基座模式以及模块加载模式。
一、为什么需要微前端?
这里我们通过 3W(what、why、how)的方式来讲解什么是微前端:
1.What?什么是微前端?
微前端就是将不同的功能按照不同的维度拆分成多个子应用。通过主应用来加载这些子应用。微前端的核心在于拆,拆完后再合!
2.Why?为什么去使用他?
- 不同团队间开发同一个应用技术栈不同怎么破?
- 希望每个团队都可以独立开发,独立部署怎么破?
- 项目中还需要老的应用代码怎么破?
我们是不是可以将一个应用划分成若干个子应用,再将子应用打包成一个个的 lib 呢?当路径切换时加载不同的子应用,这样每个子应用都是独立的,技术栈也就不用再做限制了!从而解决了前端协同开发的问题。
3.How?怎样落地微前端?
- 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 中的子应用切换路由时用户刷新页面就尴尬了。
应用间如何通信?
- 基于 URL 来进行数据传递,但是这种传递消息的方式能力较弱
- 基于 CustomEvent 实现通信
- 基于 props 主子应用间通信
- 使用全局变量、Redux 进行通信
如何处理公共依赖?
- CDN -
externals
- webpack
联邦模块
二、SingleSpa 实战
官网 https://zh-hans.single-spa.js.org/docs/configuration
1.构建子应用
首先创建一个 vue 子应用,并通过 single-spa-vue
来导出必要的生命周期:
vue create spa-vue npm install single-spa-vue
// main.js import singleSpaVue from 'single-spa-vue'; const appOptions = { el: '#vue', router, render: h => h(App) } // 在非子应用中正常挂载应用 if(!window.singleSpaNavigate){ delete appOptions.el; new Vue(appOptions).$mount('#app'); } 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.js const router = new VueRouter({ mode: 'history', base: '/vue', //改变路径配置 routes })
2.配置库打包
将子模块打包成类库
//vue.config.js module.exports = { configureWebpack: { // 把属性挂载到 window 上方便父应用调用 window.singleVue.bootstrap/mount/unmount output: { library: 'singleVue', libraryTarget: 'umd' }, devServer:{ port:10000 } } }
3.主应用搭建
<div id="nav"> <router-link to="/vue">vue 项目 router-link> <div id="vue">div> div>
将子应用挂载到 id="vue"
标签中
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 loadScript(url) { return new Promise((resolve,reject)=>{ let script = document.createElement('script') script.src = url script.onload = resolve script.onerror = reject document.head.appendChild(script) }) } // 注册应用 registerApplication('myVueApp', async ()=>{ console.info('load') // singlespa 问题 // 加载文件需要自己构建 script 标签 但是不知道应用有多少个文件 // 样式不隔离 // 全局对象没有 js 沙箱的机制 比如加载不同的应用 每个应用都用同一个环境 // 先加载公共的 await loadScript('http://localhost:10000/js/chunk-vendors.js') await loadScript('http://localhost:10000/js/app.js') return window.singleVue // bootstrap mount unmount }, // 用户切换到/vue 下 我们需要加载刚才定义的子应用 location=>location.pathname.startsWith('/vue'), ) start() new Vue({ router, render: h => h(App) }).$mount('#app')
4.动态设置子应用 publicPath
if(window.singleSpaNavigate){ __webpack_public_path__ = 'http://localhost:10000/' }
三、qiankun 实战
qiankun
是目前比较完善的一个微前端解决方案,它已在蚂蚁内部经受过足够大量的项目考验及打磨,十分健壮。这里附上官网。https://qiankun.umijs.org/zh/guide
1.主应用编写
<template> <!--注意这里不要写 app 否则跟子应用的加载冲突 <div id="app">--> <div> <el-menu :router="true" mode="horizontal"> <!-- 基座中可以放自己的路由 --> <el-menu-item index="/">Home</el-menu-item> <!-- 引用其他子应用 --> <el-menu-item index="/vue">vue 应用</el-menu-item> <el-menu-item index="/react">react 应用</el-menu-item> </el-menu> <router-view /> <!-- 其他子应用的挂载节点 --> <div id="vue" /> <div id="react" /> </div> </template> <style> </style>
注册子应用
import { registerMicroApps,start } from 'qiankun' // 基座写法 const apps = [ { name: 'vueApp', // 名字 // 默认会加载这个 HTML,解析里面的 js 动态执行 (子应用必须支持跨域) entry: '//localhost:10000', container: '#vue', // 容器 activeRule: '/vue', // 激活的路径 访问/vue 把应用挂载到#vue 上 props: { // 传递属性给子应用接收 a: 1, } }, { name: 'reactApp', // 默认会加载这个 HTML,解析里面的 js 动态执行 (子应用必须支持跨域) entry: '//localhost:20000', container: '#react', activeRule: '/react' // 访问/react 把应用挂载到#react 上 }, ] // 注册 registerMicroApps(apps) // 开启 start({ prefetch: false // 取消预加载 })
2.子 Vue 应用
// src/router.js const router = new VueRouter({ mode: 'history', // base 里主应用里面注册的保持一致 base: '/vue', routes })
// main.js import Vue from 'vue' import App from './App.vue' import router from './router' Vue.config.productionTip = false let instance = null function render() { instance = new Vue({ router, render: h => h(App) }).$mount('#app') // 这里是挂载到自己的 HTML 中 基座会拿到挂载后的 HTML 将其插入进去 } // 独立运行微应用 // https://qiankun.umijs.org/zh/faq#%E5%A6%82%E4%BD%95%E7%8B%AC%E7%AB%8B%E8%BF%90%E8%A1%8C%E5%BE%AE%E5%BA%94%E7%94%A8%EF%BC%9F if(!window.__POWERED_BY_QIANKUN__) { render() } // 如果被 qiankun 使用 会动态注入路径 if(window.__POWERED_BY_QIANKUN__) { // qiankun 将会在微应用 bootstrap 之前注入一个运行时的 publicPath 变量,你需要做的是在微应用的 entry js 的顶部添加如下代码: __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } // 子应用的协议 导出供父应用调用 必须导出 promise export async function bootstrap(props) {} // 启动可以不用写 需要导出方法 export async function mount(props) { render() } export async function unmount(props) { instance.$destroy() }
这里不要忘记子应用的钩子导出。
// vue.config.js module.exports = { devServer:{ port:10000, headers:{ 'Access-Control-Allow-Origin':'*' //允许访问跨域 } }, configureWebpack:{ // 打 umd 包 output:{ library:'vueApp', libraryTarget:'umd' } } }
3.子 React 应用
再起一个子应用,为了表明技术栈无关特性,这里使用了一个 React
项目:
// app.js import logo from './logo.svg'; import './App.css'; import {BrowserRouter,Route,Link} from 'react-router-dom' function App() { return ( // /react 跟主应用配置保持一致 <BrowserRouter basename="/react"> <Link to="/">首页</Link> <Link to="/about">关于</Link> <Route path="/" exact render={()=>( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <p> Edit <code>src/App.js</code> and save to reload. </p> <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React </a> </header> </div> )} /> <Route path="/about" exact render={()=>( <h1>About Page</h1> )}></Route> </BrowserRouter> ); } export default App;
// index.js import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; function render() { ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root') ); } // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals(); // 独立运行 if(!window.__POWERED_BY_QIANKUN__){ render() } // 子应用协议 export async function bootstrap() {} export async function mount() { render() } export async function unmount() { ReactDOM.unmountComponentAtNode(document.getElementById("root")); }
重写 react 中的 webpack 配置文件 (config-overrides.js)
yarn add react-app-rewired --save-dev
修改 package.json 文件
// react-scripts 改成 react-app-rewired "scripts": { "start": "react-app-rewired start", "build": "react-app-rewired build", "test": "react-app-rewired test", "eject": "react-app-rewired eject" },
在根目录新建配置文件
// 配置文件重写 touch config-overrides.js
// config-overrides.js module.exports = { webpack: (config) => { // 名字和基座配置的一样 config.output.library = 'reactApp'; config.output.libraryTarget = "umd"; config.output.publicPath = 'http://localhost:20000/' return config }, devServer: function (configFunction) { return function (proxy, allowedHost) { const config = configFunction(proxy, allowedHost); // 配置跨域 config.headers = { "Access-Control-Allow-Origin": "*", }; return config; }; }, };
配置.env 文件
根目录新建 .env
PORT=20000 # socket 发送端口 WDS_SOCKET_PORT=20000
React 路由配置
import { BrowserRouter, Route, Link } from "react-router-dom" const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : ""; function App() { return ( <BrowserRouter basename={BASE_NAME}><Link to="/">首页 Link><Link to="/about">关于 Link><Route path="/" exact render={() => <h1>hello homeh1>}>Route><Route path="/about" render={() => <h1>hello abouth1>}>Route>BrowserRouter> ); }
四、飞冰微前端实战
官方接入指南 https://micro-frontends.ice.work/docs/guide
4.1 react 主应用编写
$ npm init ice icestark-layout @icedesign/stark-layout-scaffold $ cd icestark-layout $ npm install $ npm start
// src/app.jsx 中加入 const appConfig: IAppConfig = { ... icestark: { type: 'framework', Layout: FrameworkLayout, getApps: async () => { const apps = [ { path: '/vue', title: 'vue 微应用测试', sandbox: false, url: [ // 测试环境 // 请求子应用端口下的服务,子应用的 vue.config.js 里面 需要配置 headers 跨域请求头 "http://localhost:3001/js/chunk-vendors.js", "http://localhost:3001/js/app.js", ], }, { path: '/react', title: 'react 微应用测试', sandbox: true, url: [ // 测试环境 // 请求子应用端口下的服务,子应用的 webpackDevServer.config.js 里面 需要配置 headers 跨域请求头 "http://localhost:3000/static/js/bundle.js", ], } ]; return apps; }, appRouter: { LoadingComponent: PageLoading, }, }, };
// 侧边栏菜单 // src/layouts/menuConfig.ts 改造 const asideMenuConfig = [ { name: 'vue 微应用测试', icon: 'set', path: '/vue' }, { name: 'React 微应用测试', icon: 'set', path: '/react' }, ]
4.2 vue 子应用接入
# 创建一个子应用 vue create vue-child
// 修改 vue.config.js module.exports = { devServer: { open: true, // 设置浏览器自动打开项目 port: 3001, // 设置端口 // 支持跨域 方便主应用请求子应用资源 headers: { 'Access-Control-Allow-Origin' : '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', } }, configureWebpack: { // 打包成 lib 包 umd 格式 output: { library: 'icestark-vue', libraryTarget: 'umd', }, } }
src/main.js
改造
import { createApp } from 'vue' import App from './App.vue' import router from './router' import store from './store' import { isInIcestark, getMountNode, registerAppEnter, registerAppLeave, setLibraryName } from '@ice/stark-app' let vue = createApp(App) vue.use(store) vue.use(router) // 注意:`setLibraryName` 的入参需要与 webpack 工程配置的 output.library 保持一致 // 重要 不加不生效 和 vue.config.js 中配置的一样 setLibraryName('icestark-vue') export function mount({ container }) { // ![](https://blog.poetries.top/img/static/images/20210731130030.png) console.log(container,'container') vue.mount(container); } export function unmount() { vue.unmount(); } if (!isInIcestark()) { vue.mount('#app') }
router 改造
import { getBasename } from '@ice/stark-app'; const router = createRouter({ // 重要 在主应用中的基准路由 base: getBasename(), routes }) export default router
4.3 react 子应用接入
create-react-app react-child
// src/app.js import { isInIcestark, getMountNode, registerAppEnter, registerAppLeave } from '@ice/stark-app'; export function mount(props) { ReactDOM.render(<App />, props.container); } export function unmount(props) { ReactDOM.unmountComponentAtNode(props.container); } if (!isInIcestark()) { ReactDOM.render(<App />, document.getElementById('root')); } if (isInIcestark()) { registerAppEnter(() => { ReactDOM.render(<App />, getMountNode()); }) registerAppLeave(() => { ReactDOM.unmountComponentAtNode(getMountNode()); }) } else { ReactDOM.render(<App />, document.getElementById('root')); }
npm run eject
后,改造 config/webpackDevServer.config.js
hot: '', port: '', ... // 支持跨域 headers: { 'Access-Control-Allow-Origin' : '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', },
五、CSS 隔离方案
子应用之间样式隔离:
Dynamic Stylesheet
动态样式表,当应用切换时移除掉老应用样式,再添加新应用样式,保证在一个时间点内只有一个应用的样式表生效
主应用和子应用之间的样式隔离:
BEM(Block Element Modifier)
约定项目前缀CSS-Modules
打包时生成不冲突的选择器名- Shadow DOM 真正意义上的隔离
css-in-js
<!DOCTYPE html> <html lang=""> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <title>shadow dom</title> </head> <body> <p>hello world</p> <div id="shadow"></div> <script> let shadowDOM = document.getElementById('shadow').attachShadow({mode: 'closed'}) // 外界无法访问 let pEle = document.createElement('p') pEle.innerHTML = 'hello shadowDOM' let styleEle = document.createElement('style') styleEle.textContent = `p{color:red} ` // ![](https://blog.poetries.top/img/static/images/20210731135230.png) shadowDOM.appendChild(styleEle) shadowDOM.appendChild(pEle) // react vue 里面的弹框等因为挂载到 body 上 所以用 shadowDOM 不行 // 会挂载到全局污染样式 //document.body.appendChild(pEle) </script> </body> </html>
shadow DOM
内部的元素始终不会影响到它的外部元素,可以实现真正意义上的隔离
六、JS 沙箱机制
当运行子应用时应该跑在内部沙箱环境中
- 快照沙箱,当应用沙箱挂载或卸载时记录快照,在切换时依据快照恢复环境 (无法支持多实例)
Proxy
代理沙箱,不影响全局环境
1.快照沙箱
- 激活时将当前 window 属性进行快照处理
- 失活时用快照中的内容和当前 window 属性比对
- 如果属性发生变化保存到
modifyPropsMap
中,并用快照还原 window 属性 - 再次激活时,再次进行快照,并用上次修改的结果还原 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 代理沙箱来实现
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);
每个应用都创建一个 proxy
来代理 window
对象,好处是每个应用都是相对独立的,不需要直接更改全局的 window
属性。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
上一篇: 前端监控系统总结篇
下一篇: 彻底找到 Tomcat 启动速度慢的元凶
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论