Single-Spa微前端落地(含nginx部署)

0. 關於

最近一段時間,因爲將來工做中涉及工業應用較多,而且考慮之後須要將工業應用在同一系統進行展現,但願有一個突破口能夠解決這個問題,即在一個總項目中展現不一樣的工業應用,每一個工業應用是一個單獨的項目,因爲工業應用可能因爲不一樣團隊進行開發,應用開發技術棧最好沒有限制,單一前端框架可能再也不能知足要求,由此瞭解到微前端並進行嘗試研究,但願藉助微前端能夠解決工業應用匯總展現的問題。javascript

固然微前端的實現方式不有不少種,包括iframe、single-spa等,本文采用的主要是single-spa。若是你使用過iframe就會知道,iframe和single-spa徹底不是一個難度,若是把iframe比做是easy模式,那麼single-spa即是地獄模式,若是你的項目着急上線使用微前端最快的方法就是iframe,,相信也有不少人在想搞一下微前端的同窗們,猝死在了研究single-spa的路上,還有也是資料的缺乏,由於如今網上雖然微前端文章不少,可是大多隻是理論介紹和部分代碼的展現,並無法幫助咱們真正落地在項目中去實施。css

single-spa使用的主要難點在於: 1.對於總項目,如何完美兼容各個子項目,作到技術棧無關,而且讓用戶感受是一個總體項目。 2.對於各個子項目,如何減小代碼侵入,而且使其具備獨立開發、獨立運行、獨立部署的能力。html

1. 微前端是什麼

微前端是借鑑後端微服務的概念而來,single-spa官網解釋到,A microfrontend is a microservice that exists within a browser即微前端是瀏覽器中存在的微服務,微前端也是UI的一部分,一般由數十個組件組成,而這些組件能夠是React,Vue和Angular等不一樣框架實現的,每一個微前端項目能夠交由不一樣的團隊進行管理,每一個團隊也能夠選擇本身的框架。儘管在遷移或實驗時可能會添有其它框架,可是最好對全部微前端使用一個框架,這是最實用的。 每一個微前端項目能夠放在不一樣的git存儲庫中,有本身的package.json和構建工具配置。這樣每個微前端項目都有一個獨立的構建打包過程和獨立的部署,也就意味着咱們能夠快速完成咱們的微前端項目的打包上線,而不用每次對一個巨無霸(Monolith)項目進行操做,後期有新的需求,也只須要修改對應的微前端項目。前端

2. 爲何要用微前端

目前隨着前端的不斷髮展,企業工程項目體積愈來愈大,頁面愈來愈多,項目變得十分臃腫,維護起來也十分困難,有時咱們僅僅更改項目簡單樣式,都須要整個項目從新打包上線,給開發人員形成了不小的麻煩,也很是浪費時間。老項目爲了融入到新項目也須要不斷進行重構,形成的人力成本也很是的高。vue

在前端開發工做中,面臨的困難:
  1. 企業工程項目愈來愈大,項目構建部署速度慢。
  2. 工程團隊人員較多,技術棧難以統一,異地團隊間溝通成本高,開發代碼容易衝突,會影響整個項目。
  3. 舊項目重構,代碼改動太大,消耗時間嚴重。
對比分析:
  1. 具備獨立運行、獨立部署功能,構建部署速度快。
  2. 技術棧無關,具備獨立開發功能,避免開發衝突,減小協做成本。
  3. 舊項目能夠做爲微前端項目一部分,避免重構。

3. 微前端實現方式

微前端實現方式有兩種:java

1.iframe嵌入 (難度:★)node

2.single-spa合併類單頁應用 (難度:★★★★★)react

4. iframe

iframe嵌入方式比較容易實現,再也不贅述。webpack

Why Not Iframe

爲何不用 iframe,這幾乎是全部微前端方案第一個會被 challenge 的問題。可是大部分微前端方案又不約而同放棄了 iframe 方案,天然是有緣由的,並非爲了 "炫技" 或者刻意追求 "特立獨行"

iframe 最大的特性就是提供了瀏覽器原生的硬隔離方案,不管是樣式隔離、js 隔離這類問題通通都能被完美解決。但他的最大問題也在於他的隔離性沒法被突破,致使應用間上下文沒法被共享,隨之帶來的開發體驗、產品體驗的問題nginx

  1. url 不一樣步。瀏覽器刷新 iframe url 狀態丟失、後退前進按鈕沒法使用。
  2. UI 不一樣步,DOM 結構不共享。想象一下屏幕右下角 1/4 的 iframe 裏來一個帶遮罩層的彈框,同時咱們要求這個彈框要瀏覽器居中顯示,還要瀏覽器 resize 時自動居中.
  3. 全局上下文徹底隔離,內存變量不共享。iframe 內外系統的通訊、數據同步等需求,主應用的 cookie 要透傳到根域名都不一樣的子應用中實現免登效果
  4. 慢。每次子應用進入都是一次瀏覽器上下文重建、資源從新加載的過程

其中有的問題比較好解決(問題1),有的問題咱們能夠睜一隻眼閉一隻眼(問題4),但有的問題咱們則很難解決(問題3)甚至沒法解決(問題2),而這些沒法解決的問題偏偏又會給產品帶來很是嚴重的體驗問題, 最終致使咱們捨棄了 iframe 方案。

參考文章: Why Not Iframe

5. single-spa

single-spa實現原理:
首先對微前端路由進行註冊,使用single-spa充當微前端加載器,並做爲項目單一入口來接受全部頁面URL的訪問,根據頁面URL與微前端的匹配關係,選擇加載對應的微前端模塊,再由該微前端模塊進行路由響應URL,即微前端模塊中路由找到相應的組件,渲染頁面內容。
參考文章: single-spa官網

6. 微前端實現過程

❤️❤️❤️ 項目源碼地址 ❤️❤️️❤️

6.1 基座項目(父項目改造)

基座項目建立:
yarn create react-app portal
yarn add antd
// 建立config-overrides.js支持antd按需加載
// fixBabelImports('import', {
// libraryName: 'antd',
// libraryDirectory: 'es',
// style: true,
// }),
複製代碼
本文采用微前端加載原理是:
首先在父項目建立dom節點,在項目註冊過程輸入待掛載的節點,便可完成子項目在父項目中運行。

6.2.1 建立路由及子項目掛載dom節點

代碼以下:
<div className="App" >
      <Layout>
        <Sider trigger={null} collapsible collapsed={collapsed}>
          <div className="logo" />
          <ul >
            <li key="react" >
              <Link to="/react">React</Link>
            </li>
            <li key="vue" >
              <Link to="/vue">Vue</Link>
            </li>
            <li key="angular" >
              <Link to="/angular">Angular</Link>
            </li>
          </ul>
        </Sider>
        <Layout className="site-layout">
          <Header className="site-layout-background" style={{ padding: 0 }}>
            {React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
              className: 'trigger',
              onClick: () => { setCollapse(!collapsed) },
            })}
          </Header>
          <Content
            className="site-layout-background"
            style={{
              margin: '24px 16px',
              padding: 24,
              minHeight: 280,
            }}
          <blockquote style=' padding: 10px 10px 10px 1rem; font-size: 0.9em; margin: 1em 0px; color: rgb(0, 0, 0); border-left: 5px solid #9370DB; background: rgb(239, 235, 233);'></blockquote>
            <div id="vue" />
            <div id="react-app" />
            <app-root></app-root>
          </Content>
        </Layout>
      </Layout>
    </div>
複製代碼

6.2.2 微前端應用註冊

src文件中建立singleSpa.js文件。
將文件引入項目入口文件index.js文件中。
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter as Router } from 'react-router-dom'
import "./singleSpa.js"; // 引入微前端配置文件;

複製代碼
// 項目目錄結構
├── public
├── src
│   ├── index.js
│   ├── singleSpa.js
│   └── App.jsx
├── config-overrides.js
├── package.json
├── README.md
├── yarn.lock
複製代碼
singleSpa.js部分代碼:
import * as singleSpa from 'single-spa';

// 註冊應用方式參考文章:
// Single-Spa + Vue Cli 微前端落地指南 (項目隔離遠程加載,自動引入)(https://juejin.im/post/5dfd8a0c6fb9a0165f490004#heading-2)
/** * runScript 一個promise同步方法。能夠代替建立一個script標籤,而後加載服務 * @param {string} url 請求文件地址 */
const runScript = async (url) => {
    // 加載css同理
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = url;
        script.onload = resolve;
        script.onerror = reject;
        const firstScript = document.getElementsByTagName('script')[0];
        firstScript.parentNode.insertBefore(script, firstScript);
    });
};

// 註冊微前端服務
/* 註冊所用函數; return 一個模塊對象(singleSpa),模塊對象來自於要加載的js導出(子項目); 若是這個函數不須要在線引入,只須要本地引入一塊加載: () => import('xxx/main.js') */
singleSpa.registerApplication(
    'vue',
    async () => {
            await runScript('http://127.0.0.1:8080/js/chunk-vendors.js');
            await runScript('http://127.0.0.1:8080/js/app.js');
            return window.singleVue;
        },
        // 配置微前端模塊前綴
        // 純函數根據參數查看是否處於活動狀態
        (location) => location.pathname.startsWith('/vue')
);

singleSpa.start(); // 啓動註冊,別忘記!
複製代碼
registerApplication參數含義:

一、 appName: string 應用名稱

二、applicationOrLoadingFn: () => <Function | Promise>返回promise加載函數或者已解析的應用。

// 應用做爲參數,該參數由一個帶有生命週期的對象組成。
const application = {
  bootstrap: () => Promise.resolve(), //bootstrap function
  mount: () => Promise.resolve(), //mount function
  unmount: () => Promise.resolve(), //unmount function
}
registerApplication('applicatonName', application, activityFunction)
複製代碼

加載函數做爲參數必須返回一個promise或者異步函數,第一次加載應用程序時,將不帶任何參數地調用該函數,返回promise必須和應用一塊兒解決。最多見的加載函數導入方式是:() => import('/path/to/application.js')

三、activityFn: (location) => boolean

動態函數(activity function),必須是一個純函數,函數將window.location做爲第一個參數提供,並在應用程序處於活動狀態時返回一個判斷結果。常見使用時,經過動態函數(activity function)第一個參數判斷子應用是否處於激活狀態。

6.2 微前端項目(子項目改造)

6.2.1 Vue子項目改造

環境準備:
npm install -g @vue/cli	//全局安裝vue-cli
vue create vue-project	// 建立子項目

// 項目目錄結構
├── public
├── src
│   ├── main.js
│   ├── assets
│   ├── components
│   └── App.vue
├── vue.config.js
├── package.json
├── README.md
└── yarn.lock
複製代碼
修改main.js文件進行註冊
import Vue from 'vue'
import App from './App.vue'
import singleSpaVue from "single-spa-vue";

Vue.config.productionTip = false
// el 爲子項目待掛載到父項目的DOM節點!!!
const vueOptions = {
  el: "#vue",
  render: h => h(App)
};

// 主應用註冊成功後會在window下掛載singleSpaNavigate方法
// 爲了獨立運行,避免子項目頁面爲空,
// 判斷若是不在微前端環境下進行獨立渲染html
if (!window.singleSpaNavigate) {
  new Vue({
    render: h => h(App),
  }).$mount('#app')
}

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: vueOptions,
});

export const bootstrap = vueLifecycles.bootstrap; // 啓動時
export const mount = vueLifecycles.mount; // 掛載時
export const unmount = vueLifecycles.unmount; // 卸載時

export default vueLifecycles;
複製代碼
根目錄建立vue.config.js修改webpack配置
module.exports = {
    /* 重點: 設置publicPath,避免父項目加載子項目時,部分資源文件路徑爲父項目地址,致使請求文件失敗。 */
    publicPath: "//localhost:8080/",
    configureWebpack: {
        devtool: 'none', // 不打包sourcemap
        output: {
            library: "singleVue", // 導出名稱
            libraryTarget: "window", //掛載目標,能夠在瀏覽器打印window.singleVue查看
        }
    },
    devServer: {
        contentBase: './',
        compress: true,
    }
};
複製代碼
子項目改造咱們總體能夠分爲兩個步驟:
1. 子項目入口文件改造,註冊微前端,肯定子項目掛載節點;
2. 子項目webpack出口文件改造,打包後在window下建立singleVue方法。

6.2.1 React子項目改造

環境準備:
yarn create react-app react  // 建立子項目
yarn add single-spa-react // 安裝single-spa-react

// 項目目錄結構
├── public
├── src
│   ├── App.js
│   ├── index.js
│   └── serviceWorker.js
├── config
│   ├── jest
│   ├── webpack.config.js
│   └── webpackDevServer.config.js
├── scripts
│   ├── build.js
│   ├── start.js
│   └── test.js
├── package.json
├── README.md
└── yarn.lock
複製代碼
修改index.js文件進行註冊
import Vue from 'vue'
import App from './App.vue'
import singleSpaVue from "single-spa-vue";

Vue.config.productionTip = false
// el 爲子項目待掛載到父項目的DOM節點!!!
const vueOptions = {
  el: "#vue",
  render: h => h(App)
};

// 主應用註冊成功後會在window下掛載singleSpaNavigate方法
// 爲了獨立運行,避免子項目頁面爲空,
// 判斷若是不在微前端環境下進行獨立渲染html
if (!window.singleSpaNavigate) {
  new Vue({
    render: h => h(App),
  }).$mount('#app')
}

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: vueOptions,
});

export const bootstrap = vueLifecycles.bootstrap; // 啓動時
export const mount = vueLifecycles.mount; // 掛載時
export const unmount = vueLifecycles.unmount; // 卸載時

export default vueLifecycles;
複製代碼
修改項目啓動端口號:
// scripts文件夾內start.js文件
- const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000
+ const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 5000;
複製代碼
修改webpack配置,修改config文件夾中webpack.config.js文件
- publicPath:paths.publicUrlOrPath
+ publicPath: 'http://localhost:5000/',
+ library: "singleReact", // 導出名稱
+ libraryTarget: "window", //掛載目標
複製代碼
父項目首次加載子項目靜態文件logo圖片報錯解決辦法:
  1. 加載失敗後,經過檢查發現,父項目中加載子項目圖片地址爲:http://localhost:3000/static/media/logo.5d5d9eef.svg。
  2. 此時父項目地址爲:http://localhost:3000/,子項目地址爲:http://localhost:5000/
  3. 不難發現,logo圖片請求地址應爲子項目地址即http://localhost:5000/static/media/logo.5d5d9eef.svg
  4. 靜態資源最終訪問路徑 = output.publicPath + 資源loader或插件等配置路徑,默認publicPath路徑爲網站根目錄的位置,而在父項目加載子項目時,當前網站根目錄爲http://localhost:3000/
  5. 能夠經過將 output.publicPath設置爲子項目跟目錄http://localhost:5000/解決這個問題。

6.2.1 Angular子項目改造

環境準備:
npm install -g @angular/cli //全局安裝angular/cli,直接安裝報錯
// 報錯信息 TypeError: Cannot read property 'flags' of undefined

npm install @angular/cli@9.0.0 -g // 指定版本安裝
ng new angular-project // 建立項目

// 項目目錄結構
├── e2e
├── src
│   ├── + `main.single-spa.ts`
│   ├── + `single-spa`	
│   │	├── + `single-spa-props.ts`
│   │	└── + `asset-url.ts`
│   ├── app
│   │	├── + `empty-route`
│   │	│	└── + `empty-route.component.ts`
│   │	├──  empty-route.component.ts
│   │	├──  app.component.ts
│   │	└──  `app.module.ts` //use src/app/empty-route/ EmptyRouteComponent
│   ├── assets
│   ├── environments
│   ├── index.html
│   ├── polyfills.ts
│   └── test.ts
├── node_modules
├── + `extra-webpack.config.js`
├── package.json
├── README.md
├── angular.json
└── yarn.lock
複製代碼
[報錯爲:TypeError: Cannot read property 'flags' of undefined](https://stackoverflow.com/questions/49544854/typeerror-cannot-read-property-flags-of-undefined)
  • Install single-spa-angular.
  • Generate single-spa-props.ts in src/single-spa/
  • Generate asset-url.ts in src/single-spa/
  • Generate an EmptyRouteComponent in src/app/empty-route/, to be used in app-routing.module.ts.
註冊應用
// src/app/empty-route/empty-route.component.ts 文件內代碼

import { Component } from '@angular/core';

@Component({
    selector: 'app2-empty-route',
    template: '',
})
export class EmptyRouteComponent {
}
複製代碼
// src/app/app.module.ts 文件內代碼

+ import { EmptyRouteComponent } from './empty-route/empty-route.component';
@NgModule({
  declarations: [
    AppComponent,
 +  EmptyRouteComponent
  ],
複製代碼
// src/single-spa/asset-url.ts 文件內代碼

export function assetUrl(url: string): string {
    // @ts-ignore
    const publicPath = __webpack_public_path__;
    const publicPathSuffix = publicPath.endsWith('/') ? '' : '/';
    const urlPrefix = url.startsWith('/') ? '' : '/'
  
    return `${publicPath}${publicPathSuffix}assets${urlPrefix}${url}`;
  }
複製代碼
// src/single-spa/single-spa-props.ts 文件內代碼
import { ReplaySubject } from 'rxjs';
import { AppProps } from 'single-spa';

export const singleSpaPropsSubject = new ReplaySubject<SingleSpaProps>(1)
export type SingleSpaProps = AppProps & {
}
複製代碼
// src/main.singleSpa.ts 文件內代碼

import { enableProdMode, NgZone } from '@angular/core';

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { Router } from '@angular/router';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { singleSpaAngular } from 'single-spa-angular';
import { singleSpaPropsSubject } from './single-spa/single-spa-props';

if (environment.production) {
    enableProdMode();
}
// if (!window.singleSpaNavigate) {
platformBrowserDynamic().bootstrapModule(AppModule)
    .catch(err => console.error(err));
// }

const lifecycles = singleSpaAngular({
    bootstrapFunction: singleSpaProps => {
        singleSpaPropsSubject.next(singleSpaProps);
        return platformBrowserDynamic().bootstrapModule(AppModule);
    },
    template: '<app2-root />',
    Router,
    NgZone: NgZone,
});

export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount;
複製代碼
修改angular.json爲修改出口文件作準備工做
npm i -D @angular-builders/custom-webpack //用於修改webpack 配置
npm i -D @angular-builders/dev-server
 "build": {
        - "builder": "@angular-devkit/build-angular:browser",
        + "builder": "@angular-builders/custom-webpack:browser",
          "options": {
          + "customWebpackConfig": {
          +   "path": "./extra-webpack.config.js" // 讀取文件,修改webpack配置
          + },
          + "deployUrl": "http://localhost:4000/", // 修改publicPath
            "outputPath": "dist/Delete",
            "index": "src/index.html",
————————————————
	"serve": {
      -	"builder": "@angular-devkit/build-angular:dev-server",
	  + "builder": "@angular-builders/custom-webpack:dev-server"
		"options": {
		"browserTarget": "Delete:build"
	 },    
————————————————        
複製代碼
設置出口文件:
建立extra-webpack.config.js並進行配置
module.exports = {
  output: {
    library: "singleAngular", // 導出名稱
    libraryTarget: "window", // 掛載目標
  },
}
複製代碼
參考文章:
single-spa-angular
single-spa-angular示例代碼地址
angular/cli版本 single-spa-angular版本
官方示例 8.1.0 3.0.1
本文示例 9.0.0 4

7. 線上部署Nginx配置

父項目加載過程當中,所有請求:
線上部署完成後`首個請求vue/`發生`nginx報錯404 Not Found`,經過排查發現nginx查找路徑錯誤:

部署訪問地址格式爲xx.xx:0000/vue/#/app1

vue:微前端應用名稱;

app1:子項目路由

Nginx報錯分析:

首次發起請求時,因爲這裏寫法相似browerRouter,根據請求會在nginx根目錄文件內查找/vue文件夾,並檢查是否有index.html文件進行返回。

xx.xx:0000/XX => nginx文件中XX文件夾 => 文件夾不存在返回404

由於/vue只是爲了註冊應用,並不須要真正去nginx中的/vue下查找index.html,由於按照這個路徑查找並不能查找成功,咱們仍但願在原來根地址進行查找。

ngix 遇到/XXX這樣地址會被看成代理,尋找對應文件夾,若是文件夾沒有就報錯404,可是實際我是但願去進入到個人項目裏面,我本身作判斷的

解決方案:
在帶有/vue進行訪問時,首先進行判斷,再使用nginx進行重定向,定向爲原根目錄。
注意事項:

此時進行重定向,可是咱們並不但願url地址發生改變,因此咱們須要使用rewrite "/xxx" /abc last;的這種跳轉形式,可是這種重定向只能對站內url重寫,若是rewrite第二個參數以http或者以https開頭或者使用permanent都會致使url地址欄改變。(302,301等會修改地址欄的url)

location /vue/ {	
			root   /home/nginx/static/html/refining/;
	      		rewrite ^/vue(.*) /;
			index index.html index.htm;
 		}
// 注意重定向保持url不變,

複製代碼

本來覺得,這樣就能夠宣告成功了!!!可是現實老是殘酷的。 咱們刷新瀏覽器使父項目從新加載子項目,發現父項目中加載子項目文件依然報錯,或者返回html致使類型報錯。

經過查看請求能夠發現,實際請求爲: xx.xx:0000/vue/#/static/css/main.d6XXXXXXXXXX

下面咱們繼續對靜態資源進行重定向:

location ~.*(gif|jpg|jpeg|bmp|png|ico|txt|js|css)$ {
	root   /home/nginx/static/html/refining/;
	rewrite ^/vue(.*) /$1 break;//$1 爲匹配到的第一個參數,即去掉vue後的請求地址
	index  index.html index.htm;
}
複製代碼
其它參考配置:
server {
    listen 8080;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;
    ssi on;

    # 將 / 重定向到 /browse
    rewrite ^/$ http://localhost:8080/browse redirect;

    # 根據路徑訪問 html 
    location /browse {
      set $PAGE 'browse';
    }
    location /order {
      set $PAGE 'order';
    }
    location /profile {
      set $PAGE 'profile'
    }

    # 全部其餘路徑都渲染 /index.html
    error_page 404 /index.html;
}
複製代碼

8. 展望

8.1 改造優化

8.1.1 JS文件自動加載

stats-webpack-plugin生成manifest.json,實現自動加載。

8.1.2 CSS沙箱

CSS處理用到postcss-loader,postcss-loader用到postcss,咱們添加postcss的處理插件,爲每個CSS選擇器都添加名爲`.namespace-kaoqin`的根選擇器,最後打包出來的CSS,以下所示:

8.1.3 JS沙箱

8.1.4 加載HTML方式加載子應用

接入地址只需配置一次,省略使用manifest動態加載,由於html自己就是一個完整的manifest.

參考文章

參考代碼

相關文章
相關標籤/搜索