微前端技術預演

概念

微前端主要是借鑑微服務的概念。隨着單個項目愈來愈大,業務愈來愈複雜,維護和開發會變的愈來愈困難。微前端的目的是將將一個大型前端工程拆分紅若干個小型的前端工程,各個前端工程以前開發維護相互獨立,共同組成一個 Monolithjavascript

The term Micro Frontends first came up in ThoughtWorks Technology Radar at the end of 2016. It extends the concepts of micro services to the frontend world. The current trend is to build a feature-rich and powerful browser application, aka single page app, which sits on top of a micro service architecture. Over time the frontend layer, often developed by a separate team, grows and gets more difficult to maintain. That’s what we call a Frontend Monolith.css

The idea behind Micro Frontends is to think about a website or web app as a composition of features which are owned by independent teams. Each team has a distinct area of business or mission it cares about and specialises in. A team is cross functional and develops its features end-to-end, from database to user interface.html

微前端將傳統的前端工程按項目(業務)縱向展開:前端

Monolithic Frontends 傳統項目 java

Monolithic Frontends

Organisation in Verticals 縱向展開 react

Organisation in Verticals

爲何須要微前端

隨着時間的推移,前端層(一般由一個單獨的團隊開發)會增加,變得更難維護。微前端背後的想法是將網站或web應用程序看做是由獨立團隊擁有的功能組成的。每一個團隊都有其所關心和專門從事的不一樣業務或任務領域。團隊是跨職能的,從數據庫到用戶界面,端到端地開發其功能。webpack

微前端的實現,意味着對前端應用的拆分。拆分應用的目的,並不僅是爲了架構上好看,還爲了提高開發效率。 爲此,微前端帶來這麼一系列的好處:git

  • 應用自治。應用內只須要遵循本身統一的接口規範或者框架,以便於系統集成到一塊兒,相互之間是不存在依賴關係的。
  • 單一職責。每一個前端應用能夠只關注於本身所須要完成的功能。
  • 技術棧無關。你可使用 Angular 的同時,又可使用 React 和 Vue。

除此,它也有一系列的缺點:github

  • 應用的拆分基礎依賴於基礎設施的構建,一旦大量應用依賴於同一基礎設施,那麼維護變成了一個挑戰。
  • 拆分的粒度越小,便意味着架構變得複雜、維護成本變高。
  • 技術棧一旦多樣化,便意味着技術棧混亂。

落地

目前市面上並無很是完美的實踐方案,考慮到咱們的前端技術棧,目前的技術方案爲基於reactice-stark,拆分的項目爲 蜘蛛先生-用戶端個人學習 模塊。web

ice-stark 架構

效果

  • 原項目效果圖:
    原項目效果圖
  • 拆分後的主框架(只包含 Header 和 Footer):
    拆分後的主框架
  • 拆分後的子應用-個人學習模塊(根據業務邏輯所拆出來的模塊-個人學習):
    拆分後的子應用
  • 組合後的效果:
    組合後的效果

代碼片斷

簡單看一下主要的代碼片斷。

主框架

主框架內主要做用就是做爲全部子應用的承載和消息樞紐。

入口

入口並無明顯改動。

import React from 'react';
import ReactDOM from 'react-dom';

import Router from './router';

import '@alifd/next/reset.scss';
import "./global.scss";

const ICE_CONTAINER = document.getElementById('ice-container');

if (!ICE_CONTAINER) {
  throw new Error('當前頁面不存在 <div id="ice-container"></div> 節點.');
}

ReactDOM.render(
  <Router />, ICE_CONTAINER ); 複製代碼

路由

引入子應用的路由須要用到 ice-stark 的路由模塊 AppRouterApproute

import React from 'react';
import { AppRouter, AppRoute } from '@ice/stark';

import NotFoundPage from '@/pages/Exception/NotFound';
import ServerErrorPage from '@/pages/Exception/ServerError';

export default function BasicPage({ setPathname }) {
  return (
    <AppRouter NotFoundComponent={NotFoundPage} ErrorComponent={ServerErrorPage} onRouteChange={pathname => { setPathname(pathname); }} > <AppRoute path={['/myLearn']} basename="/myLearn" title="個人學習| 知蛛先生" url={[ '//zhizhutest.xx.mocks.taobao.com:4445/js/index.js', '//zhizhutest.xx.mocks.taobao.com:4445/css/index.css', ]} /> </AppRouter> ); } 複製代碼

子應用

入口

能夠看到,子應用的渲染根節點須要調用 getMountNode 函數得到;

import ReactDOM from 'react-dom';
import { getMountNode } from '@ice/stark';
import { Provider } from 'react-redux';

import store from '@/store';
import Router from '@/router';

import '@alifd/next/reset.scss';
import "./global.scss";

ReactDOM.render(
  <Provider store={store}> <Router /> </Provider>
  , getMountNode());
複製代碼

路由

路由模塊和普通項目的路由同樣,使用 react-router-dom 的路由模塊;

import { BrowserRouter as Router, Switch, Route, Redirect } from 'react-router-dom';
import { getBasename } from '@ice/stark';
import React from 'react';

import Layout from '@/layouts';

export default class router extends React.Component {
  render() {
    return (
      <Router basename={getBasename()}> <Switch> <Route path="/" component={Layout} /> </Switch> </Router> ); } }; 複製代碼

源碼簡單分析

項目地址:ice-stark,如下看一些主要的點:

cache.js

export const setCache = (key: string, value: any): void => {
  if (!(window as any).ICESTARK) {
    (window as any).ICESTARK = {};
  }
  (window as any).ICESTARK[key] = value;
};

export const getCache = (key: string): any => {
  const icestark: any = (window as any).ICESTARK;
  return icestark && icestark[key] ? icestark[key] : null;
};
複製代碼

暴漏兩個方法,在 window 上的掛載了一個全局的 namespace:ICESTARK,看一下保存了哪些數據:

basename 和 root,basename 就是 react-router-dom 的 Router 的 basename,在 AppRouter 的生命週期中被賦值; root 就是子應用在主應用掛載的根節點,在 AppRoute 的生命週期中被賦值。

AppRoute.js

子應用的掛載處,render 方法返回容器 myRefBase

componentDidMount 生命週期中,首先經過 setCache 清空了 ICESTARK.root 的值,再調用 renderChild 方法。

setCache('root', null);
this.renderChild();
複製代碼

renderChild 主要步驟(拋開異常處理和狀態處理等):

  1. 調用 removeElementFromBase 方法,清空 myRefBase 下的子應用掛載節點;
  2. 調用 appendElementToBase 方法,建立子應用的容器節點(div),添加到 myRefBase 節點下,返回該節點,名爲 rootElement;
  3. 調用 setCache 方法,將 rootElement 添加至 ICESTARK.root;
  4. 調用 emptyAssets 方法,將 head 下的帶有特定屬性的 script 連接和 link 連接卸載;
  5. 調用 loadAssets 方法,將 url 下的連接裝載入 head 標籤下; renderChild 方法在 AppRoute 的 componentDidMountcomponentDidUpdate 生命週期內被調用,子應用的切換會先卸載上一個子應用所添加的 script 和 link;

getMountNode.js

子應用的渲染根節點和主框架並不同,子應用須要用 getMountNode 方法獲取節點:

import ReactDOM from 'react-dom';
import { getMountNode } from '@ice/stark';
import { Provider } from 'react-redux';

import store from '@/store';
import Router from '@/router';

import '@alifd/next/reset.scss';
import "./global.scss";

ReactDOM.render(
  <Provider store={store}> <Router /> </Provider>
  , getMountNode());
複製代碼

getMountNode 方法返回了 window.ICESTARK.root,這個值在 AppRoute 組件中的 renderChild 方法中被初始化賦值 setCache('root', rootElement)rootElement 的值爲 AppRoute 組件的方法 appendElementToBase 的返回值 document.createElement('div'),這個 element 的 id 被賦值爲 AppRoute 的 props.rootId,默認值爲 icestarkNode,這個 element 被 append 進 AppRoute 的容器中 render = () => <div ref={el => this.myRefBase = el} />

handleAssets.js

主要暴露的方法爲:recordAssetsloadAssetloadAssetsemptyAssets:

function recordAssets

該方法在 AppRouter 實例化的時被調用:

記錄下在子應用被引入以前,此時頁面上全部的 style,link,script 標籤。記錄方式爲:

style.setAttribute(PREFIX, STATIC);
複製代碼

PREFIX 和 STATIC 在 constant.ts 文件中給定默認值:

export const PREFIX = 'icestark';

export const DYNAMIC = 'dynamic';

export const STATIC = 'static';

export const ICESTSRK_NOT_FOUND = `/${PREFIX}_404`;
複製代碼

function loadAsset

根據參數 isCss 來判斷 type 爲 script 或 link,以 if...else 的邏輯來判斷標籤類型。 id 爲 icestark-(js/css)-[index] 的形式,手動設置屬性 icestark 的值爲 dynamic,基本屬性以下:

const element: HTMLScriptElement | HTMLLinkElement | HTMLElement = document.createElement(type);
// ...
  if (isCss) {
    (element as HTMLLinkElement).rel = 'stylesheet';
    (element as HTMLLinkElement).href = url;
  } else {
    (element as HTMLScriptElement).type = 'text/javascript';
    (element as HTMLScriptElement).src = url;
    (element as HTMLScriptElement).async = false;
  }
複製代碼

最後直接添加到 root 下,root 爲 loadAsset 方法的參數:

root.appendChild(element);
複製代碼

function loadAssets

該方法在 AppRoute 的 renderChild 中被調用,參數以下:

loadAssets(
  bundleList: string[],
  useShadow: boolean,
  jsCallback: (err: any) => boolean,
  cssCallBack: () => void,
)
複製代碼

bundleList 在 renderChild 中:

const bundleList: string[] = Array.isArray(url) ? url : [url];
複製代碼

loadAssets 首先獲取當前的 jsRoot 和 cssRoot,在不啓用 useShadow 的請框架均爲 head 標籤。bundleList 會被 forEach,根據 urlItem 的後綴 push 到 jsList 和 cssList,loadAssets 有兩個內置方法:loadJs 和 loadCss,這兩個方法會遍歷 jsList 和 cssList,調用 loadAsset 方法;

function emptyAssets

該方法會將 :

  • head 標籤下(css 依然要根據 useShadow 判斷),script 和 link 屬性 ${PREFIX}=${DYNAMIC}
  • 文檔內不在記錄中的(由 recordAssets 方法標記的) style、link、script 標籤所有移除;

AppRouter.js

private originalPush: OriginalStateFunction = window.history.pushState;

private originalReplace: OriginalStateFunction = window.history.replaceState;
複製代碼

componentDidMount 中調用 hijackHistory 方法,劫持 history 的 pushState 和 replaceState,調用 this.handleStateChange:

hijackHistory = (): void => {
    window.history.pushState = (state: any, title: string, url?: string, ...rest) => {
      this.originalPush.apply(window.history, [state, title, url, ...rest]);
      this.handleStateChange(state, url, 'pushState');
    };

    window.history.replaceState = (state: any, title: string, url?: string, ...rest) => {
      this.originalReplace.apply(window.history, [state, title, url, ...rest]);
      this.handleStateChange(state, url, 'replaceState');
    };

    window.addEventListener('popstate', this.handlePopState, false);
  };
複製代碼

this.handleStateChange 調用了 this.handleRouteChange,this.handleRouteChange 調用了 this.props.onRouteChange。 render 方法中,手動實現了一個路由匹配:

let match: any = null;
let element: any;

React.Children.forEach(children, child => {
  if (match == null && React.isValidElement(child)) {
    element = child;

    const { path } = child.props as any;

    match = path ? matchPath(pathname, { ...child.props }) : null;
  }
});
複製代碼

matchPath 的 匹配邏輯,未匹配會返回null。render 的返回值:

if (match) {
      const { path, basename } = element.props as any;

      setCache('basename', basename || (Array.isArray(path) ? path[0] : path));

      realComponent = React.cloneElement(element, extraProps);
    } else {
      realComponent = (
        <AppRoute
          path={ICESTSRK_NOT_FOUND}
          url={ICESTSRK_NOT_FOUND}
          NotFoundComponent={NotFoundComponent}
          useShadow={useShadow}
        />
      );
    }
    return realComponent;
複製代碼

問題

子應用的路由使用 react 的 async 函數和 webpack 的 import 函數進行 code-split 後的主框架對子應用的 bundle 加載失敗

緣由:查看異常,由於

子應用的 bundle 是從當前目錄下直接引入致使 404 異常,而在使用code-split時,webpack_require__.e 內內會根據 publicpath 手動建立 script 標籤,子應用會把本身的publicpath做爲前綴,拼成src/href:

var href = "css/" + ({}[chunkId]||chunkId) + ".css";
var fullhref = __webpack_require__.p + href; // __webpack_require__.p 就是 publicpath
複製代碼

解決:修改子應用的publicPath:

注意,ice-config 把 publicPath 分爲了兩種:

onRouteChange 默認值

  • 這裏有個問題
    onRouteChange 爲可選參數,可是
    該函數被調用時沒有判空。

如何避免相同的包被重複引入

目前能提出的解決方案是:

  • 第三方包用 webpack 的 externals 將包從 build 中剔除,主框架從 cdn 引入;
  • 公用組件發佈 npm包;

主框架與子應用和子應用之間的消息傳遞

其實這個方法就不少:

Css 預編譯文件如何處理?(子應用間和子應用與主框架間)

預編譯文件對 bundle 的體積其實沒有什麼影響,主要是同步問題,並且 sass-resources-loader 並不支持引入線上資源:

目前只能在各個項目之間手動同步。

總結

以上爲此次微前端實踐的一點總結,裏面還有不少不成熟和待完善的地方,但願能拋磚引玉,給你們帶來更多的思路和探索。

參考

備註

第一次寫文章,若是有不對或者很差的地方請你們指正!

相關文章
相關標籤/搜索