微前端实战总结篇

发布于 2024-04-28 15:59:19 字数 18629 浏览 40 评论 0

微前端现有的落地方案可以分为三类,自组织模式、基座模式以及模块加载模式。

一、为什么需要微前端?

这里我们通过 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.快照沙箱

  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]; 
        }
      }
    }
  }
}
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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

送你一个梦

暂无简介

文章
评论
27 人气
更多

推荐作者

微信用户

文章 0 评论 0

零度℉

文章 0 评论 0

百度③文鱼

文章 0 评论 0

qq_O3Ao6frw

文章 0 评论 0

Wugswg

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文