App Shell架构实战提升前端加载性能
优化前:卡得不行
上周老板把我叫过去,说我们那个移动端H5页面打开太慢了,用户点开经常直接就关了。我一开始还不信,拿自己手机试了下——好家伙,首页加载完要差不多5秒,中间白屏时间能让我数清楚天花板上有几道裂纹。这哪是用户体验,这是考验用户耐心。
更离谱的是,首屏内容其实没多少,但就是慢。每次刷新都得盯着白屏等半天,loading动画都转出心理阴影了。我自己作为用户都想卸载了,别说普通用户了。
找到瓶颈了!
先上DevTools看Network面板,发现首屏JS和CSS加起来快1.2MB,gzip之后还有400多KB。而且全都是阻塞渲染的资源,浏览器得把所有东西下完才能开始画页面。这在3G网络或者弱网环境下简直没法活。
接着用Lighthouse跑了个分,首屏渲染时间(FCP)平均在4.8s左右,TTI(可交互时间)更是到了6s以上。性能得分只有30多分……我都想给自己打个低分简历了。
问题很明显:没有做关键路径优化,也没有利用缓存结构。每次访问都是从零开始下载+解析+执行,毫无复用可言。
App Shell模式救我狗命
后来想到之前看过Google的App Shell架构,核心思想就是“先把壳子画出来,再填内容”。把导航栏、底部tab、侧边栏这些不变的部分当成“壳”,静态缓存住;动态内容通过API异步拉取。
我决定试试这个路子。思路很简单:
- HTML里只保留最基础的结构和样式
- 把框架代码提前注入到shell.html里并预缓存
- 页面切换时只替换content区域,不整页重载
- 配合Service Worker做离线可用
说白了,就是让App像原生一样启动快,哪怕第一次慢点,第二次就得飞起来。
核心代码就这几行
最关键的其实是shell的组织方式。我把整个App的骨架抽成一个独立HTML模板:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>My App</title>
<!-- 内联关键CSS -->
<style>
.app-shell { display: flex; height: 100vh; flex-direction: column; }
.header { height: 56px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.content { flex: 1; overflow-y: auto; padding: 16px; }
.nav-bar { height: 48px; background: #f8f8f8; }
</style>
<!-- 预加载核心JS -->
<link rel="preload" href="/static/app.js" as="script" />
</head>
<body>
<div class="app-shell">
<header class="header"></header>
<main id="app-content" class="content"><!-- 动态内容插入这里 --></main>
<nav class="nav-bar"></nav>
</div>
<!-- 异步加载主逻辑 -->
<script src="/static/app.js" async></script>
</body>
</html>
然后在app.js里做动态渲染:
// 模拟路由控制
function navigate(path) {
const contentEl = document.getElementById('app-content');
// 显示loading状态
contentEl.innerHTML = '<div>加载中...</div>';
// 根据路径请求不同数据
fetch(https://jztheme.com/api/pages${path})
.then(res => res.json())
.then(data => {
contentEl.innerHTML = data.html;
})
.catch(() => {
contentEl.innerHTML = '加载失败,请重试';
});
}
// 初始化默认页面
window.addEventListener('load', () => {
navigate(window.location.pathname || '/');
});
这里注意我踩过好几次坑:一开始用了document.write去写内容,结果异步加载时直接报错。后来改成innerHtml + fetch组合才稳住。还有就是CSS不能外链,必须内联关键样式,不然FOUC(无样式内容闪现)会很严重。
Service Worker加持,二次访问起飞
光有shell还不够,得配上缓存策略。写了段简单的SW来缓存shell资源:
const CACHE_NAME = 'app-shell-v1';
const APP_SHELL_FILES = [
'/shell.html',
'/static/app.js',
'/static/app.css'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(APP_SHELL_FILES);
})
);
});
self.addEventListener('fetch', event => {
const { request } = event;
// 只处理同源请求
if (!request.url.startsWith(self.location.origin)) {
return;
}
// shell相关资源走缓存优先
if (APP_SHELL_FILES.includes(new URL(request.url).pathname)) {
event.respondWith(
caches.match(request).then(response => {
return response || fetch(request);
})
);
}
});
注册SW也很简单,在app.js开头加一句:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
这样第二次访问时,shell.html和核心JS/CSS基本都是秒开,完全从缓存读。实测二次加载时间从原来的4.8s降到不到800ms。
性能数据对比
改完之后重新跑Lighthouse,结果把我自己吓了一跳:
- FIRST CONTENTFUL PAINT:从4.8s → 1.1s(提升约77%)
- TIME TO INTERACTIVE:从6.2s → 1.9s
- 性能评分:32 → 78
- 首屏JS体积减少60%,因为拆分了chunk
最关键的是用户体验变了——现在点开至少能看到个 header 和 loading 状态,不再是干等着白屏。虽然第一次还是有点慢,但至少有反馈,不会让用户觉得“崩了”。
还有些小细节
中途也试过用PRPL模式配合Router做懒加载,但项目结构不太适合,改起来成本太高。最后选择保守方案:把shell固定下来,内容区走AJAX更新。
另外做了个兜底逻辑:如果API失败,就展示缓存的上一次内容,保证至少能看。虽然不是最新数据,但总比空白强。
有个遗留问题:iOS Safari对SW支持还是不太稳定,偶尔会出现缓存未命中。目前靠 Cache-Control头补救,设置max-age=3600,至少静态资源还能走浏览器缓存。
总结一下
这次优化折腾了三天,中间反复调试SW缓存策略、处理跨域、解决iOS兼容性,差点心态炸裂。但结果值得——用户留存率明显回升,老板也没再提性能的事了。
App Shell这套模式特别适合内容型H5应用,尤其是那些结构固定、内容动态的场景。如果你也在做类似项目,建议尽早引入,别等到用户流失才想起优化。
以上是我个人对这个App Shell优化的完整实践,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合React/Vue做客户端路由,后续也会继续分享这类实战经验。

暂无评论