最近一段時間,因爲將來工做中涉及工業應用較多,而且考慮之後須要將工業應用在同一系統進行展現,但願有一個突破口能夠解決這個問題,即在一個總項目中展現不一樣的工業應用,每一個工業應用是一個單獨的項目,因爲工業應用可能因爲不一樣團隊進行開發,應用開發技術棧最好沒有限制,單一前端框架可能再也不能知足要求,由此瞭解到微前端並進行嘗試研究,但願藉助微前端能夠解決工業應用匯總展現的問題。javascript
固然微前端的實現方式不有不少種,包括iframe、single-spa等,本文采用的主要是single-spa。若是你使用過iframe就會知道,iframe和single-spa徹底不是一個難度,若是把iframe比做是easy模式,那麼single-spa即是地獄模式,若是你的項目着急上線使用微前端最快的方法就是iframe,,相信也有不少人在想搞一下微前端的同窗們,猝死在了研究single-spa的路上,還有也是資料的缺乏,由於如今網上雖然微前端文章不少,可是大多隻是理論介紹和部分代碼的展現,並無法幫助咱們真正落地在項目中去實施。css
single-spa使用的主要難點在於: 1.對於總項目,如何完美兼容各個子項目,作到技術棧無關,而且讓用戶感受是一個總體項目。 2.對於各個子項目,如何減小代碼侵入,而且使其具備獨立開發、獨立運行、獨立部署的能力。html
微前端是借鑑後端微服務的概念而來,single-spa官網解釋到,A microfrontend is a microservice that exists within a browser即微前端是瀏覽器中存在的微服務,微前端也是UI的一部分,一般由數十個組件組成,而這些組件能夠是React,Vue和Angular等不一樣框架實現的,每一個微前端項目能夠交由不一樣的團隊進行管理,每一個團隊也能夠選擇本身的框架。儘管在遷移或實驗時可能會添有其它框架,可是最好對全部微前端使用一個框架,這是最實用的。 每一個微前端項目能夠放在不一樣的git存儲庫中,有本身的package.json和構建工具配置。這樣每個微前端項目都有一個獨立的構建打包過程和獨立的部署,也就意味着咱們能夠快速完成咱們的微前端項目的打包上線,而不用每次對一個巨無霸(Monolith)項目進行操做,後期有新的需求,也只須要修改對應的微前端項目。前端
目前隨着前端的不斷髮展,企業工程項目體積愈來愈大,頁面愈來愈多,項目變得十分臃腫,維護起來也十分困難,有時咱們僅僅更改項目簡單樣式,都須要整個項目從新打包上線,給開發人員形成了不小的麻煩,也很是浪費時間。老項目爲了融入到新項目也須要不斷進行重構,形成的人力成本也很是的高。vue
在前端開發工做中,面臨的困難:
對比分析:
微前端實現方式有兩種:java
1.iframe嵌入 (難度:★)node
2.single-spa合併類單頁應用 (難度:★★★★★)react
iframe嵌入方式比較容易實現,再也不贅述。webpack
爲何不用 iframe,這幾乎是全部微前端方案第一個會被 challenge 的問題。可是大部分微前端方案又不約而同放棄了 iframe 方案,天然是有緣由的,並非爲了 "炫技" 或者刻意追求 "特立獨行"iframe 最大的特性就是提供了瀏覽器原生的硬隔離方案,不管是樣式隔離、js 隔離這類問題通通都能被完美解決。但他的最大問題也在於他的隔離性沒法被突破,致使應用間上下文沒法被共享,隨之帶來的開發體驗、產品體驗的問題nginx
- url 不一樣步。瀏覽器刷新 iframe url 狀態丟失、後退前進按鈕沒法使用。
- UI 不一樣步,DOM 結構不共享。想象一下屏幕右下角 1/4 的 iframe 裏來一個帶遮罩層的彈框,同時咱們要求這個彈框要瀏覽器居中顯示,還要瀏覽器 resize 時自動居中.
- 全局上下文徹底隔離,內存變量不共享。iframe 內外系統的通訊、數據同步等需求,主應用的 cookie 要透傳到根域名都不一樣的子應用中實現免登效果
- 慢。每次子應用進入都是一次瀏覽器上下文重建、資源從新加載的過程
其中有的問題比較好解決(問題1),有的問題咱們能夠睜一隻眼閉一隻眼(問題4),但有的問題咱們則很難解決(問題3)甚至沒法解決(問題2),而這些沒法解決的問題偏偏又會給產品帶來很是嚴重的體驗問題, 最終致使咱們捨棄了 iframe 方案。
參考文章: Why Not Iframe
single-spa實現原理:
首先對微前端路由進行註冊,使用single-spa充當微前端加載器,並做爲項目單一入口來接受全部頁面URL的訪問,根據頁面URL與微前端的匹配關係,選擇加載對應的微前端模塊,再由該微前端模塊進行路由響應URL,即微前端模塊中路由找到相應的組件,渲染頁面內容。
參考文章: single-spa官網
❤️❤️❤️ 項目源碼地址 ❤️❤️️❤️
基座項目建立:
yarn create react-app portal
yarn add antd
// 建立config-overrides.js支持antd按需加載
// fixBabelImports('import', {
// libraryName: 'antd',
// libraryDirectory: 'es',
// style: true,
// }),
複製代碼
本文采用微前端加載原理是:
首先在父項目建立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>
複製代碼
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)第一個參數判斷子應用是否處於激活狀態。
環境準備:
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方法。
環境準備:
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圖片報錯解決辦法:
- 加載失敗後,經過檢查發現,父項目中加載子項目圖片地址爲:http://localhost:3000/static/media/logo.5d5d9eef.svg。
- 此時父項目地址爲:http://localhost:3000/,子項目地址爲:http://localhost:5000/
- 不難發現,logo圖片請求地址應爲子項目地址即http://localhost:5000/static/media/logo.5d5d9eef.svg
- 靜態資源最終訪問路徑 = output.publicPath + 資源loader或插件等配置路徑,默認publicPath路徑爲網站根目錄的位置,而在父項目加載子項目時,當前網站根目錄爲http://localhost:3000/
- 能夠經過將 output.publicPath設置爲子項目跟目錄http://localhost:5000/解決這個問題。
環境準備:
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)
single-spa-props.ts
in src/single-spa/
asset-url.ts
in src/single-spa/
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 |
父項目加載過程當中,所有請求:
線上部署完成後`首個請求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;
}
複製代碼
stats-webpack-plugin生成manifest.json,實現自動加載。
CSS處理用到postcss-loader,postcss-loader用到postcss,咱們添加postcss的處理插件,爲每個CSS選擇器都添加名爲`.namespace-kaoqin`的根選擇器,最後打包出來的CSS,以下所示:
接入地址只需配置一次,省略使用manifest動態加載,由於html自己就是一個完整的manifest.