背景
在页面的性能优化中,资源加载是决定页面展示时间的重中之重。为了减少资源的请求,有两个常见的解决方案:
- 一个是SSR服务端渲染页面,这样可以减少页面的初始化数据请求,页面在服务端接受到请求时进行数据请求与渲染页面,最终返回给客户端一个可以直接展示的html页面(直出)。但这个方案需要服务器的支持。
- 另一方是SSG服务端生成页面。与SSR的区别是,SSG在构建时就会生成html文件,后续的数据获取需要客户端加载了页面资源后再进行。SSG的优势是首屏加载速度快,可以直接分发资源到CDN,减少服务器压力。更进一步可以使用离线包方案,客户端使用本地资源打开页面,最大化减少网络的影响。
目前我们采用了第二种方案:SSG与离线包相结合。但在动态页面中,页面快速展示后,可以看到明显的页面卡顿,这是由于页面需要从服务端获取动态数据。本文便是为了解决这一问题。
期望效果
期望能结合目前的离线包方案在页面展示时,没有卡顿,能直接获取到动态数据并展示。
注意事项:
- 目前项目中存在离线包与在线包两种环境的页面,离线包方式是通过客户端代理页面的方式实现,两个包的域名不同,所以在获取动态数据时,需要注意不同包的数据获取。
- 部分页面的数据与当前时间状态深度绑定,这种情况下应避免得页面预取数据与页面展示相隔太久。
解决方案
- 例如百度(CloudHybrid)、腾讯(VasSonic)等大厂都在公司内部研发了相应的框架,特点是客户端在请求页面时,提前缓存页面与请求服务端获取动态数据,再根据获取到的数据与缓存页面进行水合。但该方案需要全端配合,复杂度较高。
- 最简单的方案就是在客户端初始化时,后台加载一个预取数据页面,这个页面会将数据存储起来,后续前端使用时直接调用。这个方案大幅前置了数据预取的过程,所以在页面首屏加载后,仍需要再次请求接口更新数据,防止期间数据变动,且发生数据变动时会导致页面数据闪动。
由于人力与成本考虑,项目使用第二种方案,下面介绍详细流程:
- 客户端在用户打开APP,初始化应用时,在后台加载预取数据页面。
- 预取数据页面从服务端获取动态数据。
- 将数据存储至本地缓存。这里建议使用localforage库,该库会自动选择IndexedDB、WebSQL、localStorage中的一种存储方式,且自动进行类型转换。
- 进入应用后,用户访问功能页面。
- 页面先使用本地缓存数据展示页面。这里注意由于页面使用ssg的方案,如果展示加载的骨架屏,依然会出现数据从无到有的闪动出现。所以需要直接将页面首屏置空,等js加载完成获取到本地数据后再显示页面(使用useEffect触发)。
- 由于动态数据可能更新,所以在页面加载后,需要再次请求服务端接口,更新页面(如数据变动,会造成页面闪动)。
- 最后将更新后的数据再次存入本地存储。
参考
附录
// 结合localforage的hooks
import { useCallback, useEffect, useRef, useState } from 'react';
import localforage from 'localforage';
let EventMap = new Map<String, Function[]>();
class EventEmitter {
static on<T>(key: string, callback: (value: T) => void) {
if (EventMap.has(key)) {
EventMap.get(key)?.push(callback);
} else {
EventMap.set(key, [callback]);
}
return () => {
const funcList = EventMap.get(key);
EventMap.set(
key,
funcList!.filter((func) => func !== callback),
);
};
}
static emit<T>(key: string, value: T) {
if (EventMap.has(key)) {
EventMap.get(key)?.forEach((func) => {
func(value);
});
}
}
}
export function useLocalStorage<T>(
key: string,
defaultValue: T,
pathname?: string,
) {
const refKey = useRef(key);
const refInit = useRef(false);
function getStoredValue() {
return new Promise<T>((resolve) => {
localforage
.getItem(refKey.current)
.then((raw) => {
if (typeof raw !== 'undefined' && raw !== null) {
resolve(raw as T);
} else {
resolve(defaultValue);
}
})
.catch((e) => {
console.error(e);
resolve(defaultValue);
});
});
}
const [state, setState] = useState<T>(defaultValue);
const [initSetList, setInitSetList] = useState<Function[]>([]);
useEffect(() => {
const path = pathname || location.pathname.replace(/\//g, '_');
refKey.current = `${path}-${key}`;
getStoredValue().then((value) => {
setState(value);
if (initSetList.length) {
initSetList.forEach((func) => func());
}
});
refInit.current = true;
}, [key]);
useEffect(() => {
const handleEventEmitter = (eventValue: T) => {
if (JSON.stringify(eventValue) !== JSON.stringify(state)) {
updateState(eventValue, true);
}
};
const removeHandler = EventEmitter.on<T>(key, handleEventEmitter);
return () => {
removeHandler();
};
}, [state]);
const updateState = useCallback(
(value: T, isEmit?: boolean) => {
function updateForageState(currentState: T) {
setState(currentState);
if (typeof currentState === 'undefined') {
localforage.removeItem(refKey.current);
} else {
localforage
.setItem(refKey.current, currentState)
.then(() => {
if (!isEmit) {
console.log('emit');
EventEmitter.emit(key, currentState);
}
})
.catch((e) => {
console.error(e);
});
}
}
if (!refInit) {
setInitSetList((list) => [
...list,
updateForageState.bind(useLocalStorage, value),
]);
} else {
updateForageState(value);
}
},
[refKey, refInit],
);
return [state, updateState] as const;
}