Hybrid 架构演进
顺着工作经验,来讲一下实际工作中hybrid方案的演进,其核心目标就一个,实现页面秒开的效果,以此来获得好的用户体验。
1.0 优化资源加载
最开始我们网页是直出的,除了设置了简易的缓存头,其它的全部考浏览器自己的缓存策略。 所以整个页面的加载时间就如下图 用户看到所消耗时间 = webview初始化 + 资源请求 + 代码解析 + 浏览器render
所以我们在浏览器中即使打开 webview 快的情况下也得 1500ms左右,所以我们公司这一阶段主要针对以下两段做了动作
- 资源解析
- 资源加载
资源解析
发起一个请求的链路是 DNS - > TCP - > TLS
- DNS 是域名解析服务器,负责解析域名背后的 IP 地址
- TCP 是用来和服务器建立连接,这一块就是老生常谈的 三次握手了 RTT = 三次握手
- TLS 是用来建立加密传输数据,HTTPS握手
DNS 域名解析时间据统计第一次一般是20 - 80 ms,第二次由于 DNS 缓存机制,约等于 0ms.已知乎为例,第一次 DNS 查询时间为14.5ms,第二次就是 2ms了。 (补充一个点:DNS 缓存的失效时间,不同浏览器不同过期时间不同, TTL 大多数都在 1 -5 分钟 )


所以优化方向就出来了,尽可能的 DNS 预解析和域名提前建立链接。
- 提前解析
api域名 - 静态资源的
CDN域名 - 跳下一页面
一个完整的域名建立连接大概花时(180~300ms) = DNS(50ms) + TCP(80ms) + TLS(50ms)
优化点一: HTML本身页面,使用 prefetch/preconnect,遇到这种标签就提前预解析域名,节省掉 DNS 解析的时间。
<!-- html解析之后触发, DNS 解析 -->
<link rel="dns-prefetch" href="//www.zhihu.com" /> // 预解析知乎域名
<!-- html解析之后触发,DNS + TCP + TLS, 更强 -->
<link rel="preconnect" href="//www.baidu.com" /> // 预解析百度域名但预解析有一定的弊端,就是无法预解析 入口文档的 DNS ,比如我 app 内 入口文档是 https://www.baidu.com, 那么这块的DNS 时间是无法解析的。
我们可以从浏览器的网络面板上看到加载过程

优化点二: 让 APP 利用 OkHttp 创建一个连接池,因为能完整支持http1.1与HTTP2的功能,也就支持连接复用。
1. 只放关键的域名,域名收敛
2. 维护连接池,动态维护池子预解析域名
3. 使用 head 请求,请求方式最轻量,完成整个HTTP 整个链路,DNS + TCP + TLS资源加载
优化点三: H5加载层面优化
既然我们的目的是为了让 MCP 时间更快的到来,所以我们做了以下优化点
- 资源加载顺序,先加载 CSS、 后加载 JS, js文件使用 defer 属性
<!-- 资源的下载几乎是同时进行的
- css 解析完成后,cssom 构建完成后才会加载 js
- js 的加载(现在浏览器在后台并行下载)和执行解析会阻塞 html parser,所以script 标签会有 defer
- 资源的下载不会影响 html文件的下载
- html的解析流程是: css->cssom cssom & dom树 -->
render tree -->
<head>
<link href="A.css" rel="stylesheet" />
<!-- 下载+构建 CSSOM -->
<link href="B.css" rel="stylesheet" />
<script src="a.js" defer></script>
<script src="b.js" defer></script>
<script src="c.js" defer></script>
<script src="d.js" defer></script>
</head>
- 异步组件/资源的加载,脚手架split
// 异步加载资源
import("./component.js").then((module) => {});- 首屏使用 SSR
// ~/nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 1. 首页:SSR 模式
// 服务端渲染,确保首屏加载速度和 SEO
"/": { ssr: true },
// 2. 其他全平台通配符页面:SPA 模式
// 例如 '/about', '/products/1' 等页面都将作为纯客户端渲染
"/**": { ssr: false },
},
});- 使用http2协议&静态资源 CDN 加速

- 高效资源替代,判定是否支持 webp
/**
* 客户端是否支持webp
*/
get canUseWebp(): boolean {
return (!this.isIOS && !this.isAndroid) || (this.isIOS && check.compareVersion(this.iosVersion, '14.0.0') >= 0) || (this.isAndroid && check.compareVersion(this.androidVersion, '4.2.0') >= 0)
}
// 通过阿里云转换成 webp 格式
function convertMore(url: string) {
const convertWebp: boolean = env.canUseWebp
if (convertWebp) {
if (url.indexOf(WEBP_SUFFIX) < 0) {
url += '/format,webp'
}
} else {
if (url.indexOf(WEBP_SUFFIX) > 0) {
url += '/format,jpg'
} else if (url.indexOf(PNG_SUFFIX) > 0) {
url += '/format,jpg'
}
}
if (url.indexOf('jpg') > 0 && !convertWebp) {
url += '/interlace,1'
}
url = url.replace('http://', 'https://')
return url
}- 同步渲染采用chunk编码(这个没用过) transfer-encoding chunked
app.get('/page', async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
// 立即输出首屏骨架
res.write(`
<!DOCTYPE html>
<html>
<head><link rel="stylesheet" href="/static/xxx.css"></head>
<body>
<div id="app">加载中...</div>
`);
res.flush(); // 重要:强制发送第一个 chunk
// 模拟长耗时业务 API
const data = await fetchBusinessAPI();
// 输出数据部分,前端可用 JS 接收并渲染
res.write(`
<script>
window.__DATA__ = ${JSON.stringify(data)};
// 或者直接渲染到 DOM
document.getElementById('app').innerHTML = render(data);
</script>
</body>
</html>
`);
res.end();
});- 使用代偿加载机制,骨架屏+页面 资源解析 loading 骨架屏方案 boneyard 自动化生成骨架屏
vue-content-loader
webview 初始化完成,显示加载进度
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="Pragma" content="no-cache">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=0,viewport-fit=cover">
<script>
</script>
<title></title>
</head>
<style>
._page-loading-wrapper {
opacity: 1;
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
align-items: center;
padding-bottom: 15%;
justify-content: center;
display: flex;
}
._page-loading-content {
display: flex;
justify-content: center;
align-items: center;
width: 65px;
height: 65px;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.5);
}
.juhua-loading {
position: relative;
width: 40px;
height: 40px;
}
.juhua-loading .jh-circle-ios {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.juhua-loading .jh-circle-ios:before {
content: '';
display: block;
margin: 5% auto;
width: 8%;
height: 25%;
background-color: #fff;
border-radius: 30%;
-webkit-animation: jh-circleFadeDelay-ios 1.2s infinite ease-in-out both;
animation: jh-circleFadeDelay-ios 1.2s infinite ease-in-out both;
}
.juhua-loading .jh-circle2 {
-webkit-transform: rotate(30deg);
-ms-transform: rotate(30deg);
transform: rotate(30deg);
}
.juhua-loading .jh-circle3 {
-webkit-transform: rotate(60deg);
-ms-transform: rotate(60deg);
transform: rotate(60deg);
}
.juhua-loading .jh-circle4 {
-webkit-transform: rotate(90deg);
-ms-transform: rotate(90deg);
transform: rotate(90deg);
}
.juhua-loading .jh-circle5 {
-webkit-transform: rotate(120deg);
-ms-transform: rotate(120deg);
transform: rotate(120deg);
}
.juhua-loading .jh-circle6 {
-webkit-transform: rotate(150deg);
-ms-transform: rotate(150deg);
transform: rotate(150deg);
}
.juhua-loading .jh-circle7 {
-webkit-transform: rotate(180deg);
-ms-transform: rotate(180deg);
transform: rotate(180deg);
}
.juhua-loading .jh-circle8 {
-webkit-transform: rotate(210deg);
-ms-transform: rotate(210deg);
transform: rotate(210deg);
}
.juhua-loading .jh-circle9 {
-webkit-transform: rotate(240deg);
-ms-transform: rotate(240deg);
transform: rotate(240deg);
}
.juhua-loading .jh-circle10 {
-webkit-transform: rotate(270deg);
-ms-transform: rotate(270deg);
transform: rotate(270deg);
}
.juhua-loading .jh-circle11 {
-webkit-transform: rotate(300deg);
-ms-transform: rotate(300deg);
transform: rotate(300deg);
}
.juhua-loading .jh-circle12 {
-webkit-transform: rotate(330deg);
-ms-transform: rotate(330deg);
transform: rotate(330deg);
}
.juhua-loading .jh-circle2:before {
-webkit-animation-delay: -1.1s;
animation-delay: -1.1s;
}
.juhua-loading .jh-circle3:before {
-webkit-animation-delay: -1s;
animation-delay: -1s;
}
.juhua-loading .jh-circle4:before {
-webkit-animation-delay: -0.9s;
animation-delay: -0.9s;
}
.juhua-loading .jh-circle5:before {
-webkit-animation-delay: -0.8s;
animation-delay: -0.8s;
}
.juhua-loading .jh-circle6:before {
-webkit-animation-delay: -0.7s;
animation-delay: -0.7s;
}
.juhua-loading .jh-circle7:before {
-webkit-animation-delay: -0.6s;
animation-delay: -0.6s;
}
.juhua-loading .jh-circle8:before {
-webkit-animation-delay: -0.5s;
animation-delay: -0.5s;
}
.juhua-loading .jh-circle9:before {
-webkit-animation-delay: -0.4s;
animation-delay: -0.4s;
}
.juhua-loading .jh-circle10:before {
-webkit-animation-delay: -0.3s;
animation-delay: -0.3s;
}
.juhua-loading .jh-circle11:before {
-webkit-animation-delay: -0.2s;
animation-delay: -0.2s;
}
.juhua-loading .jh-circle12:before {
-webkit-animation-delay: -0.1s;
animation-delay: -0.1s;
}
@-webkit-keyframes jh-circleFadeDelay-ios {
0%,
39%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
@keyframes jh-circleFadeDelay-ios {
0%,
39%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
</style>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app">
<div class="_page-loading-wrapper" id="appLoading">
<div class="_page-loading-content">
<div class="juhua-loading">
<div class="jh-circle1 jh-circle-ios"></div>
<div class="jh-circle2 jh-circle-ios"></div>
<div class="jh-circle3 jh-circle-ios"></div>
<div class="jh-circle4 jh-circle-ios"></div>
<div class="jh-circle5 jh-circle-ios"></div>
<div class="jh-circle6 jh-circle-ios"></div>
<div class="jh-circle7 jh-circle-ios"></div>
<div class="jh-circle8 jh-circle-ios"></div>
<div class="jh-circle9 jh-circle-ios"></div>
<div class="jh-circle10 jh-circle-ios"></div>
<div class="jh-circle11 jh-circle-ios"></div>
<div class="jh-circle12 jh-circle-ios"></div>
</div>
</div>
</div>
</div>
</body>
</html>2.0 使用离线包
后续我们发现,只做如上优化,当用户网络不好,还是会出现白屏问题,导致用户等待时间过长,所以就想着能不能进一步的优化,所以用离线包的方式去解决。
我们当时做的方案只做了首页几个模块,一是首页的搜索模块,二是首页的每日签到模块。
初版方案是这样的

但是以上方案有几个问题
- 离线包会增加下载量、占用磁盘空间,因为我们应用内很多模块
- 在做 A B test,并不好做,需要额外实现,
- 只有在冷启动时触发离线包的资源的下载,导致用户更新不及时
- 一些边界的场景如要额外的处理(比如版本管理、增量/全量、校验、解压、原子替换、失败回退等)
总之一句话,我们的 app 不太想过多投入,所以这个方案其实做的 77 88 就不做了。于是有了 3.0 版本的方案。
3.0 使用预加载和预渲染
其实 webview 第一次是最慢的打开 H5 是最费时间。
| 平台 | 第一次创建 | 第二次创建 |
|---|---|---|
| Android(WebView) | 100ms ~ 400ms(甚至更高) | 20ms ~ 80ms |
| iOS(WKWebView) | 50ms ~ 150ms | 10ms ~ 50ms |
所以我们让客户端实现一个预加载预渲染的机制,通过拉取预渲染的配置,来实现开几个 webview。其目的有二
- 提前渲染资源
- 提前缓存资源
- 提前创建 webview 以此来实现秒开效果
整体方案流程图如下所示

整体说明如下:
- 启动 APP 时,拉取 web 页面加载配置信息,返回, preRender的属于预渲染的页面
{
"H5Config": {
"urls": [
{
"url": "https://www.baidu.com/baiduA/",
"versionUrl": "https://www.baidu.com/baiduA/1_0_0.html",
"preRender": true
},
{
"url": "https://www.baidu.com/baiduB/",
"versionUrl": "https://www.baidu.com/baiduB/1_0_0.html",
"preRender": false
},
]
}
}- 保证资源被缓存,完全使用浏览器的缓存策略
// 全部资源都做缓存策略
Cache-Control: max-age=31536000- 如何解决,资源更新问题
定义 jsapi 方法,每次进入 H5页面是, H5 调用这个 API ,来获取最新的资源版本号,当用户重新进入时,保证每次进的都是ok的版本
- 如何解决 a b test问题,因为仅仅是一个接口配置,所以配置跟新的很快。
其实我也看过一些其它的方案,一些大厂的方案看下来确实完美,但我觉得找到一个最合适自己公司的才是最好的。
其他
- APP 对 webview 添加白名单机制,只有符合条件的域名才能调用 native 方法
- 对扫码出来的符合跳转协议的标准,也做白名单机制,白名单机内才能通过扫码打开
- 对页面设置 CSP 策略,可以在 Nginx 配置