qiankun微前端從搭建到部署大型踩坑記錄片(一鏡到底)

蹦擦擦發表於2023-03-08

前言
近兩年一直會有遇到需要微前端框架的需求,同時在招聘上,微前端的需求也是挺多的,最近整理了一下之前經手過的幾個qiankun微前端專案,分享給大家。

專案結構預覽
image.png
image.png
前期準備工作

  1. 主應用的搭建、基座的配置。
  2. 子應用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
    1. 172.19.0.0 docker 建立的網路卡ip,可根據部署環境更改
    2. mirc-woody-net 建立的網路卡名稱
  • 主應用:在 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

相關文章