序言

近几年大多数常规应用都是采用MPA、SPA的方式,对每个路由绑定对应规则,达到匹配路由规则渲染组件的能力。

最近在开发一个聊天类应用,简称XX,经常使用微信、钉钉的应该体验过其状态和数据缓存的能力,e.g. 和好友聊天内容输入一半,其他应用弹出一条消息或由于某些情况中止剩余内容的输入,切出了和该好友的对话界面甚至切出了XX应用,待回到和该好友聊天界面内,之前输入的内容还在,可以继续输入。

这种体验放在原生应用上或许很常见,但移动端WEB少有聊天应用。不仅是web体验交互差,兼容成本也远超原生应用。web 数据持久化的难点是:

  • 不具备完善的数据缓存机制,BOM API支持能力的差异与数据关系映射结构复杂不利管理。
  • 低端机型浏览器易崩溃、用户刷新网页都会导致状态丢失。
  • 路由机制,应用运行时一旦切路由上一个路由就失去了其最后状态和数据。 在应用初始化阶段 Web 可以借助会话 Storage、Cache、IndexDB等API读取缓存数据或者读取存放在云端的数据来完成初始化达到目的,

PD:“数据和状态的持久化必须要有,而且聊天界面也要缓存,能够直接从好友A的聊天界面切到好友B,不需要回到聊天列表。其他页面乜”

image.png

方案

  • 首先想到的就是路由缓存方案,找下了实现,大部分基本都是mock路由保存状态/路由缓存器缓存来源页面,命中缓存页面路由后再提取激活,不具备match单个路由规则缓存多个副本的能力。
  • 数据管控路由,根据数据驱动路由控制视图,路由管理器match维护组件状态。

web初始化状态是硬伤无法避免,本文只介绍运行时的路由方案。

运行时的实现思路是使用 history API 模拟一个页面栈 PageStack,对外暴露导航方法,所有导航方法的操作都是基于 PageStack,搭配 popstate event 响应返回事件,提供统一的 PageLoader,挂载在 PageLoader 容器上供页面使用,容器对于应用本身无感,使用的方法大概如下:

使用方法

import { withContext, containerContext } from '@internal/container';
import React, { useContext, useEffect } from 'react';

// login page
export function Login() {
const { navApi } = useContext(containerContext);
const goToHome = useCallback(() => {
navApi.navigateTo('home.dashboard', { xxx: 'xxx' });
}, [navApi]);
return (
<button onClick={gotoHome}>Login</button>
);
}

// home page
export function Dashboard() {
const { navApi } = useContext(containerContext);
return (<button onClick={navApi.back}>back</button>);
}

同时也应该具备响应事件回调的能力,

// home page
const waitCb = navApi.navigateToAndWaitBack('chat.session', { xxx: 'xxx' });
waitCb.then(res => { console.log(res) })

// session page
navApi.backWithResponse({ msg: 'I\'m back' });

// result "I\'m back"

制定协议

页面可能已经有路由的情况,假设当前路由为 https://0.0.0.0:7104/overview,跳转 home.dashboard,向 pageStack 和 history 栈中应该推入栈的地址是

`https://0.0.0.0:7104/overview/?page=home.dashboard&pageParams={\"from\":\"xxx\"}`

提供本地/外置二方组件包是 UMD/AMD 方式加载,考虑到业务组件的拓展性,跳转路由可以增加协议前缀。组件分为页面级和组件级,前者囊括后者,需要一个组件 Loader 去分析协议并加载对应页面级组件,组件 Loader 也具备加载组件,得出新的协议: schema?://namespace[module?][#subModule?], 其中 schema 用于区分是什么类型的组件,namespace 表示仓库或者是组件, module 表示导出的模块名称,subModule 表示子模块,例如

  • internal://Loading 表示加载本地的Loading组件,
  • group://home.dashboard#subModule 表示加载团队二方库下的home组件中导出的dashboard模块,并渲染导出的subModule子模块,默认缺省为 default 导出。
  • https://xxx.com/xxx/0.1.0/umd/antd-mobile.js#DatePicker 表示加载antd-mobile下的 DatePicker 模块。
`https://0.0.0.0:7104/overview/?page=group://home.dashboard#subModule&pageParams={\"from\":\"xxx\"}`

API & 数据结构

定好API&数据结构


// 导航对外提供的方法
interface NavigationAPI {
navigateTo(page: string, params?: Record<string, any>): void;
back(): void;

replace(page: string, params?: Record<string, any>): void;

open(page: string, params?: Record<string, any>): void;
reload(): void;
}

// 模拟的堆栈
type PageStackItem {
page: string;
rawPageParams: string;
getPageParams: () => Record<string, any>
}

// 控制堆栈的内置方法
interface IPageStack {
getStack(): ReadonlyArray<PageStackItem>;
size: number;
push(item: PageStackItem): void;
pop(): PageStackItem | undefined;
replace(item: PageStackItem): void;
}

实现

模拟页面栈

由于还没有对 url 参数有明确的规则限制,暂允许任何类型。

实现基本的堆栈操作,history API replaceStatepushState 都只会添加记录,页面内容不会自动更新,需要暴露主动更新的方法。

type NavWithStack = {
pageStack: ReadonlyArray<PageStackItem>,
navApi: NavigationAPI
}

function useNavigator(): NavWithStack {
const forceRefresh = useForceUpdate();
// 初始化页面栈,缺省为当前页面
const data = useMemo<{ items: PageStackItem[] }>(() => {
const query: Record<string, any> = getQueryForUrl(); // 获取当前href query;
return {
items: [
{
page: query.page,
rawPageParams: query.pageParams,
getPageParams: () => safeParseJSON(query.pageParams),
}
],
};
}, []);

const pageStack = useMemo<IPageStack>(() => ({
getStack: () => data.items,
get size() {
return data.items.length
},
push: (item: PageStackItem) => {
data.items.push(item);
forceRefresh();
},
pop: (): PageStackItem | undefined => {
const preStack = data.items.pop();
forceRefresh();
return preStack;
},
replace: (item: PageStackItem) => {
data.items.pop();
data.items.push(item);
forceRefresh();
}
}), [data.items, forceRefresh]);

// 暴露所有导航方法
const navApi = useHistoryNavigation(pageStack);

return {
pageStack,
navApi
}
}

genFullUrlWithQuery 负责拼接url与参数生成完整的链接,getQueryForUrl 处理当前 href 内已有的其他查询字符串序列化成对象。

function useHistoryNavigation(pageStack: IPageStack): NavigationAPI {
const { history } = window;
const generateUrl = (page: string, params?: Record<string, any>) => {
if (isFullUrl(page)) { // https? 协议开头 非站内跳转
return genFullUrlWithQuery(page, params);
}

const currentHostAndPath = window.location.href.replace(/[?#].*$/, '');
const pageParams = JSON.stringify(params || {}) || '';
return genFullUrlWithQuery(currentHostAndPath, {
...getQueryForUrl(),
page,
pageParams, // 暂不处理url query超长的问题
});
};


function open(page: string, params?: Record<string, any>) {
window.open(generateUrl(page, params || {}));
}
// 导航跳转
function navigateTo(page: string, params?: Record<string, any> = {}) {
history.pushState(
{ page, params },
document.title,
generateUrl(page, params)
);

pageStack.push({
page,
rawPageParams: buildQueryString(params),
getPageParams: () => params,
});
}

function back() {
pageStack.pop();
history.back();
}


function replace(page: string, params?: Record<string, any> = {}) {
history.replaceState(
{ page, params },
document.title,
generateUrl(page, params)
);

pageStack.replace({
page,
rawPageParams: buildQueryString(params),
getPageParams: () => params,
});
}

function reload() {
window.location.reload();
}

return {
navigateTo,
back,
replace,
open,
reload
};
}

初始化路由层

初始化context,让子组件可以消费这些能力

function unimplementedFunction() {
throw new Error('Functions not implemented');
}
function unimplementedComponent: React.ComponentType<any>(): any {
throw new Error('Component not implemented');
}

export const containerContext: Context<ContainerAbility> = React.creareContext({
nav: {
navigateTo: unimplementedFunction,
back: unimplementedFunction,
replace: unimplementedFunction,
open: unimplementedFunction,
reload: unimplementedFunction
},
ComponentLoader: unimplementedComponent
});

function NavigatorContainer(props){
const { nav, pageStack } = useNavigator();
const originalContainer = useContext(ContainerContext);
const container = useMemo(() => ({
...originalContainer,
nav,
}),[originalContainer]);
return (
<ContainerContext.Provider value={container}>

</ContainerContext.Provider>
)
}

栈数据拿到了,如何渲染路由页面?需要一个遍历器负责去遍历页面栈,每个页面栈都是一个页面,对应一个页面根节点,可以使用 PageLoader 去生成,为了使 PageLoader 的职责更加单一,它负责把 ComponentLoader 包装一层,也可以作为容器使用。当作为容器使用时,不应该有加载动作。

image.png

// 遍历器
export function PageStack<TItem>({ items, renderItem }: PageStackProps<TItem>): any {
return items.map(renderItem);
}
// 页面根节点
const PageRoot: React.FC<{ visible: boolean } & PageStackItem> = ({ visible, children, page }) => {
const [pageRootEl, setPageRootEl] = useState(null);
return (
<>
<div
className={classnames('page-root', { 'page-visible': visible })}
data-page={page}
data-role="page-root"
ref={setPageRootEl}
/>
{pageRootEl ? createPortal(children, pageRootEl) : null}
</>
);
};

const PageLoader: React.FC<PageStackItem> = ({ page, getPageParams, children }) => {
const { ComponentLoader } = useContext(ContainerContext);
if (!page) return children as ReactElement;

return <ComponentLoader componentURI={page} props={getPageParams()} />;
};

function NavigatorContainer(props: ContainerProps): ReactElement {
// ...
return (
<ContainerContext.Provider value={container}>
<PageStack<PageStackItem>
items={pageStack.getStack()}
renderItem={(itemProps, idx) => (
<PageRoot key={idx} {...itemProps} visible={idx === pageStack.size - 1}>
<PageLoader {...itemProps}>{props.children}</PageLoader>
</PageRoot>
)}
/>
</ContainerContext.Provider>
);
}

export function Container(props: ContainerProps): ReactElement {
return (
<BasicContainer {...props}>
<NavigatorContainer {...props}>{props.children}</NavigatorContainer>
</BasicContainer>
);
}

类名 page-root 主要是给所有组件添加屏蔽点击事件行为和定义显示类型为隐藏, 只有被激活的组件,才会附加page-visible 处于可见状态。

PageLoader

页面组件可能动态加载,在一般场景下,我们使用 React.lazy 也能完成基本功能,但要想更高程度的定制功能,例如ErrorBoundary、预加载组件、Suspense,我们的容器层需要注入不同环境下甚至跨端的加载能力,还有协议解析和脚本加载等能力,下一篇文章会出如何实现高定制能力的容器层。本章不涉及ComponentLoader的内容,有兴趣的小伙伴可以插个眼。

组件加载加载完毕后,因为包装了路由层,可以引入Context使用其能力,如下图,已经具备了部分缓存能力。使用方式如下

import React, { useEffect, ReactElement, useRef, useContext } from 'react';
import { containerContext, Container } from '@internal/container';

function ChatList() {
const { navAPI } = useContext(containerContext);

return <ul>
...
<li onClick={() => navAPI.navigateTo('group://chat#default', { data: xxx }) }>...</li>
</ul>
}
function ContainerDemo(): ReactElement {
return (
<Container >
<ChatList />
</Container>
);
}

Demo:

Kapture 2021-05-06 at 15.57.03.gif

可以看到,从A页面跳B页面时,返回后A仍然处于导航前的状态,A->B->A导航前后符合预期,但再次A->B时,B页面没有被缓存。

缓存

思考以下问题

  • 如何制定match规则?
  • 如何去缓存页面?
  • 单个路由match多个副本的情况如何知道需要激活哪个缓存路由?
  • 缓存后的页面组件更新逻辑该如何处理?

如何制定match规则

更希望对下层无感可控,既然是一对多,那么就需要一个标识用于区分彼此。可以从容器层传入匹配规则,符合规则的组件将会被缓存。

<Container cacheOptions={[/^(group:\/\/)?chat(\.[A-Za-z0-9_]+)?(#\w+)?$/i]} >
<ChatList />
</Container>

路由协议微调,group://chat#default:cacheId 可以作为协议的一部分。对需要缓存的页面指定一个标识,当跳转到该路由时,必须携带一个缓存id, e.g.

navAPI.navigateTo('group://chat#default:cacheId', data)

如何去缓存页面 & 如何激活对应的缓存路由

首先要知道为什么路由不会缓存页面,一般在Route中,一旦没有命中规则,路由会返回null,即使再次被激活,组件也会重新创建,丢失之前的状态。

通过数据管控,当前栈之前已经被激活的页面组件都不会被销毁,这也是数据管控路由模式的好处,本身已经具备单路由规则多实例的能力,根据分别对应“不同”的路由创建新的页面栈,但因为每个 PageLoader 都对应着一个 pageStack,一旦后退之前的创建的节点也会被销毁掉,缓存成立的前提是单纯基于length不改变的情况下。可以将它们两者的关系改变为pageStack包容前者。

pageStack 和 cachePageStack 之间的关系。当 pageStack 里包含了符合缓存命中条件的栈,就备份副本在 CachePageStack 中,如果当前被激活的栈 pageURI 存在于 CachePageStack 里,那就使用副本。

image.png

缓存后的页面组件更新逻辑该如何处理

给 pageRoot 包一层触发器。shouldComponentUpdateuseMemo 都能达到目的。

目前所接触的业务,大部分路由都是根据业务域划分的,不应该存在跨页面级路由通信,单纯由数据驱动,实际上仍然会面临一些只涉及状态触发更新的跨路由通信在容许范围内,更新的机制可以自行定制这里就不凑字数了,就以路由激活作为唯一条件。

// Updatable
class Updatable extends Component {
static propsTypes = {
when: PropTypes.bool.isRequired
}

shouldComponentUpdate = ({ when }) => when

render = () => this.props.children
}

改造路由层

// NavigatorContainer

function NavigatorContainer(props: ContainerProps): ReactElement {
//...
return (
<ContainerContext.Provider value={container}>
<PageStack<PageStackItem & Pick<ContainerProps, 'cacheOptions'>>
cacheOptions={props.cacheOptions}
items={pageStack.getStack()}
renderItem={(itemProps, idx) => (
<PageRoot key={itemProps.page || idx} {...itemProps} visible={idx === pageStack.size - 1}>
<Updatable when={idx === pageStack.size - 1}>
<PageLoader {...itemProps}>{props.children}</PageLoader>
</Updatable>
</PageRoot>
)}
/>
</ContainerContext.Provider>
);
}

PageStack

// PageStack
export function PageStack<TItem extends PageStackItem>({ items, renderItem, cacheOptions}: PageStackProps<TItem>): any {
const forceUpdate = useForceUpdate();
const CacheRouteMap = useMemo(() => new Map<string, TItem>(), []);

useDeepDiffEffect(() => {
if (Array.isArray(cacheOptions)) {
cacheOptions.forEach(reg => {
items.forEach(item => {
const { page = '', cacheId } = item;
// match 缓存路由
if (reg.test(page.replace(`:${cacheId}`, '')) && cacheId && !CacheRouteMap.get(page)) {
CacheRouteMap.set(page, { ...item });
forceUpdate();
}
});
});
}
}, [items, cacheOptions]);

// 生成新栈
const genPageStack = (): ReadonlyArray<TItem> => {
const currentMap = items.filter(t => t.page).reduce((map, item) => ({ ...map, [item.page]: item }), {});
const copyRouteMap = [...CacheRouteMap.keys()].filter(
key => !Object.prototype.hasOwnProperty.call(currentMap, key)
);
return items.concat(copyRouteMap.map(key => CacheRouteMap.get(key)).filter(Boolean));
};

return genPageStack().map(renderItem);
}

1620281708889.gif

路由回调

在某些情况下确实需要类似跨路由通信的能力,例如,从聊天界面回来需要刷新聊天列表,甚至要携带一些数据回来(表单是否提交等)。相比通信更像是属于导航API的能力,使用方法如下

navAPI.navigateAndWaitBack('group://chat#default:123').then((submit) => {
if(submit) dosomething...
})

为了遵循API职责单一,对外暴露两个新的方法

interface NavigationAPI {
//... other api,
navigateAndWaitBack(page: string, params?: Record<string, any>): Promise<any>;
backWithData(data?: any): void;
}

function useHistoryNavigationByPageStack(pageStack: IPageStack): NavigationAPI {
const callbackQueue = useMemo<Array<Fn>>(() => [], []); // 回调队列以应对多次响应路由的情况
const callbackDataRef = useRef<any>();

function navigateTo(page: string, params?: Record<string, any>, navigatorOption?: { callbackEvent: Fn }) {
// ...other
callbackQueue.push(navigatorOption ? navigatorOption.callbackEvent : undefined);
}

// 推入回调队列
function navigateAndWaitBack(page: string, params: Record<string, any>) {
return new Promise(resolve => {
navigateTo(page, params, { callbackEvent: resolve });
});
}

// 暂存数据
function backWithResponse(data: any) {
callbackDataRef.current = data;
back();
}

return {
//...,
navigateAndWaitBack,
backWithResponse
}
}

在 popstate 时机去执行该次回调,并清理副作用。

useEffect(() => {
const popStateHandle = (event: PopStateEvent) => {
// 1.pop page stack
pageStack.pop();
// 栈如果为空,缺省为当前页
if (pageStack.size === 0) {
const pageState = event.state || {};
pageStack.push({
page: pageState.page,
cacheId: getCacheId(pageState.page),
rawPageParams: pageState.params,
getPageParams: () => pageState.params,
});
}

// 2.reactive callback event
const data = callbackDataRef.current;
callbackDataRef.current = null;
const fn = callbackQueue.pop();
if (fn) {
try {
fn.call(null, data);
} catch (err) {
console.error('invoke route backResponse callback fail', err);
}
}
};
window.addEventListener('popstate', popStateHandle);
return () => {
window.removeEventListener('popstate', popStateHandle);
};
}, [callbackQueue]);

Kapture 2021-05-06 at 15.23.22.gif

至此路由层就完成了,Demo中还有一些不足的地方,例如路由回调应对刷新页面怎么处理、栈变化时的过场动画、参数过长等问题。