仿原生路由备忘录

107 阅读1分钟

实现转场动画Animation.ts

const TRANSTION_BASE = {
  'transition-property': 'transform',
  'transition-timing-function': 'cubic-bezier(.34,.86,.54,.99)',
};
export const TRANSITION_DURATION = 400;

const DEFAULT_TRANSITION = {
  openEnterZIndex: 2,
  closeEnterZIndex: 1,
  openExitZIndex: 1,
  closeExitZIndex: 3,
  openEnterAnimationFrom: {
    transform: 'translate3d(99%,0%,0)',
    ...TRANSTION_BASE,
  },
  openEnterAnimationTo: {
    'transition-duration': '.4s',
    transform: 'translate3d(0,0,0)',
  },
  openExitAnimationFrom: {
    transform: 'translate3d(0,0,0)',
    ...TRANSTION_BASE,
  },
  openExitAnimationTo: {
    'transition-duration': '.4s',
    transform: 'translate3d(-50%,0,0)',
  },
  closeEnterAnimationFrom: {
    transform: 'translate3d(-50%,0,0)',
    ...TRANSTION_BASE,
  },
  closeEnterAnimationTo: {
    'transition-duration': '.4s',
    transform: 'translate3d(0,0,0)',
  },
  closeExitAnimationFrom: {
    transform: 'translate3d(0,0,0)',
    ...TRANSTION_BASE,
  },
  closeExitAnimationTo: {
    'transition-duration': '.4s',
    transform: 'translate3d(100%,0,0)',
  },
};

export  function getTransition(isForward, animConfig?) {
  animConfig = {
    ...DEFAULT_TRANSITION,
    ...animConfig,
  };
  const type = isForward ? 'open' : 'close';
  const enterFrom = { ...animConfig[`${type}EnterAnimationFrom`] };
  const exitFrom = { ...animConfig[`${type}ExitAnimationFrom`] };

  enterFrom['z-index'] = isForward ? animConfig.openEnterZIndex : animConfig.closeEnterZIndex;
  enterFrom.display = 'block';
  exitFrom['z-index'] = isForward ? animConfig.openExitZIndex : animConfig.closeExitZIndex;
  exitFrom.display = 'block';

  return {
    enterFrom,
    enterTo: animConfig[`${type}EnterAnimationTo`],
    exitFrom,
    exitTo: animConfig[`${type}ExitAnimationTo`],
  };
}

暴露给外部使用的路由BrowserRouter.ts

import Router from './Router';

class BrowserRouter extends Router {
  popState = () => {
    const pageStack = this.navigation.pageStack;
    // 检测是返回还是修改地址栏地址触发 popstate
    if (pageStack.length >= 2 && this.navigation.delta === 1) {
      const lastPage = pageStack[pageStack.length - 2];
      if (`#${lastPage.routeData.pathname}` === location.hash) {
        this.navigation.back();
      } else {
        this.navigation.forward(location.hash);
      }
    } else {
      this.navigation.back();
    }
  };

  replaceHashPath(): void {
    window.location.replace(`${window.location.href}#/`);
  }

  start(): void {
    window.addEventListener('popstate', this.popState);
    const hashPath = location.hash;
    if (hashPath === '') {
      this.replaceHashPath();
    }
    this.navigation.forward(location.hash);
  }

  destory(): void {
    window.removeEventListener('popstate', this.popState);
  }
}

export default new BrowserRouter();

path 匹配 matchPath.ts

import { pathToRegexp } from 'path-to-regexp';
import { qs } from './util';

const cache = {};
const cacheLimit = 10000;
let cacheCount = 0;

function compilePath(path, options) {
  const cacheKey = `${options.end}${options.strict}${options.sensitive}`;
  const pathCache = cache[cacheKey] || (cache[cacheKey] = {});

  if (pathCache[path]) return pathCache[path];

  const keys = [];
  const regexp = pathToRegexp(path, keys, options);
  const result = { regexp, keys };

  if (cacheCount < cacheLimit) {
    pathCache[path] = result;
    cacheCount++;
  }

  return result;
}

/**
 * path 匹配
 */
function matchPath(
  pathname: string,
  options:
    | {
        path?: string;
        exact?: boolean;
        strict?: boolean;
        sensitive?: boolean;
      }
    | string = {},
) {
  if (typeof options === 'string') {
    options = { path: options };
  }

  const { path, exact = false, strict = false, sensitive = false } = options;

  const queryIndex = pathname.indexOf('?');
  const queryString = queryIndex >= 0 ? pathname.substr(queryIndex) : '';

  const pathUrl = queryIndex >= 0 ? pathname.substring(0, queryIndex) : pathname;

  const paths = [].concat(path);

  return paths.reduce((matched, pathItem) => {
    if (!pathItem && pathItem !== '') return null;
    if (matched) return matched;

    const { regexp, keys } = compilePath(pathItem, {
      end: exact,
      strict,
      sensitive,
    });
    const match = regexp.exec(pathUrl);

    if (!match) return null;

    const [url, ...values] = match;
    const isExact = pathUrl === url;

    if (exact && !isExact) return null;

    const searchString = location.search;

    return {
      pathname,
      path,
      url: path === '/' && url === '' ? '/' : url,
      isExact,
      search: qs(searchString),
      query: qs(queryString),
      params: keys.reduce((memo, key, index) => {
        memo[key.name] = values[index];
        return memo;
      }, {}),
    };
  }, null);
}

export { matchPath };

控制路由跳转 Navigation.ts

import Page from './Page';
import PageManager from './PageManager';
import Router from './Router';

class Navigation {
  public pageStack: Page[] = [];

  public router: Router;

  public delta: number = 1;

  constructor(router: Router) {
    this.router = router;
  }

  forward(path?: string) {
    const matchRoute = this.router.getMatchRoute(path);
    // 获取匹配路由
    if (matchRoute) {
      const { route, routeData } = matchRoute;
      const page = new Page(route, routeData);
      const pageStack = this.pageStack;
      pageStack.push(page);
      page.init();
      if (pageStack.length > 1) {
        const prePage = pageStack[pageStack.length - 2];
        PageManager.replacePageWithAnimation(prePage, page, true);
      } else {
        if (page.willShow) page.willShow();
        if (page.didShow) page.didShow();
      }
    }
  }

  push(path: string) {
    history.pushState({}, '', `#${path}`);
    this.forward(location.hash);
  }

  replace(path: string) {
    history.replaceState({}, '', `#${path}`);
    const prePage = this.pageStack.pop();
    const matchRoute = this.router.getMatchRoute(path);
    // 获取匹配路由
    if (matchRoute) {
      const { route, routeData } = matchRoute;
      const nextPage = new Page(route, routeData);
      const pageStack = this.pageStack;
      pageStack.push(nextPage);
      PageManager.replacePageWithoutAnimation(prePage, nextPage);
    }
  }

  go(delta: number = -1) {
    this.delta = Math.abs(delta);
    history.go(delta);
  }

  back() {
    const pageStack = this.pageStack;

    // 需清理跳过的page
    if (this.delta > 1) {
      const deleteCount = this.delta - 1;
      const deleteIndex = pageStack.length - 1 - deleteCount;
      if (deleteCount > 0) {
        const removePages = pageStack.splice(deleteIndex, deleteCount);
        removePages.forEach((page) => {
          // todo execute lifecycle
          page.destory();
        });
        // 返回步数复位
        this.delta = 1;
      }
    }

    if (pageStack.length > 1) {
      const currPage = pageStack[pageStack.length - 2];
      const prePage = pageStack[pageStack.length - 1];
      pageStack.pop();
      PageManager.replacePageWithAnimation(prePage, currPage, false).then(() => {
        prePage.destory();
      });
    } else if (pageStack.length === 1) {
      const currPage = pageStack.pop();
      currPage.destory();
      this.forward();
    } else {
      this.forward();
    }
  }
}

export default Navigation;

页面容器 Page.ts

import ReactDOM from 'react-dom';
import React, { Suspense } from 'react';
import Route from './Route';

enum LIFE_CYCLE {
  WILL_SHOW = 'willShow',
  DID_SHOW = 'didShow',
  WILL_HIDE = 'willHide',
  DID_HIDE = 'didHide',
}

class Page {
  public route: Route;

  public routeData: any;

  public container: HTMLDivElement;

  public instance: React.ReactNode;

  willHide() {
    this.callLifeCycle(LIFE_CYCLE.WILL_HIDE);
  }

  didHide() {
    this.callLifeCycle(LIFE_CYCLE.DID_HIDE);
  }

  willShow() {
    this.callLifeCycle(LIFE_CYCLE.WILL_SHOW);
  }

  didShow() {
    this.callLifeCycle(LIFE_CYCLE.DID_SHOW);
  }

  callLifeCycle(method: string) {
    if (this.instance && this.instance[method]) {
      this.instance[method]();
    }
  }

  constructor(route: Route, routeData: any) {
    this.route = route;
    this.routeData = routeData;
    this.container = document.createElement('div');
    this.container.classList.add('view');
    const style = this.container.style;
    style.position = 'fixed';
    style.left = '0';
    style.top = '0';
    style.width = '100%';
    style.height = '100%';
    style.backgroundColor = '#fff';
    style.zIndex = '2';
    document.body.appendChild(this.container);
  }

  init() {
    let appendRef = {};
    let elementToAppend;
    const component = this.route.component.type;
    // react lazy组件
    if (typeof component === 'object' && component['$$typeof'] === Symbol.for('react.lazy')) {
      if (component['isReactClassComponent']) {
        appendRef = {
          ref: (instance) => {
            this.instance = instance;
          },
        };
      }
      elementToAppend = (
        <Suspense fallback={<div>Loading...</div>}>
          {React.cloneElement(this.route.component, {
            ...this.routeData,
            ...appendRef,
          })}
        </Suspense>
      );
    } else if (
      typeof component === 'function' &&
      component.prototype &&
      component.prototype.isReactComponent
    ) {
      appendRef = {
        ref: (instance) => {
          this.instance = instance;
        },
      };
      elementToAppend = React.cloneElement(this.route.component, {
        ...this.routeData,
        ...appendRef,
      });
    } else {
      elementToAppend = React.cloneElement(this.route.component, {
        ...this.routeData,
        ...appendRef,
      });
    }

    ReactDOM.render(elementToAppend, this.container);
  }

  destory() {
    ReactDOM.unmountComponentAtNode(this.container);
    document.body.removeChild(this.container);
  }
}

export default Page;

管理页面切换及生命周期 PageManager.ts

import Page from './Page';
import Route from './Route';
import { getTransition, TRANSITION_DURATION } from './Animation';
import { setCss, sleep } from './util';

enum ANI_TYPE {
  ENTER = 0,
  EXIT = 1,
}

class PageManager {
  static createPage(route: Route, routeData) {
    return new Page(route, routeData);
  }

  static generatorAnimationTask(page: Page, from: Page, to: Page, animType: ANI_TYPE) {
    return new Promise(resolve => {
      setCss(page.container, from);
      switch (animType) {
        case ANI_TYPE.ENTER:
          page.willShow();
          break;
        case ANI_TYPE.EXIT:
          page.willHide();
          break;
        default:
          break;
      }
      setTimeout(() => {
        setCss(page.container, to);
        switch (animType) {
          case ANI_TYPE.ENTER:
            page.didShow();
            break;
          case ANI_TYPE.EXIT:
            page.didHide();
            break;
          default:
            break;
        }
        sleep(TRANSITION_DURATION).then(resolve);
      }, 50);
    });
  }

  static replacePageWithAnimation(prePage: Page, nextPage: Page, isForward: boolean) {
    const { enterFrom, enterTo, exitFrom, exitTo } = getTransition(isForward);

    prePage.container.classList.remove('actived');

    const leaveTask = PageManager.generatorAnimationTask(prePage, exitFrom, exitTo, ANI_TYPE.EXIT);

    const enterTask = PageManager.generatorAnimationTask(
      nextPage,
      enterFrom,
      enterTo,
      ANI_TYPE.ENTER,
    );

    return Promise.all([enterTask, leaveTask]);
  }

  static replacePageWithoutAnimation(prePage: Page, nextPage: Page) {
    prePage.willHide();
    nextPage.init();
    nextPage.willShow();
    prePage.didHide();
    prePage.destory();
    nextPage.didShow();
  }
}

export default PageManager;

检测路径匹配 Route.ts

import React from 'react';
import { matchPath } from './matchPath';

class Route {
  public path: string;

  public strict = true;

  public sensitive = true;

  public exact = true;

  public component: React.ReactElement;

  constructor(path: string, component: React.ReactElement) {
    this.path = path;
    this.component = component;
  }

  // 检测urlpath是否匹配
  match(urlPath: string): boolean {
    return matchPath(urlPath, {
      path: this.path,
      exact: this.exact,
      strict: this.strict,
      sensitive: this.sensitive,
    });
  }
}

export default Route;

Router.ts

import Route from './Route';
import Navigation from './Navigation';

abstract class Router {
  
  public routes: Route[];

  public navigation: Navigation;

  constructor() {
    this.routes = [];
    this.navigation = new Navigation(this);
  }

  registerRoutes(routes: { [key: string]: any }) {
    const paths = Object.keys(routes);
    paths.forEach(path => {
      this.routes.push(new Route(path, routes[path]));
    });
    return this;
  }

  getMatchRoute(path?: string) {
    const pathname = path ? path.replace(/^#/, '') : location.hash.replace(/^#/, '');
    let route: Route = null;
    let routeData = null;
    for (let i = 0; i < this.routes.length; i++) {
      route = this.routes[i];
      routeData = this.routes[i].match(pathname);
      if (routeData) break;
    }
    if (!routeData) return null;
    return {
      route,
      routeData,
    };
  }

  getNavigation() {
    return this.navigation;
  }

  abstract start(): void;

  abstract destory(): void;
}
export default Router;

公共工具util.ts

/**
 * 设置元素样式
 * @param el
 * @param cssProperty
 */
const setCss = (el: HTMLElement, cssProperty) => {
  const cssList = [];
  const cssKeys = Object.keys(cssProperty);
  cssKeys.forEach(key => {
    cssList.push(`${key}: ${cssProperty[key]};`);
  });
  el.style.cssText += `; ${cssList.join('')}`;
};

/**
 * 休眠指定时间
 * @param duration 
 */
const sleep = async (duration: number) => {
  return new Promise<void>(reslove => {
    setTimeout(() => {
      reslove();
    }, duration);
  });
};

/**
 * url参数解析
 * @param search 
 */
const qs = (search: string = '') => {
    const searchMatch = search
      .match(new RegExp('([^?=&]+)(=([^&]*))?', 'g'))
    const query = searchMatch && searchMatch.reduce((result, each) => {
        const [key, value] = each.split('=');
        result[key] = decodeURIComponent(value);
        return result;
      }, {}) || {};
    return query;
  };

export { setCss, sleep, qs };