# 【NOTE】single-spa > [name=呂芯萍 Aka] [time=2025 05 27] > Mail: NT96153@cathaybk.com.tw :::spoiler 學習歷程 * 2023/11/22 > create-single-spa root-config * 2023/11/27 > create-single-spa application * 2024/03/07 > props * 2024/05/12 > single-spa-angular * 2024/07/05 > single-spa-react * 2024/07/18 > import config from deploy * 2025/02/11 > Module Federation in single-spa * 2025/05/27 > Angular 18 init error ::: :::spoiler 參考資料 * 主要 > [single-spa 官網文件](https://single-spa.js.org/docs/) * 次要 > [single-spa: props](https://single-spa.js.org/docs/layout-definition/#props) > [lifecycle function bootstrap did not return a promise](https://single-spa.js.org/error/?code=15&arg=application&arg=&arg=bootstrap&arg=1) > [Single SPA with React](https://mrbytebuster.medium.com/single-spa-with-react-85491b437759) > [single-spa-login-example-with-npm-packages | Github](https://github.com/jualoppaz/single-spa-login-example-with-npm-packages) > [single-spa import Module Federation | single-spa](https://single-spa.js.org/docs/recommended-setup/#module-federation) ::: ## 成為 Single-spa 要注意的地方 * Add single-spa * Change api Interceptor (call apiUrl from config.json) * Auth call share function * Add route parent (base url) * Unsubscribe all oberservable on ngOnDestroy * Image url call deployUrl from config.json ## 環境建置 ### 下載 single-spa CLI * 執行下列任一項即可 * 全域下載 ```npm install --global create-single-spa``` * 該目錄下下載(執行下列任一項即可) ```npm init single-spa``` ```npx create-single-spa``` :::danger * 使用 ```npm init single-spa``` 且 single-spa 6.0.3 建立時,會無法將 Application 連結進 root-config,建議使用 ```npx create-single-spa``` * 2025.05.29 測試 ::: ### 建立 root config * 執行 ```create-single-spa --moduleType root-config``` ![image](https://hackmd.io/_uploads/rJAtemoE6.png) * 替專案目錄命名 * 選擇使用的套件管理器(建議使用 npm) * 選擇是否使用 Typescript(建議選用) * 選擇是否使用 single-spa的 Layout 框架(建議選用) * 替專案命名(會自動加上尾綴「root-config」) ![image](https://hackmd.io/_uploads/rkbJZmjVp.png) * 執行 ```npm run start```,至瀏覽器輸入預設網址「[http://localhost:9000/](http://localhost:9000/)」可以看是否有初始成功 ![image](https://hackmd.io/_uploads/rkWDQmoVa.png) ### 建立 Application(Angular) * 執行 create-single-spa * 方法一:執行 ```create-single-spa --moduleType app-parcel``` ![image](https://hackmd.io/_uploads/ByqSO2bHp.png) ![image](https://hackmd.io/_uploads/H1j8Mn-ST.png) * 替專案目錄命名(建議為空,目錄才不會變兩層) * 選擇使用的前端框架 * 替專案命名 * 其他該框架的詢問(Angular 會詢問是否使用 Routing、使用的 Style、是否下載相關套件) * 專案 Port(預設 4200) ![image](https://hackmd.io/_uploads/H1TUtnbBT.png) * 方法二:執行 ```create-single-spa --framework angular``` :::info * 如出現「Package "single-spa-angular" was found but does not support schematics.」為版本不符 * 如為 angular 14 ```ng add single-spa-angular@7``` ```npm i``` * [single-spa-angular 對應版本](https://single-spa.js.org/docs/ecosystem-angular/) ::: * 更改執行指令 ```json // package.json { // ... "scripts": { // ... "start": "npm run serve:single-spa:single-spa-demo-main", // ... }, } ``` * 執行 ```npm run start```,至瀏覽器輸入預設網址「[http://localhost:4200/](http://localhost:4200/)」可以看是否有初始成功 ![image](https://hackmd.io/_uploads/HkPtA3WBa.png) :::info * 如建立 Angular14+ 版本,需手動調整成 Standalone 方式 * 確定 AppComponent 為 Standalone ```typescript @Component({ standalone: true, // ... }) export class AppComponent {} ``` * 修改 main.single-spa.ts,使用 bootstrapApplication() 而非 platformBrowserDynamic().bootstrapModule() ```typescript import { AppComponent } from './app/app.component'; import { bootstrapApplication } from '@angular/platform-browser'; import { routes } from './app/app.routes'; const lifecycles = singleSpaAngular({ bootstrapFunction: () => bootstrapApplication(AppComponent, { providers: [provideRouter(routes)], // if has routes }), template: '<app-root />', NgZone, }); export const bootstrap = lifecycles.bootstrap; export const mount = lifecycles.mount; export const unmount = lifecycles.unmount; ``` * 移除 angular.json 中的 browser ```json "architect": { "build": { "builder": "@angular-devkit/build-angular:browser", "options": { //"browser": "src/main.single-spa.ts", // remove "main": "src/main.single-spa.ts", // ... }, ``` ::: :::info * 如出現以下錯誤,執行 ```npm i``` 下載 @angular-builders 等套件 ![image](https://hackmd.io/_uploads/Hy0-A2brT.png) * 如出現以下錯誤,自行添加 environments/environment.ts ![image](https://hackmd.io/_uploads/rygzTnZra.png) ![image](https://hackmd.io/_uploads/By9hTnWS6.png) * 如出現以下錯誤,於檔案「tsconfig.json」添加「"skipLibCheck": true」 ![image](https://hackmd.io/_uploads/r1uvaK1T1g.png) ![image](https://hackmd.io/_uploads/BkP2aKypyx.png) * 如出現以下錯誤,為 angular.json 有多餘項目,移除即可(此範例為多了 browser 項目) * Error: Schema validation failed with the following errors: Data path "" must NOT have additional properties(browser). ::: * 連結進 root config * 加入 importmap ```html <!-- single-spa-demo-root-config/src/index.ejs --> <script type="systemjs-importmap"> { "imports": { ... "@single-spa-demo/main": "//localhost:4200/main.js" } } </script> ``` * 加入 zone ```html <!-- single-spa-demo-root-config/src/index.ejs --> <script src="https://cdn.jsdelivr.net/npm/zone.js@0.11.3/dist/zone.min.js"></script> ``` * 加入 router ```html <!-- single-spa-demo-root-config/src/microfrontend-layout.html --> <single-spa-router> <main> <route default> <application name="@single-spa-demo/main"></application> </route> </main> </single-spa-router> ``` :::info * 如有 APP_BASE_HREF error,加上 APP_BASE_HREF ```javascript // single-spa-demo-main\src\app\app-routing.module.ts @NgModule({ //... providers: [{ provide: APP_BASE_HREF, useValue: '/' }], }) export class AppRoutingModule { } ``` ::: ### 建立 Application(React) * 匯入套件 single-spa、single-spa-react、react-router-dom ```npm i -D single-spa single-spa-react react-router-dom``` * 重構進入點 * 調整 src/index.tsx ```typescript import React from "react"; import App from "./App"; import { Provider } from "react-redux"; import { createStore } from "redux"; import rootReducer from "./reducer/root.reducer"; import { BrowserRouter } from "react-router-dom"; import "./index.css"; import "./styles.scss"; const store = createStore(rootReducer); export default function Root() { return ( <BrowserRouter basename="/app-react"> <Provider store={store}> <App /> </Provider> </BrowserRouter> ); } ``` * 建立檔案 src/singleSpaEntry.tsx ```javascript import React from 'react'; import ReactDOM from 'react-dom'; import singleSpaReact from 'single-spa-react'; import Root from './index'; const reactLifecycles = singleSpaReact({ React, ReactDOM, rootComponent: Root, }); export const { bootstrap, mount, unmount } = reactLifecycles; ``` * 調整執行 script * 匯入套件 craco ```npm i --save-dev @craco/craco``` * 建立檔案 src/craco.config.js ```javascript const path = require("path"); module.exports = { webpack: { configure: (config, { paths }) => { config.output.libraryTarget = 'umd'; config.output.filename = 'static/js/[name].js'; config.output.library = 'single-spa-demo-app-react'; config.output.publicPath = '/'; config.entry= ['./src/singleSpaEntry.tsx']; paths.appBuild = config.output.path = path.resolve("dist", "single-spa-demo-app-react"); // 統一輸出成 dist // 打包 style const oneOfRule = webpackConfig.module.rules.find(rule => Array.isArray(rule.oneOf)).oneOf; oneOfRule.forEach(rule => { if (rule.test && rule.test.toString().includes('scss')) { rule.use = [ 'style-loader', { loader: 'css-loader', options: { importLoaders: 1, } }, 'sass-loader' ] } }) return config } } } ``` * 更改 script ```json // package.json { "scripts": { "start": "cross-env PORT=4201 craco start", "build": "craco build", // ... } } ``` * 執行 ```npm run start```,至瀏覽器輸入網址「[http://localhost:4201/static/js/main.js](http://localhost:4201/static/js/main.js)」可以看是否有初始成功 * 連結進 root config * 加入 importmap ```html <!-- single-spa-demo-root-config/src/index.ejs --> <script type="systemjs-importmap"> { "imports": { ... "@single-spa-demo/app-react": "//localhost:4201/static/js/main.js" } } </script> ``` * 加入 zone ```html <!-- single-spa-demo-root-config/src/index.ejs --> <script src="https://cdn.jsdelivr.net/npm/zone.js@0.11.3/dist/zone.min.js"></script> ``` * 加入 router ```html <!-- single-spa-demo-root-config/src/microfrontend-layout.html --> <single-spa-router> <main> <route path="app-react"> <application name="@single-spa-demo/app-react"></application> </route> </main> </single-spa-router> ``` * 至瀏覽器輸入網址「[http://localhost:9000/app-react](http://localhost:9000/app-react)」可以看是否有初始成功 ### 建立 in-browser utility module * 執行 init single-spa ```npm init single-spa``` * 替專案目錄命名 * 選擇使用 in-browser utility module * 使用框架選擇(建議使用 none) * 套件管理器選擇(建議使用 npm) * 是否使用 Typescript(建議使用) * 使用打包方式選擇(建議使用 esm) * 主專案名稱(single-spa-demo) * 子專案名稱(auth) * 設定 port=9001 ```json // pakage.json { // ... "scripts": { // ... "start": "webpack serve --port=9001", } } ``` * 執行 ```npm run start```,至瀏覽器輸入預設網址「[http://localhost:9001/single-spa-demo-auth.js](http://localhost:9001/single-spa-demo-auth.js)」可以看是否有初始成功 * 連結進 root config * 加入 importmap ```html <!-- single-spa-demo-root-config/src/index.ejs --> <script type="systemjs-importmap"> { "imports": { ... "@single-spa-demo/auth": "//localhost:9001/single-spa-demo-auth.js" } } </script> ``` * Application Import (Angular) * 新增 declarations.d.ts ```javascript declare module '@single-spa-demo/auth'; ``` * 設定 import ```javascript // extra-webpack.config.js module.exports = (config, options) => { const singleSpaWebpackConfig = singleSpaAngularWebpack(config, options); singleSpaWebpackConfig.externals = [/^@single-spa-demo\/auth$/]; return singleSpaWebpackConfig; }; ``` * 測試 Export/Import ```javascript // single-spa-demo-auth\src\single-spa-demo-auth.ts export const SS_DEMO_USERINFO_KEY = "SS-DEMO-USER-INFO"; ``` ```javascript // single-spa-demo-main\src\app\app.componet.ts import { SS_DEMO_USERINFO_KEY } from '@single-spa-demo/auth'; console.log(SS_DEMO_USERINFO_KEY); ``` * Application Import (React) * 新增 declarations.d.ts ```javascript declare module '@single-spa-demo/auth'; ``` * 設定 import ```javascript // craco.config.js module.exports = { webpack: { configure: (config) => { // ... config.externals= ["@single-spa-demo/auth"]; return config; }, }, ``` * 測試 Export/Import ```javascript // single-spa-demo-auth\src\single-spa-demo-auth.ts export const SS_DEMO_USERINFO_KEY = "SS-DEMO-USER-INFO"; ``` ```javascript // App.tsx import { SS_DEMO_USERINFO_KEY } from '@single-spa-demo/auth'; console.log(SS_DEMO_USERINFO_KEY); ``` ## Import config.json/importmap.json from deploy ### Import and Export importmap.json (root config、in-browser utility module) * root config * 建立 importmap.json ```json // src/assets/config/importmap.json { "imports": { "@single-spa-demo/root-config": "/root-config.bundle.js", "@single-spa-demo/auth": "//localhost:9001/single-spa-demo-auth.js", "@single-spa-demo/main": "//localhost:4200/main.js", "@single-spa-demo/app-react": "//localhost:4201/static/js/main.js" } } ``` * 匯入檔案 ```html <!--index.ejs--> <script type="systemjs-importmap" src="/assets/config/importmap.json"></script> ``` * in-browser utility module (export deployUrls_init$ and deployUrls$) * 建立 config.ts ```typescript import { BehaviorSubject, Subject, Observable } from "rxjs"; const deployUrlsBehaviorSubject$ = new BehaviorSubject<any[]>(undefined); const deployUrlsSubject$ = new Subject<any[]>(); export const deployUrls_init$: Observable<any[]> = deployUrlsBehaviorSubject$.asObservable(); export const deployUrls$: Observable<any[]> = deployUrlsSubject$.asObservable(); // for browser reload export function loadDeployUrls() { return fetch(`${window.location.origin}/assets/config/importmap.json`) .then(async (response) => { if (!response.ok) { throw { status: response.status, body: await response.json(), }; } return await response.json(); }) .then((importmap) => { let deployUrls; Object.keys(importmap.imports).forEach((key) => { const url = importmap.imports[key]; const a = document.createElement("a"); a.href = url; deployUrls = { ...deployUrls, [key]: a.origin, }; }); deployUrlsBehaviorSubject$.next(deployUrls); deployUrlsSubject$.next(deployUrls); }); } ``` * 呼叫方法 ```typescript // single-spa-demo-auth.ts import * as config from "./config"; export const DEMO_CONFIG_SERVICE = config; // 匯出給其他 Application 使用 DEMO_CONFIG_SERVICE.loadDeployUrls(); // 讀取所有系統網址 ``` ### Import config.json (Application) * Angular * 新增 declarations.d.ts ```javascript declare module '@single-spa-demo/auth'; ``` * 設定 import ```javascript // extra-webpack.config.js module.exports = (config, options) => { const singleSpaWebpackConfig = singleSpaAngularWebpack(config, options); singleSpaWebpackConfig.externals = [/^@single-spa-demo\/auth$/]; return singleSpaWebpackConfig; }; ``` * 建立 config.json ```json // src\assets\config\config.json { "apiUrl": "...", "deployUrl": "..." } ``` * 建立 app-config.service.ts ```typescript // src\app\core\services\app-config.service.ts import { Injectable } from '@angular/core'; import { DEMO_CONFIG_SERVICE } from '@single-spa-demo/auth'; import { Observable, of } from 'rxjs'; import { first, switchMap } from 'rxjs/operators'; @Injectable({ providedIn: 'root', }) export class AppConfigService { config: any; constructor() {} load(): Observable<any> { return DEMO_CONFIG_SERVICE.deployUrls_init$.pipe( first(), switchMap((deployUrls: any) => { if (!deployUrls) { // for browser reload return DEMO_CONFIG_SERVICE.deployUrls$.pipe(first()); } return of(deployUrls); }), switchMap((deployUrls: any) => { return fetch(`${deployUrls['@single-spa-demo/main']}/assets/config/config.json`) .then((res) => res.json()) .then((data) => { this.config = data; }); }) ); } } ``` * 設定 APP_INITIALIZER ```typescript // src\app\app.module.ts import { APP_INITIALIZER, NgModule } from '@angular/core'; import { AppConfigService } from './core/services/app-config.service'; export function initializeApp(AppConfigService: AppConfigService) { return () => AppConfigService.load(); } @NgModule({ declarations: [ //... ], imports: [ //... ], providers: [ { provide: APP_INITIALIZER, useFactory: initializeApp, deps: [AppConfigService], multi: true, }, //... ], bootstrap: [AppComponent], }) export class AppModule {} ``` * 加入方法 assetUrl 取代讀取圖片路徑 * 建立 Service ```typescript // src\app\core\services\image.service.ts import { Injectable } from '@angular/core'; import { AppConfigService } from '../services/app-config.service'; import { DEMO_ICON } from '../constants/icon'; @Injectable({ providedIn: 'root', }) export class ImageService { demoIcon = this.assetUrl(DEMO_ICON); constructor(private appConfigService: AppConfigService) {} assetUrl(url: string): string { const publicPath = // @ts-ignore this.appConfigService.config.deployUrl || __webpack_public_path__; const publicPathSuffix = publicPath.endsWith('/') ? '' : '/'; const urlPrefix = url.startsWith('/') ? '' : '/'; return `${publicPath}${publicPathSuffix}assets${urlPrefix}${url}`; } } ``` * 圖片路徑皆需經過 assetUrl 方法 ```typescript // example import { ImageService } from 'src/app/core/services/image.service'; constructor(private imageService: ImageService) {} demoIcon = this.imageService.demoIcon; ``` * React * 加入全域變數 config、方法 assetUrl 取代讀取圖片路徑 * 建立檔案 src/assets/config/config.json ```json { "deployUrl": "http://localhost:4201/" } ``` * 建立檔案 src/single-spa/config-service.ts ```typescript import { DEMO_CONFIG_SERVICE } from "@single-spa-demo/auth"; import { of } from "rxjs"; import { first, switchMap } from "rxjs/operators"; const config = {} export default config; // 讀取 ConfigMap 檔 export async function load() { return await DEMO_CONFIG_SERVICE.deployUrls_init$ .pipe( first(), switchMap((deployUrls: any) => { if (!deployUrls) { // for browser reload return DEMO_CONFIG_SERVICE.deployUrls$.pipe(first()); } return of(deployUrls); }), switchMap((deployUrls: any) => { return fetch( `${deployUrls[`@single-spa-demo/app-react`]}/assets/config/config.json` ) .then((res) => res.json()) .then((data) => { config = data; }); }) ) .subscribe(); } // 使用 config 中路徑讀取圖片 export function assetUrl(url: string): string { // @ts-ignore const publicPath = config.deployUrl || __webpack_public_path__; const publicPathSuffix = publicPath.endsWith('/') ? '' : '/'; const urlPrefix = url.startsWith('/') ? '' : '/'; return `${publicPath}${publicPathSuffix}assets${urlPrefix}${url}`; } ``` * 進入點需呼叫讀取 config 檔 ```typescript // index.tsx import { load as loadConfig } from './single-spa/config-service'; function Root() { // 確認 config fetch 完成後再渲染 App.tsx const [doRen, setDoRen] = useState<boolean>(false); useEffect(() => { let doCon = loadConfig().subscribe({ next: () => { if(!!config){setDoRen(true);} } }); return () =>{ setDoRen(false); doCon.unsubscribe(); } }, []) // ... {doRen ? <App /> : null} // ... } ``` * 取代讀取圖片路徑 * 圖片路徑皆需經過 assetUrl 方法 ```typescript // example import NoticeImg from './assets/images/Img_Notice_Horizontal.svg'; import { assetUrl } from './single-spa/config-service'; function Test() { return ( <div> <img src={assetUrl(NoticeImg)} /> </div> ); } ``` ## Import Local Folders * 安裝套件 [copy-webpack-plugin](https://www.npmjs.com/package/copy-webpack-plugin)、[clean-webpack-plugin](https://www.npmjs.com/package/clean-webpack-plugin) ```npm install copy-webpack-plugin --save-dev``` ```npm install clean-webpack-plugin --save-dev``` * 設置 patterns ```javascript plugins: [ //... new CopyPlugin({ patterns: [ { from: "assets/js", to: "assets/js", }, { from: "assets", to: "assets", }, ], }), new CleanWebpackPlugin({ verbose: true, }), ], ``` ## Import packge styles * 一般 ```css // style.css @import "node_modules/<package>"; ``` * 省略 node_modules ```css // src/style.css @import "<package>"; ``` ```json // angular.json "build": { //... "options": { "stylePreprocessorOptions": { "includePaths": ["node_modules"] }, //... } } ``` ## Use one style file when builded * 安裝套件 [mini-css-extract-plugin](https://awdr74100.github.io/2020-03-02-webpack-minicssextractplugin/) ```npm install mini-css-extract-plugin --save-dev``` * 設置 rules ```javascript const MiniCssExtractPlugin = require("mini-css-extract-plugin"); module.exports = (webpackConfigEnv, argv) => { //... return merge(defaultConfig, { plugins: [ new MiniCssExtractPlugin(), //... ], module: { rules: [ { test: /\.s[ac]ss$/i, use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"], }, ], }, }); }; ``` * Import main.css ```html <!--index.ejs--> <head> <!--...--> <link rel="stylesheet" href="/main.css"> <!--...--> </head> ``` ## Change output file name in webpack * webpack 預設 build 完的靜態檔案(圖片、字型檔)檔名為 hash 值 * 方法一:webpack * 更改檔案檔名 ```javascript module.exports = (webpackConfigEnv, argv) => { // ... return merge(defaultConfig, { // ... output: { // 入口檔案檔名 filename: "[name].js", // 靜態檔案檔名 assetModuleFilename: "[path][name][ext]", }, }); }; ``` * 方法二:使用套件 craco(React) * 匯入套件 craco ```npm i --save-dev @craco/craco``` * 建立檔案 src/craco.config.js ```javascript module.exports = { webpack: { configure: (config) => { config.output.libraryTarget = 'umd'; config.output.filename = 'static/js/[name].js'; config.output.library = 'single-spa-demo-app-react'; config.output.publicPath = '/'; return config } } } ``` * 更改 script ```json // package.json { "scripts": { "start": "cross-env PORT=4201 craco start", "build": "craco build", // ... } } ``` ## Prop * defind ![image](https://hackmd.io/_uploads/HJgPvH66a.png) * use ![image](https://hackmd.io/_uploads/rkBWOH6pT.png) * get * not work... ## Import Module Federation > [【NOTE】Module Federation](/-LHmfJBrTqi3H1hgBN-WbQ) ### Application(Angular) * 使用 webpackMerge,修改檔案 extra-webpack.config.ts ```typescript const singleSpaAngularWebpack = require("single-spa-angular/lib/webpack").default; const webpackMerge = require("webpack-merge"); const { ModuleFederationPlugin } = require("webpack").container; const { shareAll } = require("@angular-architects/module-federation/webpack"); module.exports = (config, options) => { const singleSpaWebpackConfig = singleSpaAngularWebpack(config, options); return webpackMerge.default(singleSpaWebpackConfig, { plugins: [ new ModuleFederationPlugin({ name: "angular-app-name", filename: "remoteEntry.js", remotes: { modal: "layout@http://localhost:4201/remoteEntry.js", }, shared: { ...shareAll({ singleton: true, strictVersion: true, requiredVersion: "auto", eager: true, // 如 angular 版本太舊,需開啟預先載入 }), }, }), ], }); }; ``` :::info * References for 'eager' * [Webpack Troubleshooting: Shared module is not available for eager consumption](https://webpack.js.org/concepts/module-federation/#troubleshooting) * [babel-loader version is too old](https://alexocallaghan.com/module-not-available-for-eager-consumption) * 疑難排解可參考 [【NOTE】Module Federation - 疑難排解](/-LHmfJBrTqi3H1hgBN-WbQ#疑難排解) ::: * 需使用動態載入,於欲使用 Module Federation 的元件中加入 ```html <div #header></div> ``` ```typescript import { loadRemoteModule } from '@angular-architects/module-federation'; @ViewChild('header', { read: ViewContainerRef }) viewContainer!: ViewContainerRef; async loadHeader() { const m = await loadRemoteModule({ type: 'module', remoteEntry: 'http://localhost:4201/remoteEntry.js', exposedModule: './Component/Header' }); const ref = this.viewContainer.createComponent(m.HeaderComponent); // const compInstance = ref.instance; // compInstance.ngOnInit() } ```