资源保障是前端工程中非常关键的环节。用户访问网页时,浏览器通常先加载 HTML
文件,然后通过 HTML
文件内部关联的资源链接加载对应的 CSS
和 JavaScript
文件。如果资源在加载过程中出现了问题,就会在不同程度上影响用户体验,严重时可能会导致页面不可用。
资源加载异常通常跟用户当时的网络状况强相关,且难以在其他环境下复现,这大大提高了问题排查的难度。因此,开发人员需要通过一系列的资源保障手段,提升前端资源的安全性和稳定性。
1. 场景分析
1.1 DNS劫持
DNS劫持也叫域名劫持,是指 DNS 解析的结果被篡改,使得用户访问某个域名时,被重定向到了错误的 IP 地址。例如,用户想要访问 example.com
,正常情况下应该解析到 IP地址 A,但经过 DNS 劫持后,可能被解析到了 IP 地址 B,导致访问了错误的网站。
DNS 劫持的场景可能有以下两种情况:
-
运营商 DNS 劫持:用户上网的 DNS 服务器通常是由运营商分配的,运营商可以将域名解析到任意地址。一般来说,运营商不会无故劫持域名,但是运营商出于减少骨干网络链路的负载压力、节省网间结算费用等考虑,往往会通过 DNS 劫持将请求重定向到自己的缓存服务器上。具体做法就是通过分光器映射用户的请求,抢先建立 HTTP 连接,而源服务器返回的数据则被丢弃。这并不只有坏处,CDN 厂商的工作就是从这演变而来的。
-
恶意 DNS 劫持:它会将用户重定向到恶意网站,从而达到广告植入、钓鱼诈骗、盗取个人信息等目的。
防范手段:
- 使用可靠的 DNS 服务器,比如国外的有 Google DNS(
8.8.8.8
)、 Cloudflare DNS(1.1.1.1
),国内的有CNNIC DNS、114 DNS、阿里 DNS等。 - 对于企业网络,可以使用
DNSSEC
(DNS Security Extensions),它通过数字签名来保证 DNS 数据的完整性和来源可靠性,防止 DNS 劫持。
1.2 HTTP劫持
HTTP 劫持是指在用户与服务器进行 HTTP 通信的过程中,攻击者在传输链路中监听、拦截并篡改 HTTP 数据。常见的有在网页中插入广告、恶意脚本等,或者篡改页面内容。
HTTP 劫持方式主要有三种:
- 注入自定义代码片段:通过执行脚本以篡改 HTML 文件内容
iframe
劫持:将用户要访问的HTML内容放到iframe中进行引用,然后在伪造的HTML文件中插入自定义的内容- 302重定向劫持:其原理与 DNS 劫持类似,在返回响应结果时,将HTTP响应报文的状态码改成302,并设置重定向 URL,从而使浏览器重定向跳转到被劫持后的页面。
1.3 资源加载异常
资源加载异常是最常出现的场景,表现包括:
HTML
资源加载错误:导致页面白屏CSS
资源加载错误:导致样式丢失,页面布局混乱,可用性降低JavaScript
资源加载失败:可能会导致交互无响应或白屏等异常- 图片资源加载失败:图片无法显示
- 页面长时间白屏:资源加载耗时过长
这些场景虽然不会导致页面内容被篡改,但在一定程度上影响用户的体验,从而引起用户流失、业务损失。
资源加载异常通常是由于资源加载时间过长或加载失败导致的:
- 资源加载时间过长:可能是资源文件体积过大、用户网络环境较差等原因导致的
- 资源加载失败:可能是由于加载过程中的网络抖动、网络中断,或者资源地址的目标服务器故障
2. 防劫持保障
本节介绍三个防劫持保障的前端技术手段。
2.1 标记过滤法
原理:通过自定义属性为正常的 HTML 标签打上合法标记,把没有标记的 HTML 标签视为非法标签并移除,可以帮助防止一些简单的页面篡改攻击。
实现思路:
首先,考虑标记的添加和过滤。如果要遍历每个标签并加上自定义属性,无疑会消耗大量的性能。因此,只需要给顶层标签加上自定义属性;对于新增标签,只需判断其是否为顶层容器的后代、或者是否具有合法标记,从而判断新增标签是否合法。这样可以大大提高判断效率。
然后,考虑标记过滤的时机。为了控制增量插入的标签,可以采用以下方式:
- 重写
appendChild
方法:如果新增的标签合法,则执行原生方法来插入标签,否则终止操作。除了appendChild
,可能还需要重写innerHTML
、append
、document.write
等方法,改动范围比较大。 - 监听
MutationEvent
中的DOMNodeInserted
事件:检测增量插入的新标签,如果不合法则移除。这种方式有两个问题:MutationEvent
事件无法取消,只能在标签插入后再移除,会导致页面闪烁。MutationEvent
事件机制是同步的,每次DOM修改都会触发。在这里进行处理可能会影响性能。
- 监听
MutationObserver
事件:使用HTML5 的MutationObserver
API 代替MutationEvent
事件,异步批量处理DOM 变更,解决性能问题。
下面就用MutationObserver
实现一个简单的标记过滤方法:
<body>
<div id="legitimate-container" data-legitimate="true">
legitimate-container
</div>
<script>
const legitimateContainer = document.getElementById('legitimate-container')
function isLegitimateNode(node) {
// 合法容器的后代节点:合法
if (legitimateContainer.contains(node)) return true
// 具有合法标记的节点:合法
if (node.getAttribute('data-legitimate') === 'true') return true
return false
}
const observer = MutationObserver(function(records, target) {
records.forEach(record => {
record.addedNodes.forEach(node => {
// 不合法标签:移除
if (!isLegitimateNode(node)) {
document.body.removeChild(node)
}
})
})
})
</script>
</body>
2.2 CSP配置
CSP(Content Security Policy,内容安全策略) 是一种安全机制,通过配置 HTTP 头或 meta
标签,用于控制哪些资源可以在页面上加载和执行,防止跨站脚本攻击(XSS)等。
CSP通过指定有效域的方式限制脚本、图片等资源的有效来源。比如:
<meta http-equiv="Content-Security-Policy" content="script-src 'self';">
通过指定script-src 'self'
,浏览器只允许加载同域名的脚本,非同源的远程脚本和内敛脚本都不允许执行,并且在控制台给出错误提示。这样就可以避免劫持中遇到的注入内联脚本和加载远程恶意脚本的情况。
如果开发人员需要执行内联脚本并加载非同源的远程脚本,那么可以通过添加nonce-<bash64>
或者sha256-<base64
>进行配置,对指定的脚本进行加白处理。比如:
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'nonce-1234567';">
<script nonce="1234567">alert('test')</script>
<script nonce="1234567" src="https://third-party/xxx.js"></script>
CSP 还可以指定允许使用的协议,比如服务器可以指定所有内容必须通过 HTTPS 加载。一个完整的数据安全传输策略应该包括:
- 强制使用 HTTPS 传输数据:指定
Strict-Transport-Security
HTTP头 - 提供重定向功能,让 HTTP 页面跳转到 HTTPS 页面。比如设置
upgrade-insecure-requests
CSP策略,将页面中的所有 HTTP 请求自动升级为 HTTPS 请求。 - 为所有的 cookie 标记
Secure
标识
对于 CSP 错误,还可以在HTTP头的CSP配置中指定report-uri
,从而上报错误信息。开发人员可以根据错误信息快速定位页面资源加载异常的节点,从而提高页面资源的安全稳定性。
2.3 防iframe劫持
如果页面被嵌入到其他恶意网站的 iframe
中,恶意网站就可以通过 iframe
引用来获取用户信息或篡改页面内容。
可以通过以下方法来防止 iframe 劫持:
-
使用 JavaScript 判断:页面初始化时,判断
window.top
和window.self
是否相等,即可判断当前页面是否被 iframe 嵌入。function detectIframeHijack() { if (window.top !== window.self) { console.log('当前页面被 iframe 嵌入'); // 方法1:跳转到目标页 window.top.location.href = window.self.location.href; // 方法2:页面上给出提示 document.write('页面访问异常,请联系客服') } }
-
配置
X-Frame-Options
HTTP 响应头或meta
标签:指示浏览器当前页面是否允许被嵌套在 iframe 中。比如,可以在 nginx 中配置 HTTP 响应头:# 不允许被嵌套,即使是同源页面嵌套 add_header X-Frame-Options "DENY"; # 允许同源页面的嵌套 add_header X-Frame-Options "SAMEORIGIN"; # 指定允许被嵌套的页面 add_header X-Frame-Options "ALLOW-FROM https://a.com,https://b.com";
2.4 使用HTTPS
HTTP
的内容采用明文传输,明文数据会经过中间代理服务器、路由器、WiFi热点和通信运营商的多个物理节点,如果信息在传输过程中被劫持,就会导致信息泄露或篡改。为了解决这类问题,HTTPS
诞生了。
HTTPS 在 HTTP 的基础上通过传输加密和身份认证,保证了传输过程的安全性。它是由 HTTP 加上SSL
/TLS
协议构建的网络协议,主要通过数字证书、加密算法、非对称密钥等技术实现。
-
数字证书:首先,服务端向第三方权威证书颁发机构(CA)购买数字证书,数字证书中包含了由 CA 私钥生成的签名。当客户端收到服务器的数字证书后,会使用其操作系统或浏览器内置CA的公钥来进行签名验证,将证书信息的哈希值进行比较,如果两者完全一致,就证明了该证书是由该 CA 颁发的,并且在传输过程中没有被篡改,从而确保了证书的完整性和真实性,实现了服务端的身份认证。
-
加密算法:HTTPS 对传输内容进行对称加密。
-
非对称加密:对称加密使用的会话密钥是在
SSL
/TLS
握手过程中通过非对称加密的方式传输的。
SSL/TLS握手的过程:
sequenceDiagram
participant 客户端
participant 服务器
客户端->>服务器: 发送ClientHello(SSL/TLS 版本、加密套件列表、客户端随机数)
服务器->>客户端: 发送ServerHello(选定的 SSL/TLS 版本、选定的加密套件、服务器随机数、证书)
客户端->>客户端: 验证服务器证书
客户端->>服务器: 发送ClientKeyExchange,更改密码规范
客户端->>服务器: 发送 ChangeCipherSpec(表示将使用新的加密套件)及 Finished(包含之前握手信息的加密验证)
服务器->>客户端: 发送 ChangeCipherSpec(表示将使用新的加密套件)及 Finished(包含之前握手信息的加密验证)
客户端->>服务器: 发送应用数据(加密)
服务器->>客户端: 发送应用数据(加密)
3. 稳定性保障
前端页面的渲染依赖静态资源的加载和执行,因此资源加载错误对前端页面来说是致命的,如果静态资源加载出错,可能导致前端页面无法渲染,影响系统的稳定性。
3.1 资源加载监控
使用 Performance.getEntries
和 PerformanceObserver
可以监听资源加载情况。
-
在
window.onload
事件触发时,页面已经完全载入,包括静态资源加载完成。此时可以使用Performance.getEntries
API 获取资源加载列表,拿到资源请求的详细情况,包括fetchStart
、responseStart
、duration
、transferSize
等。 -
还可以使用
PerformanceObserver
API 监听动态插入的资源加载情况。
此外,还需要关注资源加载错误的情况。
- 使用
onerror
事件定向监听某个资源加载失败。如果是跨域脚本报错时,onerror
只会提示script error
,无法精确到文件和行数,为此还需要给script标签配置crossorigin="anonymous"
属性。
<img src="image.jpg" onerror="handleImageError(event)">
<script crossorigin="anonymous" src="https://example/a.js" onerror="handleJsError(event)">
- 使用
window.addEventListener
全局监听资源加载失败事件,并且设置第三个参数为true
,可以在事件捕获阶段监听错误。
window.addEventListener('error', function(event) {
// 自定义错误处理
if (event.target.tagName === 'IMG') {
console.error("Image failed to load:", event.target.src);
} else if (event.target.tagName === 'SCRIPT') {
console.error("Script failed to load:", event.target.src);
} else if (event.target.tagName === 'LINK') {
console.error("Stylesheet failed to load:", event.target.href);
}
}, true);
3.2 资源重试
当资源加载失败时,如果页面没有任何保障措施,用户就只能通过刷新页面来重新加载资源。为了提升用户体验,我们可以引入资源重试机制。
我们可以通过 window.addEventListener
来监听 error
事件,监控资源加载失败的情况。然后,针对不同的资源采用不同的资源加载重试机制。比如,CSS资源可以通过插入 link 标签重新加载资源。
// 监听 error 事件
window.addEventListener('error', function (event) {
if (event.target.tagName === 'LINK') {
// 处理 link 资源加载失败
const link = event.target;
retryLink({
id: link.id,
href: link.href
});
}
// 重试其他资源
}, true);
// 重试加载 link 资源
function retryLink(resource) {
const newLink = document.createElement('link');
newLink.rel ='stylesheet';
newLink.href = resource.href;
newLink.id = resource.id;
document.head.appendChild(newLink);
}
对于 JavaScript 资源,为了有效保证 JavaScript 的执行顺序,需要手动处理串行加载,在前一个资源加载完成后再插入下一个标签。
// 重试加载 script 资源
function retryScripts(urls) {
const url = urls.shift();
if (!url) return;
const newScript = document.createElement('script');
newScript.src = url;
// script 资源加载完成后加载下一个资源
newScript.onload = () => {
retryScripts(urls);
};
document.head.appendChild(newScript);
}
此外,还要考虑避免无效的资源重试:
- 检查网络连接状态:资源加载失败通常是由于网络波动等原因造成的,当
window.navigator.onLine
为false
时,表示网络处于断开状态,此时可以给出提示。 - 设置资源重试次数上限:设置资源重试次数的上限,避免无限重试带来的性能损耗。
3.3 域名切换
如果网络连接状态正常,而资源还是加载失败,这可能是由于服务异常导致的,比如 CDN 厂商服务异常。这种情况往往需要一定的时间才能恢复系统,在此期间用户的服务都会受到影响。为了快速恢复线上资源可用性,可以通过切换域名,从正常工作的服务中获取资源。
比如,在 CDN 服务中,可能会出现部分区域的服务,导致资源解析失败。为了应对这种情况,需要添加 CDN 资源容灾方案,即添加多个备用 CDN 域名,并且在资源重试重试后更换新的 CDN 资源域名。
// 定义备用 CDN 域名数组
const backupCdns = [
'https://backup-cdn1.example.com/',
'https://backup-cdn2.example.com/',
'https://backup-cdn3.example.com/'
];
// 存储待重试的 CSS 资源信息
const cssResourcesToRetry = [];
// 监听 error 事件
window.addEventListener('error', function (event) {
if (event.target.tagName === 'LINK') {
// 处理 CSS 资源加载失败
const resource = event.target;
cssResourcesToRetry.push({
id: resource.id,
url: resource.href,
backupIndex: 0,
retryCount: 0
});
retryCssResources();
}
});
function retryCssResources() {
if (cssResourcesToRetry.length === 0) {
return;
}
const resource = cssResourcesToRetry.shift();
let newUrl = resource.url;
if (resource.retryCount < backupCdns.length) {
// 尝试使用备用 CDN 域名
const newDomain = backupCdns[resource.backupIndex]
newUrl = replaceDomain(newUrl, newDomain);
resource.backupIndex++;
resource.retryCount++;
cssResourcesToRetry.push(resource);
} else {
console.error(`CSS resource ${resource.id} failed to load after retries. Giving up.`);
return;
}
// 重试加载 link 资源
const newLink = document.createElement('link');
newLink.rel ='stylesheet';
newLink.href = newUrl;
newLink.id = resource.id;
document.head.appendChild(newLink);
}
3.4 资源离线化
当资源重试、域名切换都不能解决资源异常问题时,通常浏览器的网络连接已经处于离线状态了。在一些特殊的应用场景下,开发人员需要对部分资源进行离线化处理,以保证网络中断时服务的核心功能依然可以使用,从而给产品带来正向的体验收益,提升用户留存率和口碑。比如阅读类应用中,离线化阅读是一种非常普遍的而且能极大提升用户体验的功能。
为了实现资源离线化,可以借助 Service Worker
机制。它提供了独立的后台线程,可以拦截所有请求,开发人员可以进行自定义操作,从而精细化控制离线资源。比如当资源请求命中缓存时返回本地缓存资源,未命中缓存时发起资源请求并且缓存结果,还可以在请求失败时返回本地备用资源做降级处理。。
在页面主线程中注册 Service Worker
:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(function(registration) {
console.log('Service Worker 注册成功,范围是:', registration.scope);
})
.catch(function(error) {
console.error('Service Worker 注册失败:', error);
});
}
在 service-worker.js
中实现资源离线化处理:
const CACHE_NAME = 'my-site-cache-v1';
const urlsToCache = [
'/',
'/index.html',
'/styles.css',
'/script.js',
'/image.jpg'
];
// 安装:配置离线化资源
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('[Service Worker] cache opened');
return cache.addAll(urlsToCache);
})
);
});
// 响应请求:返回离线化资源
self.addEventListener("fetch", function (e) {
e.respondWith(
caches.match(e.request).then(function (r) {
console.log("[Service Worker] Fetching resource: " + e.request.url);
return (
r ||
fetch(e.request).then(function (response) {
return caches.open(CACHE_NAME).then(function (cache) {
console.log(
"[Service Worker] Caching new resource: " + e.request.url,
);
cache.put(e.request, response.clone());
return response;
});
})
);
}),
);
});