实现转场动画
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 };