The preface

In recent years, most conventional applications adopt MPA and SPA to bind corresponding rules to each route to achieve the capability of matching routing rule rendering components.

Recently, WE are developing a chat application, referred to as XX for short. Those who often use wechat and Dingding should have experienced its status and data caching capabilities When you enter half of the chat content with your friend, other applications pop up a message or suspend the input of the rest content due to some circumstances, and you cut out the dialogue interface with your friend or even the XX application. When you return to the chat interface with your friend, you can continue to enter the content you entered before.

This kind of experience may be common on native apps, but there are few chat apps on the mobile WEB. Not only is the web experience less interactive, but the cost of compatibility is much higher than native apps. The difficulties with Web data persistence are:

  • There is no perfect data caching mechanism, and the differences in BOM API support capabilities and the complex structure of data relation mapping are not conducive to management.
  • Browsers on low-end models crash easily and users refresh the web page, resulting in loss of status.
  • Routing mechanism in which the application loses its last state and data once it cuts a route.

In the application initialization stage, the Web can use session Storage, Cache, IndexDB and other APIS to read Cache data or read data stored in the cloud to complete the initialization.

PD: “There has to be persistence of data and status, and the chat screen has to be cached, so you can just go from friend A’s chat screen to friend B without going back to the chat list. Other pages.”

plan

  • The first thing that came to my mind was the route cache scheme. After searching for the implementation, most of them are basically mock routes that store the source page of the state/route cache, and then extract and activate the route after hitting the cached page. They do not have the ability to cache multiple copies of a single match routing rule.
  • Data control routing. According to the data-driven routing control view, route manager Match maintains component status.

The web initialization state is hard to avoid, and this article only covers the routing scheme at run time.

At runtime, the idea isto use the history API to simulate a PageStack PageStack and expose navigation methods. All navigation methods operate based on PageStack, with popstate events responding to return events to provide a unified PageLoader. Mount to the PageLoader container for use by the page, the container is not sensitive to the application itself, use the method roughly as follows:

Method of use

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>);
}

Copy the code

They should also be able to respond to event callbacks,

// 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"
Copy the code

Make agreement

Pages may have routing, assuming that the current routing for https://0.0.0.0:7104/overview, jump home. The dashboard, the pageStack and history should be pushed into the stack address is

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

The local/external component package is loaded in UMD/AMD mode. Considering the scalability of service components, forward routes can be prefixed with protocols. Component is divided into page level and component level, the former includes the latter, requires a component Loader to analyze the protocol and load the corresponding page level components, component Loader also has the ability to load components, the new protocol: schema? ://namespace[module?] [#subModule?] Schema is used to distinguish the types of components, namespace is the repository or component, module is the name of the exported module, and subModule is the subModule, for example

  • internal://LoadingLoading the local Loading component,
  • group://home.dashboard#subModuleLoad the dashboard module exported from the home component of the team 2 library and render the exported subModule. By default, the default export is default.
  • https://xxx.com/xxx/0.1.0/umd/antd-mobile.js#DatePickerDatePicker module under ANTD-Mobile is loaded.
` https://0.0.0.0:7104/overview/? page=group://home.dashboard#subModule&pageParams={\"from\":\"xxx\"}`
Copy the code

API & Data structures

Define the API& data structure


// Navigation provides methods externally
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;
}

// The stack is simulated
type PageStackItem {
  page: string;
  rawPageParams: string;
  getPageParams: () = > Record<string.any>}// A built-in method to control the stack
interface IPageStack {
  getStack(): ReadonlyArray<PageStackItem>;
  size: number;
  push(item: PageStackItem): void;
  pop(): PageStackItem | undefined;
  replace(item: PageStackItem): void;
}
Copy the code

implementation

Mock page stack

Since there are no clear rules for URL parameters, any type is allowed for now.

To implement basic stack operations, both the history API replaceState and pushState will only add records, and the page content will not update automatically, exposing active update methods.

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

function useNavigator() :NavWithStack {
    const forceRefresh = useForceUpdate();
    // Initializes the page stack, default to the current page
    const data = useMemo<{ items: PageStackItem[] }>(() = > {
        const query: Record<string.any> = getQueryForUrl(); // Get the current 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]);
    
    // Expose all navigation methods
    const navApi = useHistoryNavigation(pageStack);
    
    return {
        pageStack,
        navApi
    }
}
Copy the code

GenFullUrlWithQuery is responsible for concatenating urls and parameters to generate complete links, and getQueryForUrl handles serialization of other query strings into objects in the current href.

function useHistoryNavigation(pageStack: IPageStack) :NavigationAPI {
    const { history } = window;
    const generateUrl = (page: string, params? : Record<string.any>) = > {
        if (isFullUrl(page)) { // https? Non-station jump at the start of the protocol
          return genFullUrlWithQuery(page, params);
        }
        
        const currentHostAndPath = window.location.href.replace(/ [? #]. * $/.' ');
        const pageParams = JSON.stringify(params || {}) || ' ';
        returngenFullUrlWithQuery(currentHostAndPath, { ... getQueryForUrl(), page, pageParams,// The url query is too long
        });
    };
    
    
    function open(page: string, params? : Record<string.any>) {
        window.open(generateUrl(page, params || {}));
    }
    // Navigate to jump
    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
    };
}
Copy the code

Initialize the routing layer

Initialize the context so that child components can consume these capabilities

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>)}Copy the code

How do I render the routing page when I get the stack data? Need a traverser is responsible for traversing the page stack, each page stack is a page, corresponding to a page root node, can use PageLoader to generate, in order to make PageLoader responsibilities more single, it is responsible for the ComponentLoader packaging layer, can also be used as a container. When used as a container, there should be no load action.

/ / traverse
export function PageStack<TItem> ({ items, renderItem }: PageStackProps<TItem>) :any {
  return items.map(renderItem);
}
// Page root node
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>
  );
}

Copy the code

The class name page-root is used to mask click events for all components and specify the display type to be hidden. Only active components will have page-visible status attached.

PageLoader

Page components may load dynamically. In general scenarios, we can use React.lazy to complete basic functions. However, for higher degree of customization, such as ErrorBoundary, preloading components and Suspense, our container layer needs to inject loading capabilities in different environments and even across ends. There are also capabilities such as protocol parsing and script loading. The next article will cover how to implement a container layer with high customization capabilities. This chapter does not involve ComponentLoader content, interested partners can insert an eye.

After the component is loaded, because the routing layer is wrapped, the Context can be introduced to use its capabilities, as shown in the figure below. Some caching capabilities are already available. Use as follows

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>
  );
}
Copy the code

Demo:

It can be seen that when we jump from page A to page B, A is still in the state before navigation, and A->B->A meets the expectation before and after navigation, but when A->B again, page B is not cached.

The cache

Consider the following questions

  • How to make match rules?
  • How to cache pages?
  • How do I know which cached route to activate if there are multiple copies of a single route match?
  • What about cached page component update logic?

How to make match rules

I prefer to have no control over the lower layer. Since it is one-to-many, I need a mark to distinguish between them. Matching rules can be passed in from the container layer, and components that match the rules will be cached.

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

Routing protocol tuning, group://chat#default:cacheId can be used as part of the protocol. Specify an ID for the page to be cached. When switching to this route, it must carry a cache ID, LLDB.

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

How to decache the page & how to activate the corresponding cached route

The first thing you need to know is why routes don’t cache pages. In a Route, if a rule is missed, the Route returns null, and even if activated again, the component is recreated, losing its previous state.

Through data control, the current stack has been activated before the page components will not be destroyed, this is also the benefit of data control routing mode, itself has the ability to single routing rules and multiple instances, according to the corresponding “different” route to create a new pageStack, but because each PageLoader corresponds to a pageStack, The cache is valid only if the length is not changed. You can change the relationship between the two so that pageStack includes the former.

The relationship between pageStack and cachePageStack. When pageStack contains a stack that matches a cache hit, a copy is backed up in CachePageStack. If the currently active stack pageURI exists in CachePageStack, the copy is used.

What about cached page component update logic

Give the pageRoot package a layer of triggers. ShouldComponentUpdate, useMemo both do the trick.

Currently business contact, most routes are divided according to the business domain, there should be no route traffic across the page level, purely driven by data, actually still face some involve only state trigger updates across routing communication within the allowable range, the update mechanism can not – and custom here in routing activation as the only condition.

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

  render = () = > this.props.children
}
Copy the code

Modified routing layer

// 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> ); }Copy the code

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 cache route
          if (reg.test(page.replace(` :${cacheId}`.' ')) && cacheId && ! CacheRouteMap.get(page)) { CacheRouteMap.set(page, { ... item }); forceUpdate(); }}); }); } }, [items, cacheOptions]);// Generate a new stack
  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);
}
Copy the code

Routing the callback

In some cases, cross-routed communication capabilities are really needed, for example, when you come back from the chat screen to refresh the chat list, or even to bring back some data (whether the form is submitted, etc.). Capabilities that are more of a navigation API than communications are used as follows

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

In order to comply with the SINGLE responsibility of the API, two new methods are exposed

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>>(() = > [], []); // Call back the queue to handle multiple response routes
  const callbackDataRef = useRef<any> ();function navigateTo(page: string, params? : Record<string.any>, navigatorOption? : { callbackEvent: Fn }) {
    / /... other
    callbackQueue.push(navigatorOption ? navigatorOption.callbackEvent : undefined);
  }
  
  // Push into the callback queue
  function navigateAndWaitBack(page: string, params: Record<string.any>) {
    return new Promise(resolve= > {
      navigateTo(page, params, { callbackEvent: resolve });
    });
  }
  
  // Save data temporarily
  function backWithResponse(data: any) {
    callbackDataRef.current = data;
    back();
  }
  
  return {
     / /... .
     navigateAndWaitBack,
     backWithResponse
  }
}
Copy the code

Perform the callback at popState time and clean up the side effects.

useEffect(() = > {
    const popStateHandle = (event: PopStateEvent) = > {
      // 1.pop page stack
      pageStack.pop();
      // If the stack is empty, it defaults to the current page
      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]);
Copy the code

At this point, the routing layer is complete, there are still some shortcomings in the Demo, such as how to handle the route callback to refresh the page, stack change cutscenes, too long parameters, etc.