import History from './History';
import {makeMutex, noConcurrent} from '../decorator/noConcurrent';
import {ctxDependConsole as console} from '../debugKit';
import {delay, appendUrlParam, toAbsolutePath} from '../operationKit';
import {customWxPromisify} from '../wxPromise';
import {supportWXCallback} from '../decorator/compatible';
const NAV_BUSY_REMAIN = 300; //实践发现,navigateTo成功回调之后页面也并未完全完成跳转,故将跳转状态短暂延长,单位:ms
let wxPromise=null, wxResolve=null; //部分API支持用户自定义覆盖,因而等到配置环节再予以实例化
let globalStore = {
env: {
os: 'ios'
}
};
wx.getSystemInfo({
success(sysInfo){
globalStore.env.os = sysInfo.system.toLowerCase().includes('ios') ? 'ios' : 'android';
}
});
/**
* 导航器
* 由于小程序只支持最多5级页面(后放宽至10级),但需求上希望维护更长的历史栈,故自行维护完整历史栈并改写默认导航操作
* 使用:详见 {@tutorial 2.2-navigate}
*/
class Navigator {
static _config = {
enableCurtain: true, //是否开启空白中转策略
curtainPage: '/pages/curtain/curtain', //空白中转页,避免自定义返回行为时出现原生上一层级内容一闪而过的现象
enableTaintedRestore: true, //是否开启实例覆盖自动恢复策略
pageRestoreHandler: null, //页面数据恢复函数
MAX_LEVEL: 10, //小程序支持打开的页面层数
oriNavOverrides: {}, //自定义覆盖部分/全部底层跳转api(wx.navigateTo、wx.redirectTo等),接口及参数格式同wx
};
static _history = new History({routes: [{url:''}]}); //完整历史栈
static _activeUnload = false; //是否为主动触发的页面卸载: true-代码主动调用导致; false-用户点击了物理返回键/左上角返回按钮导致
/**
* 安装
* @param {Object} [options] 配置
* @param {boolean} [options.enableCurtain] 是否开启空白中转策略
* @param {string} [options.curtainPage] (开启空白中转策略时)空白中转页
* @param {boolean} [options.enableTaintedRestore] 是否开启实例覆盖自动恢复策略
* @param {Navigator~PageRestoreHandler} [options.pageRestoreHandler] 页面数据恢复函数,用于
* 1. wepy实例覆盖问题,存在两级同路由页面时,前者数据会被后者覆盖,返回时需予以恢复
* 2. 层级过深时,新开页面会替换前一页面,导致前一页面数据丢失,返回时需予以恢复
* @param {number} [options.MAX_LEVEL] 小程序支持打开的页面层数
* @param {Object.<string, function>} [options.oriNavOverrides] 自定义覆盖部分/全部底层跳转api,默认为: {navigateTo: wx.navigateTo, redirectTo: wx.redirectTo, navigateBack: wx.navigateBack, reLaunch: wx.reLaunch, switchTab: wx.switchTab}
*/
static config(options={}){
//自定义配置
Object.assign(Navigator._config, options);
Navigator._history.config({correctLevel: Navigator._config.MAX_LEVEL-2});
wxPromise = customWxPromisify({overrides: Navigator._config.oriNavOverrides, dealFail: false});
wxResolve = customWxPromisify({overrides: Navigator._config.oriNavOverrides, dealFail: true});
}
/**
* 打开新页面
* @param {Object} route
* @param {string} route.url 新页面url
* @param {function} [route.success] 兼容起见支持回调,成功时触发
* @param {function} [route.fail] 兼容起见支持回调,失败时触发
* @param {function} [route.complete] 兼容起见支持回调,成功失败均触发
* @return {{succeeded:boolean, errMsg:string}} 跳转结果,格式形如:{succeeded: true, errMsg: 'ok'}
*/
@supportWXCallback //兼容success、fail、complete回调
@makeMutex({ //避免跳转相关函数并发执行
namespace:globalStore,
mutexId:'navigate',
discardRes: {
succeeded: false,
errMsg: 'discarded by noConcurrent strategy'
}
})
static async navigateTo(route){
console.log('[Navigator] navigateTo:', route);
let curPages = getCurrentPages();
let curPage = curPages[curPages.length-1];
Navigator._history.open({url: toAbsolutePath(route.url, curPage.route||curPage.__route__)});
if (Navigator._config.enableCurtain && curPages.length == Navigator._config.MAX_LEVEL-1) { //空白中转策略:倒数第二层开最后一层时,先把倒二层换成空白页,再打开最后一层
console.log('[Navigator] replace with curtain', 'time:', Date.now(), 'getCurrentPages:', getCurrentPages());
Navigator._history.savePage(Navigator._history.length-2, curPages[curPages.length-1]); //保存页面数据
await Navigator._secretReplace({url: Navigator._config.curtainPage});
console.log('[Navigator] open from curtain', 'time:', Date.now(), 'getCurrentPages:', getCurrentPages());
await Navigator._secretOpen(route);
} else if (curPages.length < Navigator._config.MAX_LEVEL) { //层级未满,直接打开
await Navigator._secretOpen(route);
} else { //层数已占满时,替换最后一层
Navigator._history.savePage(Navigator._history.length-2, curPages[curPages.length-1]); //保存页面数据
await Navigator._secretReplace(route);
}
return {succeeded: true, errMsg: 'ok'};
}
/**
* 替换当前页面
* @param {Object} route
* @param {string} route.url 新页面url
* @param {function} [route.success] 兼容起见支持回调,成功时触发
* @param {function} [route.fail] 兼容起见支持回调,失败时触发
* @param {function} [route.complete] 兼容起见支持回调,成功失败均触发
* @return {{succeeded:boolean, errMsg:string}} 跳转结果,格式形如:{succeeded: true, errMsg: 'ok'}
*/
@supportWXCallback //兼容success、fail、complete回调
static async redirectTo(route){
console.log('[Navigator] redirectTo:', route);
let curPages = getCurrentPages();
let curPage = curPages[curPages.length-1];
Navigator._history.replace({url: toAbsolutePath(route.url, curPage.route||curPage.__route__)});
await Navigator._secretReplace(route)
return {succeeded: true, errMsg: 'ok'};
}
/**
* 返回
* @param {Object} [opts]
* @param {number} [opts.delta] 返回层数
* @param {function} [opts.success] 兼容起见支持回调,成功时触发
* @param {function} [opts.fail] 兼容起见支持回调,失败时触发
* @param {function} [opts.complete] 兼容起见支持回调,成功失败均触发
* @return {{succeeded:boolean, errMsg:string}} 返回结果,格式形如:{succeeded: true, errMsg: 'ok'}
*/
@supportWXCallback //兼容success、fail、complete回调
static async navigateBack(opts={delta:1}){
console.log('[Navigator] navigateBack:', opts);
await Navigator._doBack(opts, {sysBack: false});
return {succeeded: true, errMsg: 'ok'};
}
/**
* 关闭所有页面,打开到应用内的某个页面
* @param {Object} [route]
* @param {number} [route.url] 新页面url
* @param {function} [route.success] 兼容起见支持回调,成功时触发
* @param {function} [route.fail] 兼容起见支持回调,失败时触发
* @param {function} [route.complete] 兼容起见支持回调,成功失败均触发
* @return {{succeeded:boolean, errMsg:string}} 跳转结果,格式形如:{succeeded: true, errMsg: 'ok'}
*/
@supportWXCallback //兼容success、fail、complete回调
@makeMutex({ //避免跳转相关函数并发执行
namespace:globalStore,
mutexId:'navigate',
discardRes: {
succeeded: false,
errMsg: 'discarded by noConcurrent strategy'
}
})
static async reLaunch(route){
console.log('[Navigator] reLaunch:', route);
Navigator._activeUnload = true;
await wxPromise.reLaunch(route);
await delay(NAV_BUSY_REMAIN);
return {succeeded: true, errMsg: 'ok'};
}
/**
* 跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面
* @param {Object} [route]
* @param {number} [route.url] 需要跳转的 tabBar 页面的路径
* @param {function} [route.success] 兼容起见支持回调,成功时触发
* @param {function} [route.fail] 兼容起见支持回调,失败时触发
* @param {function} [route.complete] 兼容起见支持回调,成功失败均触发
* @return {{succeeded:boolean, errMsg:string}} 跳转结果,格式形如:{succeeded: true, errMsg: 'ok'}
*/
@supportWXCallback //兼容success、fail、complete回调
@makeMutex({ //避免跳转相关函数并发执行
namespace:globalStore,
mutexId:'navigate',
discardRes: {
succeeded: false,
errMsg: 'discarded by noConcurrent strategy'
}
})
static async switchTab(route){
console.log('[Navigator] switchTab:', route);
Navigator._activeUnload = true;
await wxPromise.switchTab(route);
await delay(NAV_BUSY_REMAIN);
return {succeeded: true, errMsg: 'ok'};
}
/**
* 主动触发的页面卸载过程结束标记
* 目前似乎无法监听接口主动触发的页面卸载过程什么时候结束:
* 1. reLaunch、redirectTo等接口会立刻进行success回调,不会等待页面卸载、跳转完成再回调
* 2. redirectTo到一个分包页面时,须等待分包加载完成,然后才发生页面卸载、跳转,过程耗时不可预估
* 因而采取如下策略判断结束时机:从第一个页面卸载开始,若干延时后,认为该次操作触发的全部页面卸载过程结束
* @return {Promise<void>}
* @private
*/
@noConcurrent
static async _finishActiveUnload(){
await delay(300); //reLaunch、switchTab等可能一次性触发多个onUnload,因而须等所有onUnload均触发完毕后才能将_activeUnload置回false
Navigator._activeUnload = false;
}
/**
* 监听页面卸载过程;本质是想监听用户的返回操作(点击物理返回键/左上角返回按钮),但似乎并没有相应接口,暂借助页面onUnload过程进行判断
*/
static onPageUnload(){
if (Navigator._activeUnload) {//调用接口主动进行页面卸载,此处不再重复处理
Navigator._finishActiveUnload();
return;
}
//用户点击了物理返回键/左上角返回按钮
console.log('[Navigator] sysBack');
Navigator._doBack({delta:1}, {sysBack: true});
}
/**
* 完整历史记录
* @return {Array<History~Route>}
*/
static get history(){
return Navigator._history.routes;
}
/**
* 返回
* @param {Object} opts 返回配置
* @param {number} opts.delta 返回层数
* @param {boolean} sysBack, 是否为系统返回: true-点击了物理返回键/左上角返回按钮,触发了系统返回行为;false-接口调用,返回逻辑完全由代码控制
* @private
*/
static async _doBack(opts, {sysBack}){
let targetRoute = Navigator._history.back(opts);
let curLength = getCurrentPages().length - (sysBack ? 1 : 0); //当前实际层级(系统返回无法取消,实际层级需要减1)
console.log('[Navigator] doBack, hisLength:', Navigator._history.length, 'curLen:', curLength, 'targetRoute:', targetRoute);
let targetCurtain = Navigator._config.enableCurtain && Navigator._history.length==Navigator._config.MAX_LEVEL-1; //目标页面是否为中转空白页(空白中转策略)
let targetTainted = Navigator._config.enableTaintedRestore && targetRoute.tainted; //目标页面数据是否已被覆盖(wepy单页面实例问题)
if (Navigator._history.length < curLength) { //返回后逻辑层级<当前实际层级,则直接返回到目标层级(如 MAX+2 层调用 navigateBack({delta: 3}) )
await Navigator._secretBack({
delta: curLength-Navigator._history.length
});
if (targetCurtain) {//当前页为中转空白页(空白中转策略),则替换为目标页面
await Navigator._secretReplace(targetRoute, {extraParams: {_forcedRefresh: true}});
await Navigator._doLostRestore(targetRoute);
} else if (targetTainted) { //若目标页面实例已被覆盖(wepy单页面实例问题),则进行数据恢复
await Navigator._doTaintedRestore(targetRoute);
}
} else if (Navigator._history.length === curLength) { //返回后逻辑层级===当前实际层级
if (!sysBack || targetCurtain) {//非系统返回 (如 MAX+1 层调用navigateBack())或 当前页为中转空白页,则重定向至目标页面
await Navigator._secretReplace(targetRoute, {extraParams: {_forcedRefresh: true}});
await Navigator._doLostRestore(targetRoute);
} else if (targetTainted){ //目标页面已被覆盖
await Navigator._doTaintedRestore(targetRoute);
}
//否则,系统返回即符合预期,无需额外处理
} else { //返回后逻辑层级 > 当前实际层级,则在最后一层载入目标页面 (如 MAX+5 层返回 MAX+4 层)
await (sysBack ? Navigator._secretOpen(targetRoute, {extraParams: {_forcedRefresh: true}}) : Navigator._secretReplace(targetRoute, {extraParams: {_forcedRefresh: true}}));
await Navigator._doLostRestore(targetRoute);
}
}
/**
* 不考虑历史记录问题,实际进行打开页面操作
* @param {History~Route} route 页面参数
* @param {object} [extraParams] 向页面url额外拼接参数
* @param {number} [retryAfter] 若因webview层级错乱原因导致打开失败,则在指定毫秒后重试
* @param {number} [retryTimeout] 重试间隔大于指定毫秒时,判定打开失败,不再重试
* @private
*/
static async _secretOpen(route, {retryAfter=NAV_BUSY_REMAIN, retryTimeout=2000, extraParams=null}={}){
console.log('[Navigator] _secretOpen', route);
let openRes = await wxResolve.navigateTo({url: appendUrlParam(route.url, extraParams)});
//打开成功
if (openRes.succeeded) {
await delay(NAV_BUSY_REMAIN);
return;
}
//层级问题导致的打开失败
if (openRes.errMsg.includes('limit exceed')) {
//超出层级限制无法打开,改为替换当前页
if (getCurrentPages().length >= Navigator._config.MAX_LEVEL) {
return Navigator._secretReplace(route, {extraParams});
}
//异常报错,如:实践发现,重定向倒二层、打开最后一层 两个操作连续进行时,虽然层级实际并未超出限制,但在某些机型某些页面上仍会报层级问题
if (retryAfter < retryTimeout) { //此时,重试几次,每次间隔时间递增
console.warn('[Navigator] false limit alarm, retry after:', retryAfter, 'ms', route);
await delay(retryAfter);
return Navigator._secretOpen(route, {retryAfter: retryAfter*2, retryTimeout, extraParams});
} else { //等待足够长的时间间隔后才重试依然失败,则打开失败
console.error('[Navigator error] _secretOpen failed, res:', openRes, 'getCurrentPages:',getCurrentPages(), 'longest retry interval:', retryAfter/2);
throw openRes;
}
}
//其它原因导致的打开失败
throw openRes;
}
/**
* 不考虑历史记录问题,实际进行页面替换操作
* @param {History~Route} route
* @param {object} [extraParams] 向页面url额外拼接参数
* @private
*/
static async _secretReplace(route, {extraParams=null}={}){
console.log('[Navigator] _secretReplace', route);
Navigator._activeUnload = true;
await wxPromise.redirectTo({url: appendUrlParam(route.url, extraParams)});
await delay(NAV_BUSY_REMAIN);
}
/**
* 不考虑历史记录问题,实际进行页面返回操作
* @param {object} [opts]
* @param {number} [opts.delta] 返回层数
* @private
*/
static async _secretBack(opts={delta:1}){
console.log('[Navigator] _secretBack', opts);
Navigator._activeUnload = true;
await wxPromise.navigateBack(opts);
await delay(globalStore.env.os=='ios' ? NAV_BUSY_REMAIN*3 : NAV_BUSY_REMAIN);
}
/**
* 数据恢复:wepy实例覆盖问题,存在两级同路由页面时,前者数据会被后者覆盖,返回时予以恢复
* 此时滚动位置等界面状态均正常,恢复数据即可
* @param {History~Route} route
* @return {Promise<void>}
* @private
*/
static async _doTaintedRestore(route){
//若有自定义页面数据恢复机制,则尝试以自定义方式恢复数据
let res = Navigator._config.pageRestoreHandler && Navigator._config.pageRestoreHandler({
route,
context: 'tainted'
});
if (res instanceof Promise)
res = await res;
if (res && res.succeeded)
return;
//若自定义恢复失败,则以刷新页面的方式恢复数据(不会保留表单数据和交互状态,但至少保证页面数据不错乱)
await Navigator._secretReplace(route, {extraParams: {_forcedRefresh: true}});
}
/**
* 数据恢复:层级过深,新开页面时会替换前一页面,导致前一页面数据丢失,返回时予以恢复
* 此时页面处于刷新结束状态,表单数据和交互状态均需自行恢复
* @param {History~Route} route
* @return {Promise<void>}
* @private
*/
static async _doLostRestore(route){
//若有自定义页面数据恢复机制,则尝试以自定义方式恢复数据
Navigator._config.pageRestoreHandler && Navigator._config.pageRestoreHandler({
route,
context: 'unloaded'
});
//否则,页面保持刷新状态,暂不提供默认恢复机制
}
}
/**
* @typedef {Function} Navigator~PageRestoreHandler 页面数据恢复处理函数
* @param {History~Route} route 路由对象
* @param {string} context 数据丢失场景: tainted - 实例覆盖问题导致的数据丢失 | unloaded - 层级问题导致的数据丢失
* @return {{succeeded:boolean}} 数据恢复是否成功,格式形如:{succeeded: true}
*@example
* function pageRestoreHandler({route, context}){
* //根据route.url或其它信息获取当前页面实例
* //....
*
* //根据route.wxPage从微信原生页面实例拷贝中恢复当前页面实例数据
* switch (context){
* case 'tainted': //实例覆盖问题导致的数据丢失
* //此时滚动位置等界面状态均正常,恢复数据即可
* break;
* case 'unloaded': //层级问题导致的数据丢失
* //此时页面处于刷新结束状态,除了数据,滚动位置等界面状态最好也能一并恢复
* break;
* default:
* console.error('[pageRestoreHandler] unknown context:', context);
* }
* return {succeeded: true}
* }
*/
export default Navigator;