也許這才是你想要的微前端方案

Denzel發表於2020-06-01

前言

微前端是當下的前端熱詞,稍具規模的團隊都會去做技術探索,作為一個不甘落後的團隊,我們也去做了。也許你看過了Single-Spaqiankun這些業界成熟方案,非常強大:JS沙箱隔離、多棧支援、子應用並行、子應用巢狀,但仔細想想它真的適合你嗎?

對於我來說,太重了,概念太多,理解困難。先說一下背景,我們之所以要對我司的小貸管理後臺做微前端改造,主要基於以下幾個述求:

  • 系統從接手時差不多30個頁面,一年多時間,發展到目前150多個頁面,並還在持續增長;
  • 專案體積變大,帶來開發體驗很差,打包構建速度很慢(初次構建,1分鐘以上);
  • 小貸系統開發量佔整個web組50%的人力,每個迭代都有兩三個需求在這一個系統上開發,程式碼合併衝突,上線時間交叉。帶來的是開發流程管理複雜;
  • 業務人員是分類的,沒有誰會用到所有的功能,每個業務人員只擁有其中30%甚至更少的功能。但不得不載入所有業務程式碼,才能看到自己想要的頁面;

所以和市面上很多前端團隊引入微前端的目的不同的是,我們是,而更多的團隊是。所以本方案適合和我目的一致的前端團隊,將自己維護的巨嬰系統瓦解,然後通過微前端"框架"來聚合,降低專案管理難度,提升開發體驗與業務使用體驗。

巨嬰系統技術棧: Dva + Antd

方案參考美團一篇文章:微前端在美團外賣的實踐

在做這個專案的按需提前載入設計時,自己去深究過webpack構建出的專案程式碼執行邏輯,收穫比較多:webpack 打包的程式碼怎麼在瀏覽器跑起來的?, 不瞭解的可以看看

方案設計

基於業務角色,我們將巨嬰系統拆成了一個基座系統和四個子系統(可以按需擴充套件子系統),如下圖所示:

20200528165839

基座系統除了提供基座功能,即系統的登入、許可權獲取、子系統的載入、公共元件共享、公共庫的共享,還提供了一個基本所有業務人員都會使用的業務功能:使用者授(guan)信(li)。

子系統以靜態資源的方式,提供一個註冊函式,函式返回值是一個Switch包裹的元件與子系統所有的models。

路由設計

子系統以元件的形式載入到基座系統中,所以路由是入口,也是整個設計的第一步,為了區分基座系統頁面和子系統頁面,在路由上約定了下面這種形式:

// 子系統路由匹配,虛擬碼
function Layout(layoutProps) {
  useEffect(() => {
      const apps = getIncludeSubAppMap();
      // 按需載入子專案;
      apps.forEach(subKey => startAsyncSubapp(subKey));
  }, []);

  return (
    <HLayout {...props}>
      <Switch>
          {/* 企業使用者管理 */}
          <Route exact path={Paths.PRODUCT_WHITEBAR} component={pages.ProductManage} breadcrumbName="企業使用者管理" />
          {/* ...省略一百行 */}
          <Route path="/subPage/" component={pages.AsyncComponent} />
      </Switch>
    </HLayout>
}

即只要以subPage路徑開頭,就預設這個路由對應的元件為子專案,從而通過AsyncComponent元件去非同步獲取子專案元件。

非同步載入元件設計

路由設計完了,然後非同步載入元件就是這個方案的靈魂了,流程是這樣的:

  • 通過路由,匹配到要訪問的具體是那個子專案;
  • 通過子專案id,獲取對應的manifest.json檔案;
  • 通過獲取manifest.json,識別到對應的靜態資源(js,css)
  • 載入靜態資源,載入完,子專案執行註冊
  • 動態載入model,更新子專案元件

直接上程式碼吧,簡單明瞭,資源載入的邏輯後面再詳講,需要注意的是model和component的載入順序

export default function AsyncComponent({ location }) {
  // 子工程資源是否載入完成
  const [ayncLoading, setAyncLoaded] = useState(true);
  // 子工程元件載入存取
  const [ayncComponent, setAyncComponent] = useState(null);
  const { pathname } = location;
  // 取路徑中標識子工程字首的部分, 例如 '/subPage/xxx/home' 其中xxx即子系統路由標識
  const id = pathname.split('/')[2];
  useEffect(() => {
    if (!subAppMapInfo[id]) {
      // 不存在這個子系統,直接重定向到首頁去
      goBackToIndex();
    }
    const status = subAppRegisterStatus[id];
    if (status !== 'finish') {
      // 載入子專案
      loadAsyncSubapp(id).then(({ routes, models }) => {
        loadModule(id, models);
        setAyncComponent(routes);
        setAyncLoaded(false);
        // 已經載入過的,做個標記
        subAppRegisterStatus[id] = 'finish';
      }).catch((error = {}) => {
        // 如果載入失敗,顯示錯誤資訊
        setAyncLoaded(false);
        setAyncComponent(
          <div style={{
            margin: '100px auto',
            textAlign: 'center',
            color: 'red',
            fontSize: '20px'
          }}
          >
            {error.message || '載入失敗'}
          </div>);
      });
    } else {
      const models = subappModels[id];
      loadModule(id, models);
      // 如果能匹配上字首則載入相應子工程模組
      setAyncLoaded(false);
      setAyncComponent(subappRoutes[id]);
    }
  }, [id]);
  return (
    <Spin spinning={ayncLoading} style={{ width: '100%', minHeight: '100%' }}>
      {ayncComponent}
    </Spin>
  );
}

子專案設計

子專案以靜態資源的形式在基座專案中載入,需要暴露出子系統自己的全部頁面元件和資料model;然後在打包構建上和以前也稍許不同,需要多生成一個manifest.json來蒐集子專案的靜態資源資訊。

子專案暴露出自己自願的程式碼長這樣:

// 子專案資源輸出程式碼
import routes from './layouts';

const models = {};

function importAll(r) {
  r.keys().forEach(key => models[key] = r(key).default);
}

// 蒐集所有頁面的model
importAll(require.context('./pages', true, /model\.js$/));

function registerApp(dep) {
  return {
    routes, // 子工程路由元件
    models, // 子工程資料模型集合
  };
}

// 陣列第一個引數為子專案id,第二個引數為子專案模組獲取函式
(window["registerApp"] = window["registerApp"] || []).push(['collection', registerApp]);

子專案頁面元件蒐集:

import menus from 'configs/menus';
import { Switch, Redirect, Route } from 'react-router-dom';
import pages from 'pages';

function flattenMenu(menus) {
  const result = [];
  menus.forEach((menu) => {
    if (menu.children) {
      result.push(...flattenMenu(menu.children));
    } else {
      menu.Component = pages[menu.component];
      result.push(menu);
    }
  });
  return result;
}

// 子專案自己路徑分別 + /subpage/xxx 
const prefixRoutes = flattenMenu(menus);

export default (
  <Switch>
    {prefixRoutes.map(child =>
      <Route
        exact
        key={child.key}
        path={child.path}
        component={child.Component}
        breadcrumbName={child.title}
      />
    )}
    <Redirect to="/home" />
  </Switch>);

靜態資源載入邏輯設計

開始做方案時,只是設計出按需載入的互動體驗:即當業務切換到子專案路徑時,開始載入子專案的資源,然後渲染頁面。但後面感覺這種改動影響了業務體驗,他們以前只需要載入資料時loading,現在還需要承受子專案載入loading。所以為了讓業務儘量小的感知系統的重構,將按需載入換成了按需提前載入。簡單點說,就是當業務登入時,我們會去遍歷他的所有許可權選單,獲取他擁有那些子專案的訪問許可權,然後提前載入這些資源。

遍歷選單,提前載入子專案資源:

// 本地開發環境不提前按需載入
if (getDeployEnv() !== 'local') {
  const apps = getIncludeAppMap();
  // 按需提前載入子專案資源;
  apps.forEach(subKey => startAsyncSubapp(subKey));
}

然後就是show程式碼的時候了,思路參考webpackJsonp,就是通過攔截一個全域性陣列的push操作,得知子專案已載入完成:

import { subAppMapInfo } from './menus';

// 子專案靜態資源對映表存放:
/**
 * 狀態定義:
 * '': 還未載入
 * ‘start’:靜態資源對映表已存在;
 * ‘map’:靜態資源對映表已存在;
 * 'init': 靜態資源已載入;
 * 'wait': 資源載入已完成, 待注入;
 * 'finish': 模組已注入;
*/
export const subAppRegisterStatus = {};

export const subappSourceInfo = {};

// 專案載入待處理的Promise hash 表
const defferPromiseMap = {};

// 專案載入待處理的錯誤 hash 表
const errorInfoMap = {};

// 載入css,js 資源
function loadSingleSource(url) {
  // 此處省略了一寫程式碼
  return new Promise((resolove, reject) => {
    link.onload = () => {
      resolove(true);
    };
    link.onerror = () => {
      reject(false);
    };
  });
}

// 載入json中包含的所有靜態資源
async function loadSource(json) {
  const keys = Object.keys(json);
  const isOk = await Promise.all(keys.map(key => loadSingleSource(json[key])));

  if (!isOk || isOk.filter(res => res === true) < keys.length) {
    return false;
  }

  return true;
}

// 獲取子專案的json 資源資訊
async function getManifestJson(subKey) {
  const url = subAppMapInfo[subKey];
  if (subappSourceInfo[subKey]) {
    return subappSourceInfo[subKey];
  }

  const json = await fetch(url).then(response => response.json())
    .catch(() => false);

  subAppRegisterStatus[subKey] = 'map';
  return json;
}

// 子專案提前按需載入入口
export async function startAsyncSubapp(moduleName) {
  subAppRegisterStatus[moduleName] = 'start'; // 開始載入
  const json = await getManifestJson(moduleName);
  const [, reject] = defferPromiseMap[moduleName] || [];
  if (json === false) {
    subAppRegisterStatus[moduleName] = 'error';
    errorInfoMap[moduleName] = new Error(`模組:${moduleName}, manifest.json 載入錯誤`);
    reject && reject(errorInfoMap[moduleName]);
    return;
  }
  subAppRegisterStatus[moduleName] = 'map'; // json載入完畢
  const isOk = await loadSource(json);
  if (isOk) {
    subAppRegisterStatus[moduleName] = 'init';
    return;
  }
  errorInfoMap[moduleName] = new Error(`模組:${moduleName}, 靜態資源載入錯誤`);
  reject && reject(errorInfoMap[moduleName]);
  subAppRegisterStatus[moduleName] = 'error';
}

// 回撥處理
function checkDeps(moduleName) {
  if (!defferPromiseMap[moduleName]) {
    return;
  }
  // 存在待處理的,開始處理;
  const [resolove, reject] = defferPromiseMap[moduleName];
  const registerApp = subappSourceInfo[moduleName];

  try {
    const moduleExport = registerApp();
    resolove(moduleExport);
  } catch (e) {
    reject(e);
  } finally {
    // 從待處理中清理掉
    defferPromiseMap[moduleName] = null;
    subAppRegisterStatus[moduleName] = 'finish';
  }
}

// window.registerApp.push(['collection', registerApp])
// 這是子專案註冊的核心,靈感來源於webpack,即對window.registerApp的push操作進行攔截
export function initSubAppLoader() {
  window.registerApp = [];
  const originPush = window.registerApp.push.bind(window.registerApp);
  // eslint-disable-next-line no-use-before-define
  window.registerApp.push = registerPushCallback;
  function registerPushCallback(module = []) {
    const [moduleName, register] = module;
    subappSourceInfo[moduleName] = register;
    originPush(module);
    checkDeps(moduleName);
  }
}

// 按需提前載入入口
export function loadAsyncSubapp(moduleName) {
  const subAppInfo = subAppRegisterStatus[moduleName];

  // 錯誤處理優先
  if (subAppInfo === 'error') {
    const error = errorInfoMap[moduleName] || new Error(`模組:${moduleName}, 資源載入錯誤`);
    return Promise.reject(error);
  }

  // 已經提前載入,等待注入
  if (typeof subappSourceInfo[moduleName] === 'function') {
    return Promise.resolve(subappSourceInfo[moduleName]());
  }

  // 還未載入的,就開始載入,已經開始載入的,直接返回
  if (!subAppInfo) {
    startAsyncSubapp(moduleName);
  }

  return new Promise((resolve, reject = (error) => { throw error; }) => {
    // 加入待處理map中;
    defferPromiseMap[moduleName] = [resolve, reject];
  });
}

這裡需要強調一下子專案有兩種載入場景:

  • 從基座頁面路徑進入系統, 那麼就是按需提前載入的場景, 那麼startAsyncSubapp先執行,提前快取資源;
  • 從子專案頁面路徑進入系統, 那就是按需載入的場景,就存在loadAsyncSubapp先執行,利用Promise完成釋出訂閱。至於為什麼startAsyncSubapp在前但後執行,是因為useEffect是元件掛載完成才執行;

至此,框架的大致邏輯就交代清楚了,剩下的就是優化了。

其他難點

其實不難,只是怪我太菜,但這些點確實值得記錄,分享出來共勉。

公共依賴共享

我們由於基座專案與子專案技術棧一致,另外又是拆分系統,所以共享公共庫依賴,優化打包是一個特別重要的點,以為就是webpack配個external就完事,但其實要複雜的多。

antd 構建

antd 3.x就支援了esm,即按需引入,但由於我們構建工具沒有做相應升級,用了babel-plugin-import這個外掛,所以導致了兩個問題,打包冗餘與無法全量匯出antd Modules。分開來講:

  • 打包冗餘,就是通過BundleAnalyzer外掛發現,一個模組即打了commonJs程式碼,也打了Esm程式碼;
  • 無法全量匯出,因為基座專案不知道子專案會具體用哪個模組,所以只能暴力的匯出Antd所有模組,但babel-plugin-import這個外掛有個優化,會分析引入,然後刪除沒用的依賴,但我們的需求和它的目的是衝突的;

結論:使用babel-plugin-import這個外掛打包commonJs程式碼已經過時, 其存在的唯一價值就是還可以幫我們按需引入css 程式碼;

專案公共元件共享

專案中公共元件的共享,我們開始嘗試將常用的元件加入公司元件庫來解決,但發現這個方案並不是最理想的,第一:很多元件和業務場景強相關,加入公共元件庫,會造成元件庫臃腫;第二:沒有必要。所以我們最後還是採用了基座專案收集元件,並統一暴露:

function combineCommonComponent() {
 const contexts = require.context('./components/common', true, /\.js$/);
 return contexts.keys().reduce((next, key) => {
   // 合併components/common下的元件
   const compName = key.match(/\w+(?=\/index\.js)/)[0];
   next[compName] = contexts(key).default;
   return next;
 }, {});
}

webpackJsonp 全域性變數汙染

如果對webpack構建後的程式碼不熟悉,可以先看看開篇提到的那篇文章。

webpack構建時,在開發環境modules是一個物件,採用檔案path作為module的key; 而正式環境,modules是一個陣列,會採用index作為module的key。
由於我基座專案和子專案沒有做沙箱隔離,即window被公用,所以存在webpackJsonp全域性變數汙染的情況,在開發環境,這個汙染沒有被暴露,因為檔案Key是唯一的,但在打正式包時,發現qa 環境子專案無法載入,最後一分析,發現了window.webpackJsonp 環境變數汙染的bug。

最後解決的方案就是子專案打包都擁有自己獨立的webpackJsonp變數,即將webpackJsonp重新命名,寫了一個簡單的webpack外掛搞定:

// 將webpackJsonp 重新命名為 webpackJsonpCollect
config.plugins.push(new RenameWebpack({ replace: 'webpackJsonpCollect' }));

子專案開發熱載入

基座專案為什麼會成為基座,就因為他迭代少且穩定的特殊性。但開發時,由於子專案無法獨立執行,所以需要依賴基座專案聯調。但做一個需求,要開啟兩個vscode,同時執行兩個專案,對於那個開發,這都是一個不好的開發體驗,所以我們希望將dev環境作為基座,來支援本地的開發聯調,這才是最好的體驗。

將dev環境的構建引數改成開發環境後,發現子專案能線上上基座專案執行,但webSocket通訊一直失敗,最後找到原因是webpack-dev-sever有個host check邏輯,稱為主機檢查,是一個安全選項,我們這裡是可以確認的,所以直接註釋就行。

總結

這篇文章,本身就是個總結。如果有什麼疑惑或更好的建議,歡迎一起討論,issues地址

相關文章