对于页面中静态资源(html/js/css/img/webfont),理想中的效果:
总结下来也就是2个指标:
静态资源加载速度引出了我们今天的主题,因为最直接的方式就是将静态资源进行缓存。页面渲染速度建立在资源加载速度之上,但不同资源类型的加载顺序和时机也会对其产生影响,所以也留给了我们更多的优化空间。
当然除了速度,缓存还有另外2大功效,减少用户请求的带宽和减少服务器压力。
先用一张图来概括下本文中将会涉及到的内容。
1、浏览器缓存
对于前端而言,这可能是我们最容易忽略的缓存类型,原因在于大部分设置都在服务器运维层面上进行,不属于前端开发的维护范围。但静态资源的内容更新时机其实前端是最清楚的,如果能在理解浏览器缓存策略的基础上合理配置效果最佳。
浏览器缓存策略一般通过资源的Response Header来定义,html文件在_很早之前的规范里_也可以通过Meta标签的http-equiv来定义。
一个Response Header示例:
可在w3c的官方文档中查看所有HTTP Response Header字段的定义,跟缓存相关的主要有上图中被圈出来的几个:
注:HTTP/1.0 没有实现 Cache-Control,所以为了兼容HTTP/1.0出现了Pragma字段。
注:这个规则允许源服务器,对于一个给定响应,向 HTTP/1.1(或之后)缓存比 HTTP/1.0 提供一个更长的过期时间。
Etag/If-None-Match:(EntityTags)是URL的tag,用来标示URL对象是否改变,一般为资源实体的哈希值。和Last-Modified类似,如果服务器验证资源的ETag没有改变(该资源没有更新),将返回一个304状态告诉客户端使用本地缓存文件。Etag的优先级高于Last-Modified,Etag主要为了解决 Last-Modified 无法解决的一些问题。
缓存策略执行过程
本地缓存过期后,浏览器会像服务器发送请求,request中会携带以下两个字段:
其中在图右侧的“file modified\?”判断中,服务器会读取请求头这两个值,判断出客户端缓存的资源是否最新,如果是的话服务器就会返回HTTP/304 Not Modified响应头,但没有响应体。客户端收到304响应后,就会从缓存中读取对应的资源;否则返回HTTP/200和响应体。
meta是html语言head区的一个辅助性标签,其中的http-equiv字段定义了服务器和用户代理的一些行为。在之前的规范中,meta的http-equiv字段中有以下值与http header缓存相关的字段功能类似。
使用方法:
<meta http-equiv="Cache-Control" content="no-cache" /> <!-- HTTP 1.1 --> <meta http-equiv="Pragma" content="no-cache" /> <!-- 兼容HTTP1.0 --> <meta http-equiv="Expires" content="0" /> <!-- 资源到期时间设为0 -->
但现在w3c的规范字段中这些值已经被移除,一个很好的理由是:
Putting caching instructions into meta tags is not a good idea, because although browsers may read them, proxies won't. For that reason, they are invalid and you should send caching instructions as real HTTP headers.
其实也很好理解,写在meta标签中代表必须解析读取html的内容,但代理服务器是不会去读取的。大多浏览器已经不再支持,会忽略这样的写法,所以缓存还是通过HTTP headers去设置。
注:HTTP Headers中的缓存设置优先级比meta中http-equiv更高一些。
2、HTML5 Application Cache
Application Cache是html5引入的本地存储方案之一,可以构建离线缓存。目前除IE10-外其他浏览器均支持。
使用方法
a、增加manifest文件
application cache是通过mannifest文件来管理的,manifest文件是简单的文本文件,内容是需要被缓存供离线使用的文件列表,及不需要被缓存或读取缓存失败的文件控制。
文件包含3个指令
b、服务器配置
mannifest文件可以使用任意拓展名,但需要在服务器中添加MIME类型匹配,使用apache比较简单,如果使用.manifest作为拓展名在apache配置文件中添加。
AddType text/cache-manifest .appcache
c、html中引用
<html lang="zh" manifest="main.manifest">
注:千万不要把manifest文件本身放在缓存文件列表中,不然浏览器无法更新manifest文件文件,最好在manifest文件的http headers中设置其立即过期。
缓存加载及更新过程
1、事件
2、执行过程
第一次加载:
第二次加载:
Application Cache Checking event(获取新的manifest文件,检查是否更新)
是:重新下载缓存文件,供下次访问使用(不会影响当前浏览器展示内容)
否
删除html中manifest文件引用
Application Cache Obsolete event(删除本地缓存中的所有文件,不再使用缓存)
一些问题
还有最后一个问题,该标准已经从 Web 标准中删除……
该特性已经从 Web 标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性。在此刻使用这里描述的应用程序缓存功能高度不鼓励; 它正在处于从Web平台中被删除的过程。请改用Service Workers 代替。
3、PWA(Service Worker)
PWA全称为“Progressive Web Apps”,渐进式网页应用,Service Worker是其几大核心技术之一。
Service worker is a programmable network proxy, allowing you to control how network requests from your page are handled.
没错,这就是官方建议替代Application Cache的方案。早在2014年,W3C就公布了Service Worker的草案。它作为一个独立的线程,是一段在后台运行的脚本。它的出现使得web app也可以具有类似native app的离线使用、消息推送、后台自动更新等能力。
不过它有以下限制:
虽然现在其浏览器支持情况并不是很广泛,但以后应该会大面积支持。本文做简单介绍,具体使用方法可以参考官方文档《The Offline Cookbook》。
简单使用
1、首先,要使用Service Worker,需要添加一个Service Worker的js的文件,然后在我们的html页面中注册对这个文件的引用。
index.html
<Script> navigator.serviceWorker .register('./sw.js') .then(function (registration) { // 注册成功 }); </Script>
2、其次,我们在js文件中补充Service Worker的生命周期事件。Service Worker生命周期有三部曲:注册,安装和激活。
一般来说我们需要注册的有3个事件:
self.addEventListener('install', function(event) { /* 安装后... */ // cache.addAll:把缓存文件加进来,如a.css,b.js }); self.addEventListener('activate', function(event) { /* 激活后... */ // caches.delete :更新缓存文件 }); self.addEventListener('fetch', function(event) { /* 请求资源后... */ // cache.put 拦截请求直接返回缓存数据 });
对于获取文件和缓存文件,Service worker依赖了两个 API:Fetch (通过网络重新获取内容的标准方式) 和 Cache(应用数据的内容存储,此缓存独立于浏览器缓存和网络状态)。
React脚手架create-react-app中已经内置了PWA功能,我们来看下打包后的build文件夹下的文件结构:
index.html文件中引用了static/js/main.js,main.js中注册了service-worker.js。service-worker.js中我们可以看到有 precacheConfig(缓存列表)和 cacheName(版本号)两个变量。断开网络,我们看到precacheConfig列表中的文件仍能从本地加载。
更新机制
以注册文件为service-worker.js为例,每次访问ServiceWorker控制的页面,浏览器都会加载最新的service-worker.js文件,跟当前service-worker.js文件对比,只要内容有任何不同,浏览器都会获取并安装新文件。但是不会立即生效,原有的ServiceWorker还是会运行,只有当ServiceWorker控制的页面全部关闭后,新的ServiceWorker才会被激活。
4、LocalStorage
LocalStorage虽是浏览器端缓存一种,但有多少人会用它来缓存文件呢?首先缓存读取需要依靠js的执行,所以前提条件就是能够读取到html及js代码段;其次文件的版本更新控制会带来更多的代码层面的维护成本,所以LocalStorage更适合关键的业务数据而非静态资源。
5、CDN缓存
这是一种以空间换时间的方案,减少了用户的访问延时,也减少的源站的负载。
客户端浏览器先检查是否有本地缓存是否过期,如果过期,则向CDN边缘节点发起请求,CDN边缘节点会检测用户请求数据的缓存是否过期,如果没有过期,则直接响应用户请求,此时一个完成HTTP请求结束;如果数据已经过期,那么CDN还需要向源站发出回源请求。
更新机制
CDN边缘节点缓存策略因服务商不同而不同,但一般都会遵循http标准协议,通过http响应头中的Cache-control: max-age的字段来设置CDN边缘节点数据缓存时间。另外可通过CDN服务商提供的“刷新缓存”接口来更新缓存。
预加载是浏览器对将来可能被使用资源的一种暗示,一些资源可以在当前页面使用到,一些可能在将来的某些页面中被使用。作为开发人员,我们比浏览器更加了解我们的应用,所以我们可以对我们的核心资源使用该技术。
通过prebrowsing可以提前缓存部分文件,可作为一种静态资源加载优化的手段。prebrowsing有以下几种:
prefetch \& preload
对于前面三种不少浏览器已经内部默认做了优化,而prefetch \& preload需要开发者根据情况代码手动设置。
兼容性
从prefetch和preload的浏览器支持情况来看,prefetch除了safari外基本浏览器都有所支持,但preload作为新出的规范,兼容性差些,但safari正慢慢支持这一标准,如在iOS的safari高级选项的试验性Webkit功能中已经有Link Preload这一选项。
优先级
preload 是声明式的 fetch,可以强制浏览器请求资源,同时不阻塞文档 onload 事件,是对浏览器指示预先请求当前页需要的资源(关键的脚本,字体,主要图片)。
prefetch 提示浏览器这个资源将来可能需要,但是把决定是否和什么时间加载这个资源的决定权交给浏览器。prefetch 应用场景稍微有些不同 —— 用户将来可能在其他部分(比如视图或页面)使用到的资源。
从以上的描述可以看出,对于preload和prefetch声明,preload明显高于prefetch。
注:prebrowsing 好用但千万不要乱用,除非你非常明确会加载要prebrowsing的文件,不然会加重浏览器负担适得其反。
应用
接触过Next.js的同学都知道,next.js提供了一个具有预获取功能的模块:next/prefetch,看起来功能与prefetch类似,但其优先级与preload类似。
<Link prefetch href='/'><a>Home</a></Link> <Link prefetch href='/features'> <a>Features</a></Link> { /* we imperatively prefetch on hover */ } <Link href='/about'> <a onMouseEnter={() => { Router.prefetch('/about'); console.log('prefetching /about!') }}>About</a> </Link> <Link href='/contact'><a>Contact (<small>NO-PREFETCHING</small>)</a> </Link>
由于features链接设置了prefetch,访问Index页面时浏览器会在页面加载完毕后从服务器取feature.js的文件,在index页面访问features页面时不会再从服务器请求features.js文件,直接从本地缓存中读取;contact没有做处理,从index访问contact时会从服务器请求concact.js文件。
我们还可以发现,在next.js打包出来的html文件头中,都会将index.js / error.js / app.js 3个文件作为preload加载,因为这3个文件是本页面中必须用到的资源。
不同文件类型
1、HTML文件
虽然大多数html只会在每次发布上线时才会改变,如更新js/css资源的引用地址,所以一般将HTTP Headers中设置一个比较短的max-age值,如cache-control: max-age=300,除此之外建议服务器开启Etag。
但以实时内容为主的网站(如金融类)为了页面的打开速度,会采取后台服务生产的方式 ,将所有首页数据全部生成到html中,省去用户首次加载时的后台接口请求等待时间。一般会设置cache-control: no-cache。
2、js/css/img文件
现在一般都通过文件名进行版本控制。Webpack打包命名可根据文件内容生成文件名的hash值,每次打包只有当内容改才重新生成hash值。此种情况之下,可以在HTTP Headers设置一个较大的缓存时间,如max-age=2592000,尽量避免304请求和服务器进行请求连接。
// js output: { path: config.build.assetsRoot, filename: utils.assetsPath('js/[name].[chunkhash].js'), chunkFilename: utils.assetsPath('js/[id].[chunkhash].js'), } // css new ExtractTextPlugin({ filename: utils.assetsPath('css/[name].[contenthash].css'), }),
3、webfont
webfont文件比较特殊,正如这篇文章中所说:
其实不同浏览器下载font文件的时间不太一样,有的碰到css的声明就会加载,有的会等到dom节点匹配css声明时加载。
优化实践
根据以上罗列的缓存建议,对当前的一个移动端项目进行优化。项目背景如下:
1、缓存配置
2、其他优化
以其中一个单页为例,页面效果如下:
动态加载的js
这个单页页面会打开几个小的页面(红色圈部分),通过webpack打包之后大概这个样子:
其中第一个index.js会在页面初次加载,其他4个js会在路由切换时动态加载。考虑下这个页面的业务场景,只要进入到这个页面,其他几个路由是一定会访问到的。所以如果在页面加载完成之后,趁户思考之际就主动把剩下几个js加载好,岂不完美。
在此选用了preload-webpack-plugin这个插件,它可以打包将动态路由进行预加载。
webpackConfig.plugins.push(new PreloadWebpackPlugin({ rel: 'prefetch', }));
rel属性还可以选择preload / prefetch模式。打包出来是这样:
访问页面可以看到,在不影响dom加载的情况下,浏览器预先加载了另外几个后面将会用到的js,当切换到对应路由时,也会直接从缓存取,不从服务器请求资源。
css文件
非动态加载(路由)页面的css会单独打包,在html文件中进行引用。除了使用一些打包插件优化代码体积外,可将css更细粒度拆分,如首页的css+弹窗css+页面标签切换的css等。除首页css外的先预加载,然后动态获取。但一般来说一个页面的css大小在合理的代码情况下经过gzip压缩后都不会过大,所以优化的效果并不会太明显。
动态加载路由中css没有单独拆分而是在路由的js中,所以只能随着js优化了。
webfont文件
对于font文件,除了减少文件大小,设置缓存时间之外,也可以通过预加载的方式提前让浏览器下载来提高首屏渲染速度。预加载webfont需要与webpack的html-webpack-plugin结合,打包时将制定的字体插入到html中。网上找了一圈没有找到现成的插件,自己来写一个。
1、写插件
2、用插件
npm install fontpreload-webpack-plugin --save-dev
const FontPreloadWebpackPlugin = require('fontpreload-webpack-plugin'); webpackConfig.plugins.push(new FontPreloadWebpackPlugin({ rel: 'prefetch', fontNameList: ['fontawesome-webfont'], crossorigin: true, }));
3、打包效果
本文内容到此结束,如有错误欢迎指正。
饥人谷一直致力于培养有灵魂的编程者,打造专业有爱的国内前端技术圈子。如造梦师一般帮助近千名不甘寂寞的追梦人把编程梦变为现实,他们以饥人谷为起点,足迹遍布包括facebook、阿里巴巴、百度、网易、京东、今日头条、大众美团、饿了么、ofo在内的国内外大小企业。 了解培训课程:加微信 xiedaimala03,官网:https://jirengu.com
本文作者:饥人谷方应杭老师