微前端入门 - single-spa

452 阅读1分钟

在微前端架构中,微应用被打包为模块,然后在浏览器中加载模块代码。但浏览器对模块化支持不够完善,所有single-spa中使用SystemJS处理浏览器中的模块化

SystemJs

浏览器对原生esm的支持

浏览本地支持esm

<!DOCTYPE html>
<html lang="en">
<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>Single-spa</title>
</head>
<body>
    <div id="app">{{ msg }}</div>
</body>
<script type="importmap">
    {
        "imports": {
            "vue": "https://cdn.jsdelivr.net/npm/vue@3.2.26/dist/vue.esm-browser.js"
        }
    }
</script>
<script type="module">
import { createApp } from 'vue'
const Hello = {
    data() {
        return {
            msg: 'Hello World'
        }
    }
}
createApp(Hello).mount('#app')
</script>
</html>

Systemjs在浏览器中使用

浏览器本身支持esm的写法,但是并不是所有浏览器都支持esm。因此,可以引入systemjs

<script type="systemjs-importmap">
    {
        "imports": {
+            "vue": "https://cdn.jsdelivr.net/npm/vue@3.2.26/dist/vue.global.min.js"
        }
    }
</script>
+<script type="systemjs-module" src="./js/main.js"></script>
+<script src="https://cdn.jsdelivr.net/npm/systemjs@6.11.0/dist/system.min.js"></script>
</html>
System.register(['vue'], () => {
    let Vue
    return {
        setters: [ret => {
            Vue = ret.Vue
        }],
        execute() {
            const Hello = {
                data() {
                    return {
                        msg: 'Hello World'
                    }
                }
            }
            Vue.createApp(Hello).mount('#app')
        }
    }
})

使用webpack构建system模块

webpack配置文件

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

/** @type {import('webpack').Configuration} */
module.exports = {
    mode: 'development',
    entry: './src/main.jsx',
    output: {
        filename: '[name].js',
        path: path.join(__dirname, 'dist'),
        // 告诉webpack输出兼容systemjs兼容代码
        libraryTarget: 'system',
    },
    externals: ['react', 'react-dom'],
    resolve: {
        extensions: ['.js', '.json', '.jsx'],
      },
    devtool:'source-map',
    devServer: {
        port: 8080,
        static: {
            directory:  path.join(__dirname, 'dist'),
        },
        historyApiFallback: true
    },
    module: {
        rules: [{
            test: /\.jsx?$/,
            exclude: /node_modules/,
            use: {
                loader: 'babel-loader',
                options: {
                    presets: [
                        '@babel/preset-react'
                    ]
                }
            }
        }]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './public/index.html',
            // 告诉HtmlWebpackPlugin不需要把构建结果自动注入html中
            inject: false
        })
    ]
}
<!DOCTYPE html>
<html lang="en">
<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>systemjs</title>
    <script type="systemjs-importmap">
        {
            "imports": {
                "react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.development.js",
                "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.development.js"
            }
        }
    </script>
    <script src="https://cdn.jsdelivr.net/npm/systemjs@6.11.0/dist/system.min.js"></script>
    <script type="systemjs-module" src="./main.js"></script>
</head>
<body>
    <div id="root"></div>
</body>
</html>

安装

全局安装

npm i create-single-spa -g
# 创建主应用
create-single-spa

不想全局安装,使用下面命令

npm init create-single-spa

创建主应用

? Directory for new project                                            main
# 选择 root config 表示主应用
? Select type to generate single-spa                                   root config
? Which package manager do you want to use?                            npm
? Will this project use Typescript?                                    No
? Would you like to use single-spa Layout Engine                       No
? Organization name (can use letters, numbers, dash or underscore)     learn

主应用

// learn-root-config.js
import {
  registerApplication,
  start
} from "single-spa";

// 注册微应用
registerApplication({
  name: "@single-spa/welcome",
  app: () =>
    System.import(
      "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
    ),
  // 激活微应用的路径
  activeWhen: ["/"],
});

registerApplication({
  name: "@learn/navbar",
  app: () => System.import("@learn/navbar"),
  activeWhen: ["/"]
});

start({
  urlRerouteOnly: true,
});

微应用

没有框架的微应用

创建一个新目录,用来存放微应用

npm init -y
npm i webpack webpack-cli webpack-dev-server webpack-merge webpack-config-single-spa @babel/core single-spa -D

新建webpack.config.js文件

// webpack.config.js
const {
    merge
} = require('webpack-merge')
const singleSpaConfig = require('webpack-config-single-spa')
module.exports = () => {
    // 默认入口js文件名称为[orgName]-[orgName]
    const config = singleSpaConfig({
        orgName: 'learn',
        projectName: 'html'
    })
    return merge(config, {
        devServer: {
            port: 9001
        }
    })
}

微应用的入口文件

//  启用
export async function bootstrap() {
    console.log('learn bootstrap')
}
//  渲染
export async function mount() {
    console.log('learn mount')
    render()
}
//  注销
export async function unmount() {
    console.log('learn unmount')
}


function render() {
    const el = document.createElement('div')
    el.innerHTML = '<h1>无框架微应用</h1>'
    document.body.appendChild(el)
}

注意,微应用的入口文件必须导出bootstrap、mount、unmount三个函数,且返回值都必须是Promise

主应用中注册

registerApplication({
  name: "@learn/html",
  app: () => System.import("@learn/html"),
  activeWhen: ["/html"]
});

React微应用

npm init single-spa
? Select type to generate single-spa   application / parcel
# 之后选择React应用

Vue微应用

npm init single-spa
...
? Select type to generate single-spa   application / parcel
# 之后选择Vue应用

Parcel微应用

使用single-spa创建一个微应用

  <script type="systemjs-importmap">
    {
      "imports": {
        "@learn/root-config": "//localhost:9000/learn-root-config.js",
        "@learn/html":"//localhost:9001/learn-html.js",
        "@learn/react":"//localhost:9002/learn-react.js",
        "@learn/vue":"//localhost:9003/js/app.js",
        "@learn/vue3":"//localhost:9004/js/app.js",
+        "@learn/parcel-vue":"//localhost:9005/js/app.js",
        "react":"https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.development.js",
        "react-dom":"https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.development.js",
        "react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@6.2.1/main.min.js",
        "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js",
        "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.5.3/dist/vue-router.min.js"
      }
    }
  </script>

注册parcel应用

registerApplication({
  name: "@learn/parcel-vue",
  app: () => System.import("@learn/parcel-vue"),
  activeWhen: ["/parcel-vue"]
})

在vue应用中使用parcel应用

<script>
import Parcel from 'single-spa-vue/dist/esm/parcel'
import { mountRootParcel } from 'single-spa'
export default {
  name:'About',
  components:{
    Parcel
  },
  data(){
    return {
      parcelConfig: window.System.import('@learn/parcel-vue'),
      mountParcel : mountRootParcel
    }
  }
}
</script>
<template>
  <div class="about">
    <h1>This is an about page</h1>
    <Parcel :config="parcelConfig" :mountParcel ="mountParcel"/>
  </div>
</template>

注意,不要让webpack打包single-spa

在React应用中使用Parcel应用

import Parcel from 'single-spa-react/parcel'
const About = () => {
    return (
        <div>
            <Parcel config={System.import('@learn/parcel-vue')}/> 
        </div>
    )
}

跨应用共享的工具模块

使用single-spa创建应用时选择工具模块

npm init single-spa

? Directory for new project utility-test
# 选择工具模块
? Select type to generate in-browser utility module (styleguide, api cache, etc)
? Which framework do you want to use? none
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) learn
? Project name (can use letters, numbers, dash or underscore) utility-test

在模块应用导出一个方法

export function add(a, b) {
    return a + b
}

在主应用中注册

其他应用调用

const UtilityTest = () => import('@learn/utility-test')

// 注意导出模块采用import导入语应用时异步的,所以需要等模块加载完毕后再调用
UtilityTest().then(ret => {
    console.log(ret.add(2,3))
})

应用通信

每个应用应尽可能的自己管理自己的状态

使用rxks

import {Subject} from 'rxjs'
export const subject = new Subject()
subject.subscribe({
  next: (v) => console.log(`observerA: ${v}`)
})

registerApplication({
  name: "@learn/react",
  app: () => System.import("@learn/react"),
  activeWhen: ["/react"],
  // 将subject传递给微应用
  customProps: {
    subject
  }
});

在微应用中使用

let subject1 = null
// 微应用通过props获取subject
export async function mount(props) {
  if (!subject1 && props.subject) {
    subject1 = props.subject.subscribe({
      next: (v) => console.log(`React observer: ${v}`)
    })
    setTimeout(() => {
      props.subject.next(100)
    }, 5000)
  }

  lifecycles.mount(props)
}
export async function unmount(props) {
  if (subject1) {
    console.log('取消订阅')
    subject1.unsubscribe()
  }
  lifecycles.mount(props)
}
export const {
  bootstrap
} = lifecycles;

封装一个类似qiankun的状态管理

import {
    Subject
} from 'rxjs'
export const subject = new Subject()

class GlobalState {
    constructor(state) {
        this._state = state
        this._subject = new Subject()
        this.subs = []
    }
    onGlobalStateChange = (cb, fireimmediately) => {
        if (fireimmediately) cb(this._state)
        const sub = this._subject.subscribe({
            next: v => {
                cb(v, this._state)
            }
        })
        this.subs.push(sub)
    }
    setGlobalState = (state) => {
        this._subject.next(state)
        this._state = state
    }
    offGlobalState = () => {
        this.subs.forEach(sub => {
            sub.unsubscribe()
        })
    }
}
class ChildState {
    constructor(global) {
        this._global = global
        this.subs = []
    }
    onGlobalStateChange = (cb, fireimmediately) => {
        const global =  this._global
        if (fireimmediately) cb( global._state)
        const sub = global._subject.subscribe({
            next: v => {
                cb(v, global._state)
            }
        })
        this.subs.push(sub)
    }
    setGlobalState = (state) => {
        this._global._subject.next(state)
        this._global._state = state
    }
    offGlobalState = () => {
        this.subs.forEach(sub => {
            sub.unsubscribe()
        })
    }
}
export function initGlobalState(state) {
    return new GlobalState(state)
}
export function initChildState(global){
    return new ChildState(global)
}

封装ChildState的目的是为了防止子应用卸载后会清除调主应用的状态监听

const global = initGlobalState({
  count: 1
})
const childState = new initChildState(global)
registerApplication({
  name: "@learn/react",
  app: () => System.import("@learn/react"),
  activeWhen: ["/react"],
  customProps: {
    onGlobalStateChange: childState.onGlobalStateChange.bind(global),
    setGlobalState: childState.setGlobalState.bind(global),
    offGlobalState: childState.offGlobalState.bind(global)
  }
});