微前端项目实战整理(single-spa+react+iframe)(二)

1,745 阅读6分钟

之前简单整理了整个项目的架构和基座应用的主要模块实现思路,这次主要是讲一下子应用的主要模块实现思路

入口文件

我们平时开发的react单页应用,入口文件就是通过ReactDOM.render把入口的组件页面渲染到对应的dom节点中,而在single-spa的子应用中,不再需要通过ReactDOM.render来渲染组件,而是通过对应的辅助库,来给单页应用加上single-spa的生命周期,比如我们子应用使用react开发,所以我们使用single-spa-react在入口文件对应用进行包装,代码如下:

import React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import App from "./App";

function domElementGetter() {
  let el = document.getElementById("login");
  if (!el) {
    el = document.createElement("div");
    el.id = "login";
    document.body.append(el);
  }

  return el;
}

const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: App,
  domElementGetter,
});

export function bootstrap(props) {
  return reactLifecycles.bootstrap(props);
}

export function mount(props) {
  return reactLifecycles.mount(props);
}

export function unmount(props) {
  return reactLifecycles.unmount(props);
}

以上就是一个最简单的single-spa子应用的打包入口文件,并没有增加什么功能,只是最简单的给react单页应用加上了single-spa的生命周期,因此不管是新开发的react应用还是老的react代码改为single-spa子应用,我们都只需要把webpack打包的入口文件改为此种形式即可,其他页面都按正常react单页应用开发无需任何更改。

数据仓库

上一篇文章里提到了全局状态仓库的解决思路,这里讲一下各子应用里对于各自store的导出和全局状态的使用,首先是一个很经典的应用场景:
login子应用里我们集成的是登录注册相关的页面逻辑,那相应的登录状态就需要存储在loginstore中,而其他子应用如show中部分页面需要用户登录之后有用户身份才可以访问,或者有些行为例如点赞评论等,同样需要有用户身份。
所以我们的需求为:

  • login子应用需要集成redux,并且需要有登录注册等对应的actionreducer
  • login子应用需要将自己的store导出并加载进基座应用,整合为一个全局的store
  • show子应用中需要调用login子应用中store里面的state需求一:就是常规的redux的实现不必赘述。
    需求二:即在webpack中配置多入口打包,将store.js文档打包导出,基座应用中的导入和整合上一篇已经讲过,不再赘述。
    需求三:因为我们在注册single-spa应用的时候,把整合好的全局状态仓库对象通过props传给了各个子应用,看起来似乎我们直接从props里面拿我们需要的方法和状态就好了,但是这里有一个问题,因为我们传过来的globalEventDistributor对象是一个层级很深的对象,我们需要通过自己封装的getState方法来获取状态仓库当前的状态,通过自己封装的dispatch来触发各个store中对应的action,这里就牵扯到了一个react组件更新时深浅对比的问题,即globalEventDistributor里状态更新组件不会更新,怎么解决这个问题呢?这里我自然的就想到了react-redux里面的Provider组件和connect组件。所以我们需要实现一个自己的Providerconnect,react-redux实现这两个组件的核心逻辑是reactContext,这里不过多赘述,直接上代码:
/*provider.tsx*/
import React, { useEffect, useState, createContext } from "react";

export const Store = createContext(null);
const Provider = (props) => {
  const { store, children } = props;
  const [state, setState] = useState(store.getState());
  useEffect(() => {
    store.subscribe((value) => {
      setState(value);
    });
  }, []);
  return <Store.Provider value={{ state, store }}>{children}</Store.Provider>;
};
export default Provider;

Provider的原理就是创建store的监听,在状态变化时更新组件内的statestate更新时组件更新,这样就做到了Context里面的值一直是最新的

/*connect.tsx*/
import React from "react";
import { Store } from "./provider";

const connect = (mapStateToProps) => {
  return (WrappedComponent) => {
    return (props) => {
      console.log(props);
      return (
        <Store.Consumer>
          {({ state, store }) => {
            return (
              <WrappedComponent
                {...mapStateToProps(state, props)}
                dispatch={(args) => {
                  store.dispatch.call(store, args);
                }}
                {...props}
              />
            );
          }}
        </Store.Consumer>
      );
    };
  };
};
export default connect;

通过Consumer可以拿到Context里面的值,把值作为props绑定到子组件上,这样就实现了connect的功能,这里没有实现mapDispatchToProps,只是简单的拿到dispatch方法使用,这里之所以要用call不是直接把store.dispatch穿进去,是因为jsthis的特性,直接传在子组件调用dispatch方法的话,this指向是window,如果有需要实现mapDispatchToProps的需求可以自己实现,这里我没需求就不再实现了。
上面我们实现了适用于single-spa子应用的Providerconnect,就可以像普通单页应用使用redux那样开发single-spa的子应用了。

路由控制

因为我们需要一级路由来决定加载那个子应用,所以子应用内部的路由应该都为二级或更高层级的路由,这里拿还是show应用举例:

const historySelf = createHistory();

const PrivateRoute = ({ children, islogin, ...rest }) => {
  return (
    <React.Fragment>
      {islogin ? (
        <Route {...rest}>{children}</Route>
      ) : (
        <Redirect
          to={{
            pathname: "/login/choose",
            state: { from: window.location.pathname },
          }}
        />
      )}
    </React.Fragment>
  );
};

const App = (props) => {
  const { history, loginReducer } = props;
  return (
    <React.Fragment>
      <BrowserRouter history={history || historySelf}>
        <Switch>
          <Route exact path="/show/post/:id">
            <PostShow />
          </Route>
          <Route exact path="/show/album/:id">
            <AlbumShow />
          </Route>
          <Route exact path="/show/video/:id">
            <VideoShow />
          </Route>
          <Route exact path="/show/videoplay/:id">
            <VideoPlay />
          </Route>
          <Route exact path="/show/user/:id">
            <UserShow />
          </Route>
          <Route exact path="/show/switch/:id">
            <PostSwitch />
          </Route>
          <PrivateRoute
            path="/show/userzone"
            islogin={
              loginReducer && loginReducer.userId
            }
          >
            <UserZone />
          </PrivateRoute>
        </Switch>
      </BrowserRouter>
    </React.Fragment>
  );
};
const BaseApp = connect((state) => {
  return { ...state };
})(App);

const WrapApp = ({ globalEventDistributor, history }) => {
  console.log(globalEventDistributor);
  return (
    <Provider store={globalEventDistributor}>
      <BaseApp history={history} />
    </Provider>
  );
};

export default WrapApp;

以上是show子应用的简略版代码,可以看到路由都是二级或更高级路由,并且有些路有可能必须登录后才能访问,为私有路由,所以我们实现了一个PrivateRoute组件,如果不满足访问条件会重定向到登录页面。

iframe的处理

上面我们看到了正常应用的路由控制以及Providerconnect组件的应用,因为我的项目特殊的地方在于,有大量的老旧页面并没有重新开发,所以我们有一个单独的iframe的子应用,通过路由控制把新项目中没有的路由重定向到iframe子应用,并通过iframe打开,这里路由的控制我是在基座应用里做的,代码如下:

const Router = () => {
  return (
    <BrowserRouter history={history}>
      <Switch>
        <Route path="/show"/>
        <Route path="/login" />
        <Route path="/create" />
        <Route path="/iframe" />
        <Redirect from="/zone/show/:id" to={`/show/user${window.location.pathname.split("/zone/show")[1]}`} />
        <Redirect from="/home" to={`/show/userzone`} />
        <Redirect from="/post/make" to={`/create/new`} />
        <Redirect from="/youth/post/show/:id" to={`/show/switch${window.location.pathname.split("/post/show")[1]}`} />
        <Redirect from="/post/show/:id" to={`/show/switch${window.location.pathname.split("/post/show")[1]}`} />
        <Redirect to={`/iframe${window.location.pathname}`} />
      </Switch>
    </BrowserRouter>
  )
}

上面可以看到,对于一些特殊的页面,即新项目中重做了,路由变得与老项目不一样的,但是在APP中分享出来的链接还是老路由连接,我做了特殊情况的重定向,然后如果多有RouteRedirect都没有匹配到当前路由,就会重定向到iframe子应用,在子应用中会做一些处理拿到当前href/iframe后面的地址,把对应完整的hrefiframe中打开,这就是iframe的全部处理逻辑,这样就做到了渐进开发渐进迁移,我们可以一部分一部分开发,然后没开发的新页面暂时用iframe引进老页面,这样就解决了体量庞大的老项目重构时的痛点,即工期长,开发过程中,还需对老项目进行日常的维护。
这里关于iframe有一些需要优化的地方,一个是iframe标签最好动态加载,即拿到要加载的页面之后再异步的把iframe加载进去,还有一个是IOS中iframe里面的滑动会失效,需要做一些特殊的处理,代码如下:

//iframe渲染位置:
<div className="App" ref={iframeRef}></div>

//动态加载时,ios和其他系统不用的属性设置如下:
iframeRef.current.innerHTML = 
isiOS 
? 
`<iframe 
  scrolling="no"
  class="iframe"
  frameBorder="0"
  title="wraphtml"
  id="pageIframe"
  src=${iframeUrl}
></iframe>`
: 
`<iframe 
  scrolling="yes"
  class="iframe"
  frameBorder="0" 
  title="wraphtml" 
  id="pageIframe" 
  src=${iframeUrl}
></iframe>`;

//外层div样式
.App {
  text-align: center;
  max-width: 557px;
  overflow: auto;
  -webkit-overflow-scrolling:touch;
  width:100%;
}

小结

以上就是子应用开发时一些对应的点的总结,至此基座应用和子应用的框架以及开发的细节都已经讲完了,后面会继续分享关于打包部署相关的问题~