前言
近兩年一直會有遇到需要微前端框架的需求,同時在招聘上,微前端的需求也是挺多的,最近整理了一下之前經手過的幾個qiankun微前端專案,分享給大家。
專案結構預覽
前期準備工作
- 主應用的搭建、基座的配置。
- 子應用template的搭建(react)。
搭建主應用
在workspace建立mirc-project目錄來存放主應用和微應用
mkdir mirc-project // 建立目錄
cd mirc-project
mkdir main // 建立主應用專案目錄
cd main
npm init //初始化package.json
為主應用安裝qiankun
yarn add qiankun
根目錄下新建src目錄,並新建index.html,根據結構預覽劃分html結構,同時,新建index.ts檔案,並在index.html引用,如下:
<body>
<div id="wrapper">
<div id="sidebar-slot"></div>
<div id="container">
<div id="navbar-slot"></div>
<div id="micro-app-wrapper">
<!-- loading icon -->
<div id="loading-wrapper">
<div class="sc-bdnxRM cCKQJl">
<div class="sc-gtsrHT kzzTWM"></div>
<div class="sc-gtsrHT kzzTWM"></div>
<div class="sc-gtsrHT kzzTWM"></div>
<div class="sc-gtsrHT kzzTWM"></div>
</div>
</div>
<div id="micro-app-slot"></div>
</div>
</div>
</div>
<script type="module" src="./index.ts"></script>
</body>
安裝一下ts+react開發環境
yarn add --dev typescript ts-node react react-dom @types/react @types/react-dom ejs jest @types/ejs @types/jest
下載babel
yarn add --dev babel-jest @babel/core @babel/preset-env @babel/preset-typescript
配置babel.config.json
{
"presets": [
["@parcel/babel-preset-env", { "targets": { "node": "current" } }],
"@babel/preset-typescript"
],
"plugins": ["@parcel/babel-plugin-transform-runtime"]
}
為主應用新增一個打包的庫,這裡選擇Parcel
, 以及微前端的一個庫single-spa
。
yarn add --dev parcel parcel-bundler parcel-plugin-custom-dist-structure
yarn add single-spa @parcel/babel-preset-env @parcel/babel-plugin-transform-runtime
為主應用package.json
新增執行指令碼:
"start": "parcel src/index.html",
"build:dev": "parcel build src/index.html --no-cache",
至此,需要的依賴都已搞定。接下來是code環節。
註冊子應用
子應用專案的搭建我們後面再做詳細介紹,現在假如我們已經成功執行了一個子應用,本地訪問localhost:3001。index.ts
import { registerMicroApps, start } from "qiankun";
registerMicroApps([
{
name: "react app", // app name registered
entry: "http://localhost:3001/",
container: "#micro-app-slot",
activeRule: "/",
},
]);
start();
現在執行 npm run start
可以看到我們子應用的內容,如果你為loading圖示新增了樣式,loading圖示還在轉?我們還需要完善。
自定義註冊微應用
新建 microAppsConfig.ts
。因為用了ts,這裡我們先定義一下型別。src/core/interface.d.ts
export type ApplicationActiveRule = string | string[];
export type ContainerSlot =
| "#sidebar-slot"
| "#navbar-slot"
| "#micro-app-slot"
export interface MicroApplication {
name: string;
entry: string;
container: ContainerSlot;
activeRule: ApplicationActiveRule;
inactiveRule?: ApplicationActiveRule;
basename: string;
path?: string;
noAuth?: boolean;
critical?: boolean;
}
export interface MicroPages {
loginApp: string;
notFoundApp: string;
notAllowAccessApp: string;
apps: MicroApplication[];
}
microAppsConfig.ts
內容如下:
import { MicroApplication } from "../core/interface";
export const mainApps: MicroApplication[] = [
{
name: "navbar",
entry: "http://localhost:3001",
container: "#navbar-slot" as const,
activeRule: "/",
inactiveRule: ["/login", "/404", "/forgot-password"],
basename: "/",
},
{
name: "sidebar",
entry: "http://localhost:3002",
container: "#sidebar-slot" as const,
activeRule: "/",
inactiveRule: ["/login", "/404", "/401", "/forgot-password"],
basename: "/",
critical: true,
},
{
name: "login",
entry: "http://localhost:3000",
container: "#micro-app-slot" as const,
activeRule: ["/login", "/forgot-password"],
basename: "/",
path: "/login",
noAuth: true,
},
{
name: "404",
entry: "/pages/404/index.html",
container: "#micro-app-slot" as const,
activeRule: "/404",
basename: "/404/",
path: "/404",
noAuth: true,
},
{
name: "401",
entry: "/pages/401/index.html",
container: "#micro-app-slot" as const,
activeRule: "/401",
basename: "/401/",
path: "/401",
noAuth: true,
},
];
export const microAppsConfig = {
loginApp: "login",
notFoundApp: "404",
notAllowAccessApp: "401",
apps: [
...mainApps,
{
name: "dashboard",
entry: "http://localhost:3003",
container: "#micro-app-slot" as const,
activeRule: "/dashboard",
basename: "/dashboard/",
},
],
};
export default microAppsConfig;
對內容做一些解釋
loginApp: "foo" # 用於登陸的app 名字
notFoundApp: "404" # 當沒有當前路徑沒有任何app匹配時跳轉到該app
defaultApp: "foo" # 當訪問根路徑時,會跳轉到該app
apps:
- name: "foo" # 應用名字,最好不要包含空格,還有各種奇怪的字元,全域性唯一
entry: "/subapps/foo/index.html" # 應用入口,可以為一個完整URL,只支援絕對路徑
container: "#sidebar-slot" # 應用掛載位置 "sidebar-slot" | "navbar-slot" | "micro-app-slot"
activeRule: "/foo" # 支援string 或者 string[],當pathname 以rule開頭時,就認為該app是active的
inactiveRule: "/login" # 可選,支援string 或者 string[],當pathname 以rule開頭時,就認為該app是inactive的
basename: "/foo/" # 定義微應用的basename,一般與activeRule相同,需要以"/"結尾。對於需要使用根路徑做跳轉的應用,建議使用"/"作為basename。
path: “/foo" # 選填 string,當應用作為loginApp / notFoundApp / defaultApp 時,會跳轉到這個地址
noAuth: true # 選填 boolean,為true的話則表示沒有 token 依然能載入成功
critical: true # 選填 boolean,為true時表示該應用在啟動的時候就需要提前載入
主應用與子應用之間的通訊
這裡qiankun提供了initGlobalState
方法在主應用註冊定義全域性狀態,並返回通訊方法,子應用透過props
呼叫。
當然,我們需要注意的是當路由和登入使用者切換之後處理。
定義獲取當前使用者資訊的方法getUser
檔案,主要用於獲取使用者token以及其他的使用者資訊。
export type User = {
username: string;
token: string
}
const getUser: () => Promise<User> = async () => {
// 可以在這裡呼叫使用者資訊介面
// todo
return { username: "test", token: "test_token" };
};
export default getUser;
定義專案全域性的state
// 定義
export interface GlobalState {
user: User | null;
refreshToken: () => Promise<string>;
}
const initGlobalState = (
initialState: Partial<GlobalState>,
apps: MicroApplication[]
) => {
const actions = qiankunInitGlobalState({
...initialState,
});
return actions;
};
export default initGlobalState;
定義全域性子應用狀態action store
import {
initGlobalState as qiankunInitGlobalState,
MicroAppStateActions,
} from "qiankun";
let _state: Parameters<typeof qiankunInitGlobalState>[0] = {};
let _stateChangeFns: Parameters<
MicroAppStateActions["onGlobalStateChange"]
>[0][] = [];
export const setOnGlobalStateChange = (
onGlobalStateChange: MicroAppStateActions["onGlobalStateChange"] | undefined
) => {
_stateChangeFns = [];
_state = {};
onGlobalStateChange((state, prevState) => {
_stateChangeFns.forEach((fn) => fn(state, prevState));
_state = state;
}, true);
};
export const addStateChangeListener = (
...[callback, fireImmediately]: Parameters<
MicroAppStateActions["onGlobalStateChange"]
>
) => {
_stateChangeFns.push(callback);
if (fireImmediately) {
callback(_state, _state);
}
};
路由或者使用者改變時,需要對重定向地址做處理,相應的demo,我們統一放在一個core
包下面
由於程式碼量的原因,完整程式碼放在gitee上,僅供參考。
在子應用中使用主應用註冊的state和回撥方法
以react專案為例, 透過props
傳遞給App元件
export async function bootstrap() {}
export async function mount(props: SubAppProps) {
ReactDOM.render(
<App
basename={props.basename}
subAppProps={props}
styledTarget={props.container}
/>,
props.container.querySelector(defaultRootSelector)
);
}
export async function unmount(props: SubAppProps) {
const ele = props.container.querySelector(defaultRootSelector);
ele && ReactDOM.unmountComponentAtNode(ele);
}
定義獲取全域性state的hook方法
interface UserInfo {
token: string | null;
username: string;
}
export interface GlobalState {
user: UserInfo | null;
refreshToken: (() => Promise<string>) | undefined;
}
export interface GlobalStateContext {
state: GlobalState;
setToken: (token: string | null) => void;
getToken: () => string | null;
}
const appGlobalContainer = createContainer<
GlobalStateContext,
Pick<SubAppProps, "onGlobalStateChange" | "setGlobalState">
>((initialState) => {
const [state, setState] = useState<GlobalState>({
user: null,
refreshToken: undefined,
});
const stateRef = useCurrent(state);
const { onGlobalStateChange, setGlobalState } = initialState!;
useEffect(() => {
onGlobalStateChange((state) => {
setState(state as GlobalState);
}, true);
}, [onGlobalStateChange]);
const setInnerAndGlobalState = useCallback(
(newState: Partial<GlobalState>) => {
setGlobalState({ ...stateRef.current, ...newState });
setState({ ...stateRef.current, ...newState });
},
[setState, stateRef, setGlobalState]
);
const setToken = useCallback(
(token: string | null) => {
const { username } = stateRef.current?.user || {};
setInnerAndGlobalState({
user: {
token,
username: username || "",
// permissionList,
},
});
},
[setInnerAndGlobalState, stateRef]
);
const getToken = useCallback(() => {
return stateRef.current.user?.token || null;
}, [stateRef]);
return {
state,
setToken,
getToken,
};
});
export const AppGlobalStateProvider = appGlobalContainer.Provider;
export const useAppGlobalState = appGlobalContainer.useContainer;
在頁面中使用
import { useAppGlobalState } from "context/appGlobalState";
import { useIntl } from "react-intl";
import { Line, PageWrap } from "components/styled.common";
export default function Home() {
const intl = useIntl();
const { state } = useAppGlobalState();
const routes = [
{
path: "/",
breadcrumbName: "首頁",
},
{
path: "",
breadcrumbName: "系統使用者",
},
];
return (
<PageWrap>
<h4>App Global State: {state.user?.username}</h4>
{/* <h3># Page operation update</h3> */}
</PageWrap>
);
}
將在頁面看到 App Global State: test
微前端子應用
這裡以React
專案來舉例子,相關的搭建React專案的經驗可以參考其他文章。這裡我們預設以create-react-app
生成了一個React專案,主要關注整合qiankun的部分。
因為qiankun+vite方式構建微應用還沒有完善的解決辦法,所以如果使用vue的話,暫時只有使用webpack構建的版本在配置上會簡單一點。
子應用qiankun的配置src/qiankun.ts
declare global {
interface Window {
__webpack_public_path__?: string;
__POWERED_BY_QIANKUN__?: boolean;
__INJECTED_PUBLIC_PATH_BY_QIANKUN__?: string;
__QIANKUN_DEVELOPMENT__?: boolean;
}
}
export type OnGlobalStateChangeCallback = (
state: Record<string, any>,
prevState: Record<string, any>
) => void;
export interface SubAppProps {
name: string;
basename: string;
container: HTMLElement;
onGlobalStateChange: (
callback: OnGlobalStateChangeCallback,
fireImmediately?: boolean
) => void;
setGlobalState: (state: Record<string, any>) => boolean;
}
src/public-path.ts
declare global {
interface Window {
__webpack_public_path__?: string;
__POWERED_BY_QIANKUN__?: boolean;
__INJECTED_PUBLIC_PATH_BY_QIANKUN__?: string;
__QIANKUN_DEVELOPMENT__?: boolean;
}
}
if (
window.__POWERED_BY_QIANKUN__ &&
window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
export {};
子應用單獨執行時的的處理src/renderDev.ts
import { OnGlobalStateChangeCallback, SubAppProps } from "./qiankun";
import { render as reactDomRender } from "react-dom";
import packageJson from "../package.json";
const createGlobalState = (initialGlobalState: Record<string, any>) => {
let globalState: Record<string, any> = initialGlobalState;
const callbacks: OnGlobalStateChangeCallback[] = [];
const onGlobalStateChange = (
callback: OnGlobalStateChangeCallback,
fireImmediately?: boolean
) => {
callbacks.push(callback);
if (fireImmediately) {
callback(globalState, globalState);
}
};
const setGlobalState = (newState: Record<string, any>) => {
const prevState = globalState;
globalState = newState;
callbacks.forEach((cb) => {
cb(globalState, prevState);
});
return true;
};
return {
onGlobalStateChange,
setGlobalState,
};
};
const renderDev = async (
App: React.FC<{ basename: string; subAppProps: SubAppProps }>,
rootSelector: string,
initialGlobalState: Record<string, any>
) => {
const basename = process.env.PUBLIC_URL || "/";
reactDomRender(
<App
basename={basename}
subAppProps={{
container: document.body,
name: packageJson.name,
basename: basename,
...createGlobalState(initialGlobalState),
}}
/>,
document.body.querySelector(rootSelector)
);
};
export default renderDev;
接著,在src/index.ts
檔案,定義qiankun的掛載生命週期,以及子應用獨立執行的判斷。
import "./public-path";
import ReactDOM from "react-dom";
import { SubAppProps } from "./qiankun";
import App from "./App";
import "./index.less";
const defaultRootSelector = "#root";
if (process.env.NODE_ENV === "development" && !window.__POWERED_BY_QIANKUN__) {
Promise.all([import("./renderDev")]).then(async ([{ default: render }]) => {
// 可以在這裡進行使用者介面的請求。
let user = {
username: "子應用dev環境使用者名稱",
token: "子應用dev環境使用者名稱token",
};
render(App, defaultRootSelector, {
user: user,
});
});
}
export async function bootstrap() {}
export async function mount(props: SubAppProps) {
ReactDOM.render(
<App
basename={props.basename}
subAppProps={props}
styledTarget={props.container}
/>,
props.container.querySelector(defaultRootSelector)
);
}
export async function unmount(props: SubAppProps) {
const ele = props.container.querySelector(defaultRootSelector);
ele && ReactDOM.unmountComponentAtNode(ele);
}
當我們執行npm run dev
,我們在頁面中得到的state.user?.username
為子dev環境使用者名稱
如何透過docker
部署。
qiankun微前端架構透過docker
映象部署方式:
docker
建立bridge net
:docker network create -d bridge --subnet 172.19.0.0/24 --gateway 172.19.0.1 mirc-qiankun-net
- 172.19.0.0
docker
建立的網路卡ip,可根據部署環境更改 - mirc-woody-net 建立的網路卡名稱
- 172.19.0.0
主應用:在
Dockerfile
配置docker
容器nginx
, 以便訪問子應用。example
FROM nginx VOLUME /tmp ENV LANG en_US.UTF-8 RUN echo "server { \ listen 80; \ #解決Router(mode: 'history')模式下,重新整理路由地址不能找到頁面的問題 \ location / { \ root /var/www/html/; \ index index.html index.htm; \ if (!-e \$request_filename) { \ rewrite ^(.*)\$ /index.html?s=\$1 last; \ break; \ } \ } \ location /system-login/ { \ proxy_pass http://172.19.0.3;\ proxy_set_header Host \$host; \ } \ location /system-sidebar/ { \ proxy_pass http://172.19.0.4;\ proxy_set_header Host \$host; \ } \ location /system-navbar/ { \ proxy_pass http://172.19.0.5;\ proxy_set_header Host \$host; \ } \ location /system-setting/ { \ proxy_pass http://172.19.0.6;\ proxy_set_header Host \$host; \ } \ access_log /var/log/nginx/access.log; \ }" > /etc/nginx/conf.d/default.conf \ && mkdir -p /var/www \ && mkdir -p /var/www/html ADD dist/ /var/www/html/ EXPOSE 80 EXPOSE 443
其中,
location
配置的是微前端主應用註冊子應用的entry
入口。
##### 註冊子應用示例{ name: "login", entry: "/system-login/", container: "#micro-app-slot" as const, activeRule: "/login", basename: "/login", path: "/login", noAuth: true, },
proxy_pass
配置的是子應用在docker
建立的閘道器內指定的ip訪問地址。?會講如何在子應用掛載docker閘道器ip
子應用(以當前子應用模版為例):
1:craco.config.js
的配置修改webpack: { configure: { output: { publicPath: process.env.NODE_ENV === "production" ? `/system-navbar/` : "/", library: `${packageName}-[name]`, libraryTarget: "umd", jsonpFunction: `webpackJsonp_${packageName}`, }, }, }
主要修改兩個地方,
publicPath
生產設定為主應用的entry
路徑,
library
最好設定為 註冊子應用時的name
。2: 確保
package.json
檔案的name
欄位值唯一,不與其他子應用衝突
3: 子應用Dcokerfile
FROM nginx VOLUME /tmp ENV LANG en_US.UTF-8 RUN echo "server { \ listen 80; \ #解決Router(mode: 'history')模式下,重新整理路由地址不能找到頁面的問題 \ location / { \ root /var/www/html/; \ index index.html index.htm; \ if (!-e \$request_filename) { \ rewrite ^(.*)\$ /index.html?s=\$1 last; \ break; \ } \ } \ access_log /var/log/nginx/access.log ; \ } " > /etc/nginx/conf.d/default.conf \ && mkdir -p /var/www \ && mkdir -p /var/www/html COPY ./build /var/www/html/system-navbar ADD build/ /var/www/html/ EXPOSE 80 EXPOSE 443
docker
命令
正常構建映象:
docker build -f Dockerfile -t platform-end:v1.0 . docker build -f Dockerfile -t mirc-sidebar:v1.0 . docker build -f Dockerfile -t mirc-navbar:v1.0 . docker build -f Dockerfile -t mirc-system-setting:v1.0 .
執行容器時,需要制定
docker
閘道器,以及對應的ip
, 指定的ip
即為主應用nginx
代理的ip
地址
docker run -d -p 8099:80 --net mirc-qiankun-net --ip 172.19.0.2 --name mirc-main platform-end:v1.0 docker run -d -p 9000:80 --net mirc-qiankun-net --ip 172.19.0.3 --name mirc-login mirc-woody-login:v1.0 docker run -d -p 9001:80 --net mirc-qiankun-net --ip 172.19.0.4 --name mirc-sidebar mirc-sidebar:v1.0 docker run -d -p 9002:80 --net mirc-qiankun-net --ip 172.19.0.5 --name mirc-navbar mirc-navbar:v1.0 docker run -d -p 9003:80 --net mirc-qiankun-net --ip 172.19.0.6 --name mirc-system-setting mirc-system-setting:v1.0
伺服器只需配置上述配置的主應用8099埠即可訪問整個專案。
demo倉庫(主應用)
如果你覺得有用的話,幫忙點個贊?。
source:
微前端qiankun+docker+nginx配合gitlab-ci/cd的自動化部署的實現
qiankun
create-react-app
antd