微前端实战总结篇

发布于 2024-04-28 15:59:19 字数 18629 浏览 25 评论 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技术交流群

发布评论

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

关于作者

送你一个梦

暂无简介

0 文章
0 评论
23 人气
更多

推荐作者

内心激荡

文章 0 评论 0

JSmiles

文章 0 评论 0

左秋

文章 0 评论 0

迪街小绵羊

文章 0 评论 0

瞳孔里扚悲伤

文章 0 评论 0

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