序
在传统页面中,每个url都是和页面绑定的,即使是单页面,也应该有这种习惯。因此我们把Page对象作为不同的url,通过页面的切换来更改url。url的变换代表着用户在该应用中的探索路径,用户可以随时的后退到上一个页面,或者前进到后一个页面。从上面的history篇章中,使用浏览器的提供的api可以很好的解决这个问题。然而在实际的使用中存在一个问题:比如用户从一个页面跳转到另外一个页面,然后后退到前一个页面,发现上一个页面并不是他之前访问的页面了,原因如下:
- 后退到上一个页面,页面重新渲染时得到的数据和上一次的数据不一样;
- 后退到上一个页面,无法让页面后退到之前浏览的那个位置;
- 渲染的页面有问题。
一般第三点都不会发生,第二点也不是很重要,如果在切换页面的时候存储跳转时的滚动地址,也可以很好的解决,第一点确实是个值得思考的问题,原因如下:
- 第一次页面进入请求了,第二次是否还需要去请求数据;
- 如果第二次不去请求,直接渲染第一次显示的数据,那怎么保证页面的数据是最新的呢;
- 按照2的做法,意味着页面不是重新渲染,恢复的页面以及dom绑定的事件和最后离开页面的时候应该是保持一致的;
- 按照3的做法,我们必须要存储历史记录中的页面相关数据,比如缓存dom,缓存data等;
- 关于缓存data,是否可以把data完全交给history.state,它不就是为了缓存页面数据而存在的么?不过这里并非采用了history.state,因为当我们要任意更改历史记录的时候,之前没有保存在历史记录的data就无法被还原,通过内存存储就能增加灵活性,当然history.state可以作为备案,要求缓存的数据的可序列化的。
合理的历史记录一般不会有太多级,而且把历史记录中的页面储存起来,可以加快页面的渲染速度,增强用户体验。
需求
通过上面的讨论,我们需要实现如下的目标:
- 针对历史记录创建一层数据缓存,用来实现页面导航的时候可以快速切换页面,并将其还原;
- 数据缓存中主要存储Page对象中的DOM,事件和data;
- 要保证缓存数据和历史记录的数据保持高度一致,当新的页面添加到历史记录的时候,缓存记录就会新增一条记录,删除历史记录时,就删除缓存中对应的页面数据;
- 为了保证页面数据和后台数据的一致性,提供了一个可选的方法,主要是为了进行数据更新以及页面的局部更新。这个可选方法只有在快速切换的时候才会调用。
实现思路
之前的Page对象实现中, 每次渲染一个新页面的时候,都会new一个指定的页面,现在我们只要在切换页面的时候把它缓存起来,等到需要的时候,再把它还原回去。只有重新创建一个页面的时候或者历史记录中没有缓存页面的时候,才会去new一个新Page对象。现在我们在Page的原型对象中新增两个方法:
- save(), 将数据缓存起来;
- restore(dom), 将数据还原到页面上。
同时页面的生命周期也产生了变化,因为从历史记录中还原页面不再进行render,getDomObj和beforeInit流程了,通过restore方法,就快速的将页面还原到浏览器了,为了保证数据一致性,添加了一个afterRestore可选方法,进行局部更新,保证前后端数据更新交互。
save方法是对Page对象的浅删除操作,将剩余的必要信息缓存起来。之前的destroy是深度删除操作,是为了删除所有的引用。相对应的,要进行缓存的Page对象的生命周期是这样的:
- restore(dom):将Page还原到页面上;
- 如果存在afterRestore方法,调用之后,通过局部更新从而保证页面一致;
- 调用init方法,初始化该页面需要引入的插件;
- 日常的业务处理,等待用户切换页面;
- 首先调用dispose方法,这个方法主要是处理引入的插件的销毁;
- save(),将数据缓存起来, 如果存在beforeSave方法,先调用beforeSave。
如下代码
save: function () {
this.isSave = true; // 设置状态
this.destroy(false); // 浅删除
this.parent.history.save(this); // 保存页面于历史缓存中
if (typeof this.beforeSave === "function") this.beforeSave();
this.nodes = []; // 把dom全部缓存起来
for (var i = 0; i < this.parentDom.childNodes.length; i++) {
this.nodes.push(this.parentDom.childNodes[i]);
}
},
restore: function (dom) {
dom.innerHTML = ''; // 清除
// 保证dom是原来的dom
var fragment = this.template.content;
for (var i = 0; i < this.nodes.length; i++) {
fragment.appendChild(this.nodes[i]);
}
dom.appendChild(fragment);
this.nodes.length = 0;
if (typeof this.afterRestore === "function") this.afterRestore();
this._beforeInit();
// 这方法后紧接着就是绑定事件。
},
// 调用destroy(true)仅在历史记录没有该页面的时候调用,否则都是浅删除
destroy: function (isClean) {
this.dispatchEvent("_dispose");
if (isClean) {
this.eventDispatcher.destroy();
this._removeDom();
this.template = null;
this.nodes.length = 0;
this.parent = null;
this.data = {};
this.parentDom = null;
}
},
// _dispose方法放在_init里面定义,仅能被调用一次
_init: function () {
this.isSave = false;
this.eventDispatcher.clearListenerByType("_dispose"); // 清除这个事件,然后绑定新的
this.attachDiyEvent("_dispose", function () {
if (typeof this.dispose === "function") this.dispose();
this._removeEventListener();
this.http.destroy();
}, true) // 调用后销毁
if (typeof this.init === "function") this.init.apply(this, arguments);
},
新增一个HistoryStorage对象,用于存储页面缓存,并且修改原先的History对象, 让History专注于交互,HistoryStorage专注于存储,并将之前的popstate事件转到History对象下, 修改如下
function History(app) {
this.app = app;
this.appStorage = new HistoryStorage();
this.skipPop = false; // 是否要过滤popstate事件
this.popBack = null; // 过滤popstate事件时执行的可变方法
// 代表监听popstate实现
window.addEventListener("popstate", this._popHandler.bind(this));
}
History.prototype = {
constructor: History,
// 设置锁屏与否
setLock: function (isLock) {
this.appStorage.setLock(isLock);
},
// 获取是否锁屏状态
getLock: function () {
return this.appStorage.isLock
},
// 恢复页面
restore: function () {
this.appStorage.restore();
},
pushState: function (page, option) {
this.appStorage.pushState(page, option);
},
replaceState: function (page, option) {
this.appStorage.replaceState(page, option);
},
_popHandler: function (ev) {
var app = this.app, that = this;
if (this.getLock()) return this.restore();
if (this.skipPop)
if (typeof this.popBack === "function") return this.popBack();
// 改变hash也会触发popstate事件
if (location.pathname == app.currentPage.url) return;
// 传入三个方法,分别代表当前位置是否在首页,如果是首页执行第二个方法,不是首页则执行第三个方法
this.appStorage.popOperation(function (name, nameList) {
return nameList.indexOf(name) === 0;
}, function (component) {
if (typeof app.outofHistory === "function") app.outofHistory();
setTimeout(function () {
that.pushState(component);
app._renderPage(component, true); // 渲染页面
}, 2000);
}, function (component, config, str) {
that.renderBackComponent(app.component, config, str);
});
}
};
History的实际操作转向StorageHistory, 为了后期能更好的扩展。
function HistoryStorage() {
this.history = []; // 存放url数组,对应历史记录
this.components = []; // 存放Page对象
this.datas = []; // 存放数据,这里的数据和Page对象,还有包含其它数据
this.index = null; // 指向当前的位置
this.isLock = false;
}
HistoryStorage.prototype = {
constructor: HistoryStorage,
popOperation: function (filter, elseFn, operationFn) {
var name = this._getCurrentHistoryName();
if (filter(name, this.history)) {
elseFn(this.components[this.index]);
} else {
var urlObj = this._getSurroundUrl(),
str, config,
component = this.components[this.index];
component.save(); // 保存页面
if (urlObj.prev === name) {
config = this.datas[--this.index];
str = "out";
} else {
config = this.datas[++this.index];
str = "in";
}
operationFn(this.components[this.index], config, str);
}
},
// 指向pushState和replaceState的时候,把数据和page对象保存起来
replaceState: function (component, option) {
option = option || {};
var url = option.url || component.url;
// 销毁页面
if (this.components[this.index]) this.components[this.index].destroy(true);
if (location.protocol !== "file:") history.replaceState(option, "", url);
this.datas[this.index] = option;
this.components[this.index] = component;
this.history[this.index] = url;
},
pushState: function (component, option) {
option = option || {};
var url = this.type == "app" ?
option.url || component.url :
location.pathname + "?popup=" + component.name,
components = this.components,
datas = this.datas;
if (components[this.index] && !components[this.index].isSave)
components[this.index].save();
if (location.protocol !== "file:") history.pushState(option, "", url);
if (typeof this.index === "number") {
for (var i = components.length - 1; i >= this.index + 1; i--) {
// 移除页面的时候销毁页面
if (components[i]) components[i].destroy(true);
}
var nextIndex = ++this.index,
len = components.length;
components.splice(nextIndex, len - nextIndex, component);
datas.splice(nextIndex, len - nextIndex, option);
this.history.splice(nextIndex, len - nextIndex, url);
} else {
this.index = 0;
this.components.push(component);
this.history.push(url);
this.datas.push(option);
}
},
// 其它操作和之前的类似,这里省略
}
[案例地址]http://www.renxuan.tech:2006
结语
通过history和Page配合,让页面能够快速的切换,极大的提升了页面的切换速度,让webapp更像原生app。
推广
底层框架开源地址:https://gitee.com/string-for-100w/string
演示网站: https://www.renxuan.tech/