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

前言

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

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

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

因此和市面上不少前端團隊引入微前端的目的不一樣的是,咱們是,而更多的團隊是。因此本方案適合和我目的一致的前端團隊,將本身維護的巨嬰系統瓦解,而後經過微前端"框架"來聚合,下降項目管理難度,提高開發體驗與業務使用體驗。react

巨嬰系統技術棧: Dva + Antdwebpack

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

在作這個項目的按需提早加載設計時,本身去深究過webpack構建出的項目代碼運行邏輯,收穫比較多:webpack 打包的代碼怎麼在瀏覽器跑起來的?, 不瞭解的能夠看看github

方案設計

基於業務角色,咱們將巨嬰系統拆成了一個基座系統和四個子系統(能夠按需擴展子系統),以下圖所示:web

20200528165839

基座系統除了提供基座功能,即系統的登陸、權限獲取、子系統的加載、公共組件共享、公共庫的共享,還提供了一個基本全部業務人員都會使用的業務功能:用戶授(guan)信(li)。json

子系統以靜態資源的方式,提供一個註冊函數,函數返回值是一個Switch包裹的組件與子系統全部的models。segmentfault

路由設計

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

// 子系統路由匹配,僞代碼
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地址

相關文章
相關標籤/搜索