微前端技术预演

883 阅读8分钟

概念

微前端主要是借鉴微服务的概念。随着单个项目越来越大,业务越来越复杂,维护和开发会变的越来越困难。微前端的目的是将将一个大型前端工程拆分成若干个小型的前端工程,各个前端工程之前开发维护相互独立,共同组成一个 Monolith

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.

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.

微前端将传统的前端工程按项目(业务)纵向展开:

Monolithic Frontends 传统项目

Monolithic Frontends

Organisation in Verticals 纵向展开

Organisation in Verticals

为什么需要微前端

随着时间的推移,前端层(通常由一个单独的团队开发)会增长,变得更难维护。微前端背后的想法是将网站或web应用程序看作是由独立团队拥有的功能组成的。每个团队都有其所关心和专门从事的不同业务或任务领域。团队是跨职能的,从数据库到用户界面,端到端地开发其功能。

微前端的实现,意味着对前端应用的拆分。拆分应用的目的,并不只是为了架构上好看,还为了提升开发效率。 为此,微前端带来这么一系列的好处:

  • 应用自治。应用内只需要遵循自己统一的接口规范或者框架,以便于系统集成到一起,相互之间是不存在依赖关系的。
  • 单一职责。每个前端应用可以只关注于自己所需要完成的功能。
  • 技术栈无关。你可以使用 Angular 的同时,又可以使用 React 和 Vue。

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

  • 应用的拆分基础依赖于基础设施的构建,一旦大量应用依赖于同一基础设施,那么维护变成了一个挑战。
  • 拆分的粒度越小,便意味着架构变得复杂、维护成本变高。
  • 技术栈一旦多样化,便意味着技术栈混乱。

落地

目前市面上并没有非常完美的实践方案,考虑到我们的前端技术栈,目前的技术方案为基于reactice-stark,拆分的项目为 蜘蛛先生-用户端我的学习 模块。

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 并不支持引入线上资源:

目前只能在各个项目之间手动同步。

总结

以上为这次微前端实践的一点总结,里面还有很多不成熟和待完善的地方,希望能抛砖引玉,给大家带来更多的思路和探索。

参考

备注

第一次写文章,如果有不对或者不好的地方请大家指正!