之前简单整理了整个项目的架构和基座应用的主要模块实现思路,这次主要是讲一下子应用的主要模块实现思路
入口文件
我们平时开发的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子应用里我们集成的是登录注册相关的页面逻辑,那相应的登录状态就需要存储在login的store中,而其他子应用如show中部分页面需要用户登录之后有用户身份才可以访问,或者有些行为例如点赞评论等,同样需要有用户身份。
所以我们的需求为:
login子应用需要集成redux,并且需要有登录注册等对应的action和reducer。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组件。所以我们需要实现一个自己的Provider和connect,react-redux实现这两个组件的核心逻辑是react的Context,这里不过多赘述,直接上代码:
/*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的监听,在状态变化时更新组件内的state,state更新时组件更新,这样就做到了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穿进去,是因为js的this的特性,直接传在子组件调用dispatch方法的话,this指向是window,如果有需要实现mapDispatchToProps的需求可以自己实现,这里我没需求就不再实现了。
上面我们实现了适用于single-spa子应用的Provider和connect,就可以像普通单页应用使用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的处理
上面我们看到了正常应用的路由控制以及Provider和connect组件的应用,因为我的项目特殊的地方在于,有大量的老旧页面并没有重新开发,所以我们有一个单独的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中分享出来的链接还是老路由连接,我做了特殊情况的重定向,然后如果多有Route和Redirect都没有匹配到当前路由,就会重定向到iframe子应用,在子应用中会做一些处理拿到当前href里/iframe后面的地址,把对应完整的href在iframe中打开,这就是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%;
}
小结
以上就是子应用开发时一些对应的点的总结,至此基座应用和子应用的框架以及开发的细节都已经讲完了,后面会继续分享关于打包部署相关的问题~