概述
大部分为了做web的性能优化,针对 APP 内嵌的 H5 页面,有一种比较常见且有效的方式就是接入离线包,通过把页面加载需要的静态资源js、css、图片(html一般不放)等
静态资源提前下载到客户端本地,避免页面加载时静态资源网络请求的开销,从而来提高页面的加载速度。大部分是提高了首屏速度,减少资源从网络下载。
技术实现
Web有个 onInterceptRequest
方法,当Web组件加载url之前触发该回调,用于拦截url并返回响应数据。也就是说,当请求资源的时候,会经过这个方法,我们可以根据某个规则,判断本地是否有离线文件,然后把这个文件返回给h5即可,下面的例子是把h5的图片,全部拦截,替换咱们本地图片,不用去网上下载来,返回本地图片的WebResourceResponse。这个方法如果返回null,那么还会继续从网络加载
.onInterceptRequest((event) => {
if (event) {
// 是图片的正则表达式
const imageRegex = /.(jpg|jpg#|jpeg|png|gif|bmp|webp)(?:?.*)?$/i;
if (imageRegex.test(event.request.getRequestUrl())) {
console.log(`onInterceptRequest=> ${event.request.getRequestUrl()}`)
// 图片,全部替换成本地的一个图片
let responseWeb: WebResourceResponse = new WebResourceResponse();
// ResponseDataID
// 构造响应数据
// 为自定义的JavaScript请求响应生成 CodeCache:自定义请求响应的资源类型如果是JavaScript脚本,
// 可以在响应头中添加“ResponseDataID”字段,Web内核读取到该字段后会在为该JS资源生成CodeCache,
// 加速JS执行,并且ResponseData如果有更新时必须更新该字段。不添加“ResponseDataID”字段的情况下默认不生成CodeCache。
responseWeb.setResponseHeader([
{
// 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
headerKey: "ResponseDataID",
headerValue: "0000000000001"
}]);
// 1.要设置的资源响应数据。string表示HTML格式的字符串。
// 2. number表示文件句柄, 此句柄由系统的Web组件负责关闭。
// 3. Resource表示应用rawfile目录下文件资源。
// 4. ArrayBuffer表示资源的原始二进制数据。
responseWeb.setResponseData($rawfile('img1.webp'));
responseWeb.setResponseMimeType('image/webp');
responseWeb.setResponseEncoding('utf-8');
responseWeb.setResponseCode(200);
responseWeb.setReasonMessage('OK');
responseWeb.setResponseIsReady(true)
return responseWeb
}
}
// 返回响应数据则按照响应数据加载,无响应数据则返回null表示按照原来的方式加载
return null;
})
首先模拟h5打包给到客户端,模拟 打开juejin.cn/
我们第一次打开juejin.cn 然后在onInterceptRequest中去下载css,js,图片等资源下载到本地
// 假设这个都是静态资源的前缀
let PRX = `https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web`
.onInterceptRequest((event) => {
if (event) {
let responseWeb = OfflineUtil.instance.getLocalFile(event.request.getRequestUrl());
return responseWeb
}
return null
})
getLocalFile(url: string): WebResourceResponse | null {
// 第一次需要先下载,模拟
if (url.includes(PRX)) {
this.download(url)
}
}
下面这是下载的
download(url: string) {
// 每一个httpRequest对应一个HTTP请求任务,不可复用
let httpRequest = http.createHttp();
// 用于订阅HTTP响应头事件
httpRequest.on('headersReceive', (header: Object) => {
console.info(TAG, 'header: ' + JSON.stringify(header));
});
// 用于订阅HTTP流式响应数据接收事件
let res = new ArrayBuffer(0);
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
const newRes = new ArrayBuffer(res.byteLength + data.byteLength);
const resView = new Uint8Array(newRes);
resView.set(new Uint8Array(res));
resView.set(new Uint8Array(data), res.byteLength);
res = newRes;
});
// 用于订阅HTTP流式响应数据接收完毕事件
httpRequest.on('dataEnd', () => {
console.info(TAG, '下载完成');
// 下载完成之后,保存
this.saveFile(res, url)
});
let streamInfo: http.HttpRequestOptions = {
method: http.RequestMethod.GET, // 可选,默认为http.RequestMethod.GET
}
// 填写HTTP请求的URL地址,可以带参数也可以不带参数。URL地址需要开发者自定义。请求的参数可以在extraData中指定
httpRequest.requestInStream(url, streamInfo).then((data: number) => {
console.info("requestInStream OK!");
console.info('ResponseCode :' + JSON.stringify(data));
// 取消订阅HTTP响应头事件
httpRequest.off('headersReceive');
// 取消订阅HTTP流式响应数据接收事件
httpRequest.off('dataReceive');
// 取消订阅HTTP流式响应数据接收完毕事件
httpRequest.off('dataEnd');
// 当该请求使用完毕时,调用destroy方法主动销毁
httpRequest.destroy();
}).catch((err: Error) => {
console.info("requestInStream ERROR : err = " + JSON.stringify(err));
});
}
然后保存到本地
saveFile(arrayBuffer: ArrayBuffer, urlReq: string) {
try {
let downUrl: url.URL = url.URL.parseURL(urlReq);
// 得到 文件在url中的名字
// "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/4ab562f.js"
// 比如上面得到的就是 4ab562f.js
let name: string = downUrl.pathname.split('/').pop() as string;
console.log(TAG, `名字是 ${name}`);
// 获取真正的路径
let path = urlReq.replace(PRX, '');
let pathDown = this.offlineFilePath + path;
// 打开,如果不存在 直接创建
console.log(TAG, `下载的路径是 ${pathDown}`);
// /data/storage/el2/base/haps/entry/cache/offline/static/rc-client-security-web-glue/1.0.0.19/sdk-glue.js
// 再判断 parent是否存在,不存在创建parent目录 ,否则再openSync会报错
this.makeParentDir(path)
let file = fs.openSync(pathDown, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
let fd = file.fd;
fs.writeSync(fd, arrayBuffer)
fs.closeSync(file)
} catch (e) {
console.error(JSON.stringify(e) + urlReq)
}
}
我们看一下下面的图,这就是下载下来的静态资源
开始从刚才下载的资源去给到h5中
在webonInterceptRequest
,
- 判断是否是前缀的静态资源
- 判断对应文件是否存在
setResponseMimeType
需不需要设置看自己的h5,如果需要,自己实现
getLocalFile(url: string): WebResourceResponse | null {
// 第一次需要先下载,模拟
if (url.includes(PRX)) {
// this.download(url)
}
// 表明是在离线包中
try {
// 如果包含这个前缀,就去寻找
if (url.includes(PRX)) {
// 判断 离线包下的这个文件是否存在
let path = this.offlineFilePath + url.replace(PRX, '')
if (fs.accessSync(path)) {
// 证明存在
let file = fs.openSync(path)
let responseWeb: WebResourceResponse = new WebResourceResponse();
// 加速JS执行,并且ResponseData如果有更新时必须更新该字段。不添加“ResponseDataID”字段的情况下默认不生成CodeCache。
// 把这个responseDataID存在全局的HashMap中
let responseDataID = '1'
if (OfflineUtil.instance.map.hasKey(path)) {
responseDataID = OfflineUtil.instance.map.get(path)
} else {
responseDataID = this.generateUniqueID().toString()
OfflineUtil.instance.map.set(path, responseDataID)
}
responseWeb.setResponseHeader([
{
// 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
headerKey: "ResponseDataID",
headerValue: responseDataID
}
]);
// 2. number表示文件句柄, 此句柄由系统的Web组件负责关闭。
responseWeb.setResponseData(file.fd);
// 好像这里不用填写MimeType,如果有的网址强校验这个,请根据path来获取真正的mimetype
// responseWeb.setResponseMimeType();
responseWeb.setResponseEncoding('utf-8');
responseWeb.setResponseCode(200);
responseWeb.setReasonMessage('OK');
responseWeb.setResponseIsReady(true);
console.log('给的是本地数据 ' + url)
return responseWeb;
}
}
} catch (e) {
console.error(`错误了=> ${JSON.stringify(e)}`)
}
return null
}
源码位置github
其实就两个文件
OfflineWebPage
import { webview } from '@kit.ArkWeb';
import { OfflineUtil } from './OfflineUtil';
@Entry
@Component
struct OfflineWebPage {
@State message: string = 'Hello World';
controller: webview.WebviewController = new webview.WebviewController();
startTime: number = 0
aboutToAppear(): void {
OfflineUtil.instance.init()
}
build() {
RelativeContainer() {
Web({ src: 'https://juejin.cn/', controller: this.controller })
.fileAccess(true)// 是否可以读存储的html,api11 默认是true ,api12 默认是false $rawfile 不受影响
.zoomAccess(false)// 设置是否支持手势进行缩放 默认允许,咱们app一般不允许
.databaseAccess(true)//设置是否开启数据库存储API权限,默认不开启
.cacheMode(CacheMode.Online)// 设置成只从网络读取,不读取缓存,这样咱们可以看出能减少多少ms
.onPageBegin(() => {
this.startTime = new Date().getTime()
})
.onPageEnd(() => {
console.log(`从onPageBegin到onPageEnd 事件是 ${new Date().getTime() - this.startTime}`)
})
.onInterceptRequest((event) => {
if (event) {
let responseWeb = OfflineUtil.instance.getLocalFile(event.request.getRequestUrl());
return responseWeb
}
return null
})
.width('100%')
.height('100%')
}
.height('100%')
.width('100%')
}
}
OfflinUtil
import { HashMap, url } from '@kit.ArkTS';
import { http } from '@kit.NetworkKit';
import { fileIo as fs } from '@kit.CoreFileKit';
let TAG = 'OfflineUtil'
let PRX = `https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web`
export class OfflineUtil {
static instance: OfflineUtil = new OfflineUtil();
map: HashMap<string, string> = new HashMap()
// 要把 h5 下载到这里
offlineFilePath = getContext(this).filesDir + '/offline';
private constructor() {
}
init() {
// 文件夹是否存在
let exists = fs.accessSync(this.offlineFilePath)
if (!exists) {
// 创建文件夹
fs.mkdirSync(this.offlineFilePath)
}
}
download(url: string) {
// 每一个httpRequest对应一个HTTP请求任务,不可复用
let httpRequest = http.createHttp();
// 用于订阅HTTP响应头事件
httpRequest.on('headersReceive', (header: Object) => {
console.info(TAG, 'header: ' + JSON.stringify(header));
});
// 用于订阅HTTP流式响应数据接收事件
let res = new ArrayBuffer(0);
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
const newRes = new ArrayBuffer(res.byteLength + data.byteLength);
const resView = new Uint8Array(newRes);
resView.set(new Uint8Array(res));
resView.set(new Uint8Array(data), res.byteLength);
res = newRes;
});
// 用于订阅HTTP流式响应数据接收完毕事件
httpRequest.on('dataEnd', () => {
console.info(TAG, '下载完成');
// 下载完成之后,保存
this.saveFile(res, url)
});
let streamInfo: http.HttpRequestOptions = {
method: http.RequestMethod.GET, // 可选,默认为http.RequestMethod.GET
}
// 填写HTTP请求的URL地址,可以带参数也可以不带参数。URL地址需要开发者自定义。请求的参数可以在extraData中指定
httpRequest.requestInStream(url, streamInfo).then((data: number) => {
console.info("requestInStream OK!");
console.info('ResponseCode :' + JSON.stringify(data));
// 取消订阅HTTP响应头事件
httpRequest.off('headersReceive');
// 取消订阅HTTP流式响应数据接收事件
httpRequest.off('dataReceive');
// 取消订阅HTTP流式响应数据接收完毕事件
httpRequest.off('dataEnd');
// 当该请求使用完毕时,调用destroy方法主动销毁
httpRequest.destroy();
}).catch((err: Error) => {
console.info("requestInStream ERROR : err = " + JSON.stringify(err));
});
}
saveFile(arrayBuffer: ArrayBuffer, urlReq: string) {
try {
let downUrl: url.URL = url.URL.parseURL(urlReq);
// 得到 文件在url中的名字
// "https://lf-web-assets.juejin.cn/obj/juejin-web/xitu_juejin_web/4ab562f.js"
// 比如上面得到的就是 4ab562f.js
let name: string = downUrl.pathname.split('/').pop() as string;
console.log(TAG, `名字是 ${name}`);
// 获取真正的路径
let path = urlReq.replace(PRX, '');
let pathDown = this.offlineFilePath + path;
// 打开,如果不存在 直接创建
console.log(TAG, `下载的路径是 ${pathDown}`);
// /data/storage/el2/base/haps/entry/cache/offline/static/rc-client-security-web-glue/1.0.0.19/sdk-glue.js
// 再判断 parent是否存在,不存在创建parent目录 ,否则再openSync会报错
this.makeParentDir(path)
let file = fs.openSync(pathDown, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
let fd = file.fd;
fs.writeSync(fd, arrayBuffer)
fs.closeSync(file)
} catch (e) {
console.error(JSON.stringify(e) + urlReq)
}
}
makeParentDir(path: string) {
// 拿到 parent 路径
// let parentPath = pathDown.replace(`/${pathDown.split('/').pop() as string}`, "")
// let exists = fs.access(parentPath)
// console.log('parent=' + parentPath)
// if (!exists) {
// // 有可能是多级目录
// fs.mkdirSync(parentPath, true)
// }
try {
let temp = ''
let array = path.split('/')
array.pop()
array.forEach((value, index, array) => {
if (value != "") {
temp = temp + '/' + value
let exists = fs.accessSync(this.offlineFilePath + temp)
console.log(`这个是否存在 ${this.offlineFilePath + temp} + ${exists}`)
if (!exists) {
console.log(`创建 ${this.offlineFilePath + temp}`)
fs.mkdirSync(this.offlineFilePath + temp)
console.log(`创建 成功`)
}
}
})
} catch (e) {
console.error(JSON.stringify(e) + path)
}
}
getLocalFile(url: string): WebResourceResponse | null {
// 第一次需要先下载,模拟
if (url.includes(PRX)) {
// this.download(url)
}
// 表明是在离线包中
try {
// 如果包含这个前缀,就去寻找
if (url.includes(PRX)) {
// 判断 离线包下的这个文件是否存在
let path = this.offlineFilePath + url.replace(PRX, '')
if (fs.accessSync(path)) {
// 证明存在
let file = fs.openSync(path)
let responseWeb: WebResourceResponse = new WebResourceResponse();
// 加速JS执行,并且ResponseData如果有更新时必须更新该字段。不添加“ResponseDataID”字段的情况下默认不生成CodeCache。
// 把这个responseDataID存在全局的HashMap中
let responseDataID = '1'
if (OfflineUtil.instance.map.hasKey(path)) {
responseDataID = OfflineUtil.instance.map.get(path)
} else {
responseDataID = this.generateUniqueID().toString()
OfflineUtil.instance.map.set(path, responseDataID)
}
responseWeb.setResponseHeader([
{
// 格式:不超过13位纯数字。js识别码,Js有更新时必须更新该字段
headerKey: "ResponseDataID",
headerValue: responseDataID
}
]);
// 2. number表示文件句柄, 此句柄由系统的Web组件负责关闭。
responseWeb.setResponseData(file.fd);
// 好像这里不用填写MimeType,如果有的网址强校验这个,请根据path来获取真正的mimetype
// responseWeb.setResponseMimeType();
responseWeb.setResponseEncoding('utf-8');
responseWeb.setResponseCode(200);
responseWeb.setReasonMessage('OK');
responseWeb.setResponseIsReady(true);
console.log('给的是本地数据 ' + url)
return responseWeb;
}
}
} catch (e) {
console.error(`错误了=> ${JSON.stringify(e)}`)
}
return null
}
generateUniqueID(): string {
// 获取当前时间戳(毫秒)
const timestamp = Date.now(); // 当前时间戳
const randomNum = Math.floor(Math.random() * 1000); // 生成 0 到 999 之间的随机数
// 将时间戳和随机数组合成一个字符串
const uniqueID = (timestamp % 1000000000000) + randomNum; // 确保不超过 13 位
return uniqueID.toString();
}
}