React App Framework - 1. Base

背景

在一些大型前端项目内, 需要把多个应用组合起来, 避免多次跳转和减少系统的交互成本, 以前常见的方式是按同一个模板开发, 然后用iframe组合起来, 能实现较高的隔离性, 但是开发/维护/体验都不算舒适, 最近开发了一个基于react+ts实现的框架, 实现了开发/体验/运维都比较好的效果, 会逐渐将一些重点内容记录下来

框架开发

基础组件

应用容器

最先要实现的就是底层的应用容器app-container了, 有几个基本要素:

  1. 根据不同url路由到不同的子应用
  2. 每个子应用隔离开发并支持动态加载
  3. 子应用统一风格, 可以选择是否支持切换项目
  4. 子应用的状态管理隔离
  5. 用户基础信息由外层统一提供

这几个中难度比较大的是子应用的切换项目和状态管理, 按难度排序给出主要实现

子应用路由

主要原理是通过react-router结合React.Suspense实现, 首先实现root的切换组件, 然后实现动态加载app的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// root 切换组件
// root.root-app-container.tsx

const RootAppContainer: React.FC = () => {
return (
<main className='container flex-auto overflow-hidden flex-column'>
<Switch>
<Route
exact
path='/'
render={(props) => {
return (
<RouteLazyComponent
app='home'
isApp
project={null}
title={CONSTANT.ALL_APPS.home.title}
import={import(/* webpackChunkName: "home" */ `./../../home`)}
routeProps={props}
/>
);
}}
/>
{_.map(CONSTANT.APPS, ({ name, title, hasProject }, app: AppOptions) => (
<Route
key={app}
path={`/${app}`}
render={(props) => {
currentApp !== app && dispatch(globalConfigDucks.changeApp(app));
return hasProject ? (
<RootAppProjectContainer app={app} title={title} routeProps={props} />
) : (
<RouteLazyComponent
app={app}
isApp
project={null}
title={title}
import={import(/* webpackChunkName: "[request]" */ `./../../../containers/${app}`)}
routeProps={props}
/>
);
}}
/>
))}
<Route render={() => <Redirect to='/' />} />
</Switch>
</main>
);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 动态加载app组件
// RouteLazyComponent

const RouteLazyComponent: React.FC<{
app: AllAppOptions;
isApp: boolean;
path?: string;
project?: Project;
title: string;
import: Promise<{ default: React.ComponentType<any> }>;
routeProps: RouteComponentProps<any, StaticContext, any>;
}> = ({ app, path, isApp, title, project, import: _import, routeProps }) => {
const { t } = useTranslation();
const componentCache = useRef<{ [key: string]: React.LazyExoticComponent<any> }>({});
const key = isApp ? app : path;
const LazyComponent = (componentCache.current[key] = componentCache.current[key] || lazy(() => _import));
const App = CONSTANT.ALL_APPS[app];
useMemoCondition(
() => {
if (isApp) {
document.title = title;
return;
}
if (App.hasProject && project) {
document.title = `${app}:${title} - ${project.code} - ${App.title}`;
return;
}
document.title = `${app}:${title} - ${App.title}`;
return;
},
[title, project],
([title, project]) => !!title || !!project,
);
const _project = useMemo(() => project, [project && project.code, project && project.name]);
return (
<Suspense fallback={<Loading loading appendToBody />}>
<LazyComponent {...routeProps} app={app} {...(_project ? { project: _project } : {})} />
</Suspense>
);
};

子应用统一菜单

由于侧边菜单和页面header部分需要保持统一风格, 实现通用的子应用加载组件, 并且支持组件内页面的动态加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 子应用容器组件
// sub-app.tsx

const SubApp: React.FC<{
app: AllAppOptions,
menu: SubAppMenu,
translation: Translation,
AppIcon: React.FC,
reducers?: ReducersMapObject,
preloadedState?: any,
store?: Store<{}, Action>,
}> = ({ app, menu, children, reducers, preloadedState, store, translation, AppIcon }) => {
const [menuFoldState, setMenuFoldState] = useState(false);
const match = useMatch();
return (
<div className={classNames('sub-app flex-auto overflow-hidden flex-row position-relative', app)}>
<AppLeftMenu fold={menuFoldState} setFold={setMenuFoldState} menu={menu} app={app} match={match} AppIcon={AppIcon} />
<AppMain fold={menuFoldState} menu={menu} app={app} match={match}>
{children}
</AppMain>
</div>
);
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 子应用页面加载组件
// sub-app-main.tsx

const AppMain: React.FC<{
app: AllAppOptions;
fold: boolean;
menu: SubAppMenu;
match: match;
}> = ({ app, fold, menu, match }) => {
return (
<>
<div className={classNames('app-main flex-column', { 'menu-fold': fold })}>
<Switch>
{menu.map((p) => {
const pc = p as SubAppMenuParentHasChildren;
const pi = p as SubAppMenuParentItem;
return _.size(pc.children) > 0 ? (
pc.children.map((c) => (
<Route
exact
key={c.name}
path={`${match.url}${c.path}`}
render={(props) => (
<RouteLazyComponent
app={app}
isApp={false}
path={c.component_name}
title={c.title}
import={import(/* webpackChunkName: "[request]" */ `./../../containers/${app}/containers/${c.component_name}`)}
routeProps={props}
/>
)}
/>
))
) : (
<Route
exact
key={p.name}
path={`${match.url}${pi.path}`}
render={(props) => (
<RouteLazyComponent
app={app}
isApp={false}
path={pi.component_name}
title={p.title}
import={import(/* webpackChunkName: "[request]" */ `./../../containers/${app}/containers/${pi.component_name}`)}
routeProps={props}
/>
)}
/>
);
})}
{_.size(menu) && <Route render={() => <Redirect to={`${match.url}`} />} />}
</Switch>
</div>
</>
);
};

git-submodule

子应用需要隔离开发互不影响, 最好的方式就是各自一个独立的repo, 但是完全独立后又没有一个很好的办法统一管理和开发, 于是使用submodule来实现开发和版本隔离, 基础框架不需要考虑各子应用的分支版本细节, 只有在发布的时候子应用在基础框架repo中加上对应ref的提交即可, 在gitlab-ci中加上GIT_SUBMODULE_STRATEGY: recursive, 就可以实现基础框架打包完整系统部署了

同时在子模块的ci中加上获取全局框架并打包的支持, 就可以实现子模块分离打包和部署各自的测试环境了

1
2
# 添加子模块
git submodule add --name 'src/containers/${app}' ../${app}-front.git src/containers/${app}
1
2
3
4
5
# gitlab-ci.yml
- git clone -b ${PARENT_BRANCH} --recurse-submodules https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/web-group/front-base
- ln -s /home/web/node_modules /home/web/front-base/node_modules
- rm -r /home/web/front-base/src/containers/${APPNAME}
- cp -r ${CI_PROJECT_DIR}/ /home/web/front-base/src/containers/${APPNAME}

小结

到此为止实现了基础框架的加载, 统一头部和侧边菜单, 动态加载子应用和子应用页面, 以及各子应用的独立开发环境与统一打包部署的功能, 子应用的状态分离管理下期继续

作者

Mosby

发布于

2019-08-29

许可协议

评论