前端性能优化
我认为前端性能主要分成三个部分: 编译, 加载和运行.
编译 这一块可能分成开发阶段(dev)和构建阶段(build). 更多的是关于vite/webpack/babel这类工具, 开发者本身可能不需要太过在意.
加载 涉及网络相关知识(DNS预解析, HTTP2/HTTP3, HTTP Cache), 浏览器从输入一个url到页面展示的过程,
运行 可能涉及内存泄漏, 浏览器的回流/重绘(动画性能)等.
编译
CommonJs与ESM
在这之前有必要写一下common与module的区别.
- 对于commonjs
function require(modulePath) {
var moduleId = getModuleId(modulePath)
if (cache[moduleId]) {
return cache[moduleId]
}
function _require(exports, require, module, __filename, __dirname) {
// 执行模块里的代码
// ...
}
var module = {
// 还有其它属性
exports: {},
}
var exports = module.exports
var __filename = moduleId
var __dirname = getDirname(__filename)
_require.call(exports, exports, require, module, __filename, __dirname)
cache[moduleId] = module.exports
return module.exports
}exports, module都是外部传进来的值, 就给了cjs很多神奇的操作. 比如
function a() {
const b = 1
module.exports = { b }
}
const a = 1;
const b = 2;
if (Math.random() < 0.5) {
module.exports = { a }
} else {
module.exports = { b }
}对静态分析相当的不友好. TreeShaking能做的很有限, 最多看哪些文件有没有导入, 导入了有没有副作用来进行文件级别的裁剪.
- 对于ESM 对于ESM, 浏览器(或者别的什么东西)大致会经历这三个阶段:
- 构造阶段: 将.js文件的import提升到顶层, 并分析import语句. 根据import下载更多的js文件, 然后继续这样做. 最终得到一个依赖图.
理想情况下, 这个依赖图是没有环的(也就是说没有循环导入). 不过ESM的一部分目的就是较为完美地解决循环导入问题.
- 实例化阶段: 为导出的变量分配内存空间, 并让导入和导出指向同一块内存空间.
// a.js
export let a = 1;
export function increase() {
a++
}
// main.js
import { a, increase } from './a.js'
console.log(a) // 1
increase()
console.log(a) // 2- 求值: 运行代码, 将具体的值填进内存里
延迟求值是为了解决循环导入的问题, 这里简单介绍一下.
这样的import/export语句是比commonjs更容易进行静态分析. 毕竟import和export几乎都只在外层进行, 不太可能在函数或者别的什么地方内部进行. 这使得ESM更加容易进行更细粒度的TreeShaking.
function a() {
const b = 1;
export { b }; // no
}
const b = 1;
export { b }; // yesimport语句通常也比较不自由, 一般也只会出现在顶层(除了import函数)
import { xxx } from 'xxx' // yes
function a() {
import { xxx } from 'xxx' // no
}
function b() {
import('xxx').then({ xxx } => {
// yes
})
}webpack
值得一提的是, webpack诞生在没有ESM或者ESM没有被广泛推广的时代, 浏览器甚至可能不支持require, 只能通过script标签来将模块挂载到window全局.
<script src='path/to/lodash'></script>
<script>
_.xxx() // 直接使用lodash, 不需要也不能import/require
</script>对于开发者来说, 如果能像写node一样用require会很舒服; 但是对浏览器来说, 它需要一个自己能看懂的js文件.
同时, 过多的script链接也导致浏览器可能需要加载太多的模块, 影响加载性能.
于是webpack诞生了. webpack允许开发者写自己舒服的代码, 任意地使用自己喜欢的导入方式(当时的require, 还有其它的东西, 以及现在的import), 最后webpack自己将开发者喜欢的代码, 翻译成浏览器侧喜欢的代码.
构建
如果要写完整的话可能会花费我很多时间, 因此只是简单写一写, 只要能面对简单的面试就好.
构建时, 对于一个入口(entry):
遇到require/import时, 加到依赖图中, 然后进入到被导入的js文件继续解析, 重复这种行为, 最终得到一个依赖图.
这里还涉及加载器, 比如css/scss代码之类的. 需要loader来进行解析, 最后也是得到一份js代码.
对于bundle, 一切都是js.
构建这个依赖图主要是为了TreeShaking, 以及代码去重等.
完成依赖图后, webpack会构建出这样一份bundle.js文件出来:
这个文件主要是一个立即执行函数.
其中维护一个依赖表, 如
var __webpack_modules__ = {
413(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, {
add: () => (add),
});
function add() {
// ...
}
}
}webpack runtime提供了一个类require函数: __webpack_require__
// The module cache
var __webpack_module_cache__ = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// Check if module exists (development only)
if (__webpack_modules__[moduleId] === undefined) {
var e = new Error("Cannot find module '" + moduleId + "'");
e.code = 'MODULE_NOT_FOUND';
throw e;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}原本js代码中的require或者import会被替换成webpack runtime提供的require函数.
如果有动态导入的话, 那么webpack会拆分出一/多个chunk出来, 并在内部维护一个installedChunks来标记这些chunk的状态.
如果没有加载, 那么创建一个script标签进行下载并执行对应的js代码.
chunk的执行结果(导出产物)会加到window中
(self["webpackChunkwebpack"] = self["webpackChunkwebpack"] || []).push(...)这个push不是Array.push, 而是webpack提供的push, 在自己的主js定义.
// install a JSONP callback for chunk loading
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data;
// add "moreModules" to the modules object,
// then flag all "chunkIds" as loaded and fire callback
var moduleId, chunkId, i = 0;
if(chunkIds.some((id) => (installedChunks[id] !== 0))) {
for(moduleId in moreModules) {
if(__webpack_require__.o(moreModules, moduleId)) {
__webpack_require__.m[moduleId] = moreModules[moduleId];
}
}
if(runtime) var result = runtime(__webpack_require__);
}
if(parentChunkLoadingFunction) parentChunkLoadingFunction(data);
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
installedChunks[chunkId][0]();
}
installedChunks[chunkId] = 0;
}
}
var chunkLoadingGlobal = self["webpackChunkwebpack"] = self["webpackChunkwebpack"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));然后将导出结果放进缓存表里, 并修改相关chunk的状态.
最后就是执行Promise的回调了.
这个
__webpack_require__还有一些属性, 这里没有写出来.
开发阶段
开发服务器与构建类似, 只是多了一个热重载.
开启HMR时, 浏览器端会与开发服务器通过ws通信.
当某处代码修改时, webpack会遍历依赖图, 并标记受影响的节点为失效, 然后重新构建对应的代码, 最终通过ws将chunkId通知给浏览器.
这也导致了随着应用规模增大, HMR的速度增大. 因为需要遍历依赖图.
浏览器根据这个chunkId, 更新manifest, 下载并执行js代码, 更新缓存表对应的缓存.
在更新缓存后, 还需要通知依赖被更新模块的模块, 让它们使用新的模块.
webpack的runtime提供了HMR的接口(module.hot), 但是它自身不知道如何具体更新某个模块.
如果模块没有使用module.hot.accept, 那么这个更新事件就会冒泡, 如果冒泡到入口都没能解决, 那么就会整页刷新.
比如我的index.js依赖utils.js:
// utils.js
export function greet(name) {
return `111`;
}
// index.js
// 在index.html导入
import { greet } from './utils'
console.log(greet())现在我直接修改utils.js或者index.js, 那么浏览器会直接刷新. 因为没有模块进行module.hot.accept
不过如果我这样改的话
// utils.js
// ...greet代码
if (module.hot) {
module.hot.accept(); // 防止自身修改事件冒泡
}这样之后, 修改utils.js就不会导致刷新了. 但是修改index.js依旧会产生刷新.
去掉上面那个, 在index.js里增加
if (module.hot) {
module.hot.accept('./utils.js', () => {
console.log('index.js', 'HMR')
})
}当utils.js修改时, 同样不会刷新, 并且会打印出index.js HMR, 这是utils.js的事件冒泡到上一级模块index.js, 并且index.js解决了. 如果冒泡到entry的话那么就会刷新.
不过这玩意基本不需要开发者关注, vue有vue-loader, css有css-loader. 现在不会真的有人用纯js写, 还用webpack+HMR吧.
相关优化
无论构建还是开发服务器, webpack都需要进行打包, 得到一份或多份bundle. 这也导致了随着项目增大, 启动速度越来越慢. 主要原因构造这个依赖图需要遍历所有文件, 复杂度为O(n).
为了避免构造依赖图, webpack使用内存缓存和持久化缓存的解决方案:
- 开发环境下, 在内存中进行依赖图的构建.
- 将构建结果存到硬盘中, 下次启动直接复用, 跳过大部分解析过程.
vite
vite诞生在一个ESM被推广的时代. 根据前面写的可以发现: webpack跟ESM实际上是有重合的地方的, 比如都需要构建依赖图. 如果webpack也是在这个时代开发出来, 可能可以更简单地构建依赖图了.
在没有启用RollDown的vite中, 开发和构建是分开的: 开发使用esbuild, 构建使用rollup. 启用rolldown的话, 那开发和构建都是使用rolldown.
开发
如果说webpack是打包主义者的话, 那么vite就是不打包主义者. 当浏览器需要某个js文件时, 才会将文件发过去. 比如我们的main.js
import App from './App.vue'vite不会将App.vue给打包进去, 而是单独成一个App.vue文件, 当浏览器解析到这一句时才会进行下载, vite服务器通过Vue插件转为js发过去给浏览器. 这样子就产生了一些问题: 我们安装的代码通常还会有依赖, 依赖可能又有依赖, 导致我们一个简单的导入就需要让浏览器使用几百条连接下载, 不管是HTTP几都不太可能这样做.
vite会将代码分成两类: 依赖和源码. 这里的依赖通常指的是node_modules里的东西, 也就是用包管理器安装的东西. 源码就是自己编写的东西.
对于依赖, vite会进行预构建, 将依赖以及依赖内置/依赖的很多模块构建成单个模块, 这样数百条请求就变成了一条请求. 同时, 在预构建阶段vite会完成commonjs/umd到ESM的转换.
预构建通常只需要一次, 直到锁文件改变, 或者vite.config.js相关字段, NODE_ENV, 补丁文件夹修改时间的改变, 才有可能再次构建. 预构建产物会放在node_modules/.vite中.
同时, 预构建产物可以让浏览器进行强缓存, 只需要一次请求就不再请求, 直到再次预构建.
vite的HMR与webpack类似, 也是接受开发服务器的事件, 通知对应的模块, 然后进行事件冒泡.
// a.ts
export function a() {
return 111
}
if (import.meta.hot) {
import.meta.hot.accept(() => {
console.log('a reload')
})
}但是区别在于, vite不需要自己去遍历依赖图. vite本身就需要让事件冒泡即可: 如果事件被自身解决了, 那么只需要重新加载单个文件就可以了.
vite同样有依赖图, 不过这个依赖图是懒加载的形式, 只有浏览器要的时候, vite才会将模块放进依赖图里.
比如这里的a.ts, vite根据websocket传递的信息, 下载最新的a.ts?t=xxx, 这个xxx是发过来的时间戳. 重新执行a.ts后, 如果事件已经被自身解决了, 到这里就结束了, 不需要做更多的事情.
如果这里没有解决, 那么就标记这些模块也是失效的模块, 重新下载, 然后继续往上找依赖它的模块, 直到找到事件解决的地方.
这里的话main.ts依赖了a.ts, 那么main.ts可以这样写.
if (import.meta.hot) {
import.meta.hot.accept('./a.ts', (mod) => {
console.log('mod', mod?.a())
console.log('a reload')
})
}这个mod就是新的a导出的东西, 原本的实际上还是旧的引用. 直到修改main.ts, 这样子下载的main.ts引用就是最新的a.
如果一直没有模块来处理事件, 那就只能刷新页面了.
构建
其实感觉vite的构建没什么好说的. vite目前的构建使用的是rollup, 可以说vite相当于rollup的扩展, rollup支持的它都支持.
vite同样需要一个依赖图, 然后将静态导入的打包到一个js文件里, 动态导入的分成一个个都js文件. 基于vite打包的动态导入没webpack那么麻烦, 不需要使用JSONP, 浏览器要, 那就给一份js文件. 这就是高贵的ESM.
vite打包出来的单js文件几乎没有运行时. 每个模块都会尽可能地打平, webpack则是需要放在一个函数里, 变成一个个都module.exports.
vite会自动地进行modulepreload的优化, 在某些情况下通过js动态地放一些到head里.
对于访问网站就一定要下载的东西, 会提前放好在head里, 比如说打包好的vue或者css资源之类的.
开发与构建一致性
众所周知, rollup的插件vite都能用, 这很合理. 但是esbuild能用看上去就不太合理.
为了让esbuild也能使用vite插件, 尽可能实现开发与构建一致, vite提供了一个类似容器的东西: PluginContainer.
在这个容器中, 会模拟rollup的行为, 提供rollup的钩子.
esbuild本身不会去执行这些rollup/vite插件, 代码先经过插件容器, 然后再交给esbuild处理.
比如App.vue, 它需要先使用Vue插件转换成js代码, 然后才交给esbuild. esbuild自己是不认识vue代码的.
至于为什么不全都是用esbuild, 官方文档其实有写, 大概就是rollup生态丰富, 然后vite生态也跟着丰富.
对于webpack和vite的介绍大概就到这里, 实际上还有很多东西, 不过暂时先这样吧.
加载
HTTP
HTTP/1.1
每条连接默认都是长连接, 有对相同域名的HTTP请求时尽量复用连接而不是再开一个连接.
浏览器通常允许对同一个域名有最大六个连接的限制.
存在HTTP应用层队头阻塞问题: 每个请求都需要等待前面请求完成才能发起.
原因: HTTP没有办法判断多个响应时, 哪个响应属于哪个请求(缺少唯一标识符).
有HTTP流水线的解决方案, 但是太复杂, 依旧要求响应依次到来. 不推荐使用, 浏览器一般也不支持.
HTTP/2
多路复用, 只使用一个长连接, 但是每个请求可以同时进行.
每个请求都是二进制的形式, 报文被分成首部帧(对应header)和数据帧(对应body), 并且帧具有唯一标识符(StreamId).
这样浏览器不再要求响应依次到来, 即使乱序也没有问题. 依靠唯一标识符来匹配请求-响应.
但是依旧存在TCP队头阻塞. 原因: TCP没有帧的概念, 它只知道它要把有序, 正确的数据交上去. 因此, 即使某个响应先到达了, 也需要等待其它响应, 然后一起交给HTTP应用层. 如果某个响应丢了, 其它响应也得跟着等.
HTTP/3
使用UDP+QUIC.
QUIC解决了TCP队头阻塞的问题, 并且糅合了TCP的流量控制和TLS等功能, 是一个可靠的传输协议.
UDP没有连接这个概念, 不过QUIC实现了类似的东西. QUIC为连接设置了ConnectionID(CID). 当流量与wifi切换时, 浏览器会将这个id发给服务器, 继续之前的"连接". 这样实现了数据与wifi的无感切换. 如果是TCP的话, 那么需要重新建立"连接".
从一个URL到一个网页
网络请求
当用户输入一个URL并回车时, 首先会进行DNS查询获取一个ip, 然后再发起一个HTTP请求. 通常我们会得到一个html文件.
解析HTML
对于这个html文件, 浏览器会开启两个线程进行解析: 主线程解析html文件, 并构建DOM树; 预加载扫描器会扫描html文件, 找出需要的资源并下载.
浏览器的一个优化. 预加载扫描器下载好资源后, 浏览器解析到这个标签时就可以直接用下载好的资源了. 根据资源的不同, 主线程采取的行为也不同:
- 对于媒体资源: 不阻塞主线程也不阻塞渲染
- 对于css资源: 不阻塞主线程, 但是阻塞渲染(也会阻塞js执行)
- 对于js资源: 会阻塞主线程也阻塞渲染
由于js会阻塞主线程, 因此css也可以间接地阻塞主线程.
CSSOM构建发生在css资源下载完成后, 同样需要主线程来完成
在一步完成后, 会得到一份DOM树和CSSOM树.
样式计算
主线程会遍历DOM树, 为每一个节点计算出最终的样式.
在这个过程, 会将很多预设值转为绝对值, 比如red转为rgb(255, 0, 0)
这一步完成后, 得到一份带有样式的DOM树.
布局
主线程遍历DOM树, 为每个节点计算出几何信息(节点宽高, 相对包含块的位置等), 得到一棵布局树.
布局树和DOM树可能不是一一对应的, 有些DOM节点可能是
display: none;这种不参与布局的, 布局树就不会有这个节点.对于没有被行盒包裹的文本, 会生成一个匿名行盒来包裹, 布局树就会多一个行盒.
一些伪元素, 比如before, after, 也可能生成节点.
分层
浏览器会根据一些影响因素, 对节点进行分层, 尽可能地将稳定的和不太稳定的分开, 这样变化时, 只需要重新渲染其中某一层(理想情况下).
这是一种空间换时间的手段. 分层越多, 占用内存越多.
可以使用will-change来影响分层结果.
绘制
主线程会为每个层单独产生绘制指令集, 用于描述这一层的内容该如何画出来.
类似我们的canvas, 将光标移动到某处, 然后绘制怎么样的东西.
完成绘制后, 主线程将这些信息交给合成线程.
分块
合成线程会对每个图层进行分块, 这个过程是多线程的, 会从线程池里取出更多线程进行分块.
然后合成线程将分块信息交给GPU进程.
光栅化
这一阶段会相当的快, 因为有GPU.
GPU会分出多个线程来完成光栅化, 并且优先处理靠近视口区域的块.
这一步会得到一块一块的位图.
展示
合成线程拿到位图后, 会生成一个个指引信息, 指示哪一块位图应该画在屏幕上的哪一个地方.
变形发生在这里, 与渲染主线程无关. 因此
transform效率很高.
合成线程将指引交给GPU进程, 由GPU进程产生系统调用, 提交给GPU硬件, 完成最终的屏幕成像.
首次访问
对于首次访问, 主要有两部分的性能优化: 网络与渲染.
网络优化
对于每个不同的域名, 都需要进行DNS查询(如果有缓存, 返回缓存结果), 然后才能发起HTTP请求.
对于DNS, 可以使用dns预解析的手段:
<link rel="dns-prefetch" href="xxx">这样预加载扫描器扫描到这个, 就会对xxx进行DNS解析, 不会等到真正要向xxx进行请求时才进行DNS解析. 还可以使用preconnect来提前建立TCP/TLS连接.
对于HTTP,
使用CDN减少客户端与服务端的距离
升级HTTP协议: 比如从HTTP/1.1升到2或者3.
减少HTTP请求: 比如关键css使用style标签而不是用link.
如果升级了HTTP2的话, 减少HTTP请求仍有意义但是作用没那么大, HTTP2多路复用解决了HTTP/1.1的队头阻塞问题.
内联css/js通常由框架或者打包器来完成. nuxt就会将关键的css直接放到style标签里, vitepress会将js放在script标签中挂在body里.
- 提前进行HTTP请求: 使用preload来浏览器提前下载某些资源, 而不是扫描到相关标签才下载.
prefetch也是提前下载, 但是优先级比较低, 是期望浏览器有空的时候去下载, 通常放未来需要的东西, 比如用户可能打开的第二个网页.
- 减少资源体积:
- 图片先使用占位图或者缩略图占位, 完成第一次渲染后尝试使用高清图片(懒图片加载)
- 减少不必要的js和css资源, 按需导入
- 启用代码压缩和gzip
- 图片使用webp格式
- 字体优化
像vue/react这些框架, 实际上都相当依赖js来渲染页面. 因此html很薄, js很厚.
可以使用Islands架构(astro)或者React Server Component(RSC), 实现在服务端运行js代码, 只发送必要的客户端js代码.
渲染优化
首屏展示的渲染优化其实没太多东西.
- 减少DOM数量, 只渲染必要的DOM(比如长列表只渲染看得见的)
- 使用SSR/SSG SSR/SSG相当于提前将原本要使用js注入的东西先注入了, 浏览器拿到这些body内的东西可以提前渲染一部分, 加快首屏展示.
- 延迟js执行, 避免阻塞HTML解析/渲染 对script标签使用defer或者async
- async: 让js脚本不阻塞主线程也不阻塞渲染, 并行下载, 下载完执行
- defer: 让js脚本不阻塞主线程也不阻塞渲染, 在html解析完成后, DOMContentLoaded前执行.
对于type="module"的script标签, 默认有defer的效果, 无论defer是否为true, 但是async优先级更高
- 避免回流和重绘 图片/视频指定width和height, 让浏览器预留空间, 避免下载好后回流
使用content-visibility: auto;优化长列表渲染. 来自MDN
再次访问
再次访问比首次访问多一个缓存的优化.
HTTP缓存
- 私有缓存
- 共享缓存
- 代理缓存
- 托管缓存
私有缓存一般指浏览器缓存. 共享缓存一般指web缓存器, 比如在路由器上设置的, 当一个用户请求一份资源时, 路由器或者别的什么中间件可以缓存起来, 另一个用户也要请求这东西时, 可以直接把这玩意发给它(如果缓存有效).
在Cache-Control出现或推广前还有个启发式缓存, 大概就是缓存上一次更新时间到现在的时间差的1/10. 比如有个东西上次更新时间是去年, 那么缓存时间就是0.1年.
如果没有这个Cache-Control, 那么浏览器就会尝试使用启发式缓存. 所以最好手动配置缓存头.
这里主要讨论Cache-Control的.
- max-age
一般情况下, 服务器可以使用Cache-Control来控制缓存策略, 比如使用max-age
Cache-Control: max-age=3600这样接下来的3600秒浏览器不需要再请求这份资源, 直接用缓存就可以了.
在Cache-Control之前, 还有个Expires响应头, 但是现在比较少见, 如果同时存在则max-age优先级更高.
- Last-Modified/If-Modified-Since
同时, 服务器可以使用Last-Modified响应头来声明额外的信息让浏览器记录. 浏览器会将这个东西跟max-age一起存下来.
当缓存失效时(时间差超过了max-age), 那么就再次发送请求:
GET xxx
If-Modified-Since: xxx这个xxx就是上次存起来的Last-Modified. 服务器会检查这个xxx, 如果xxx之后资源没有修改, 说明缓存还有效, 返回304告诉浏览器用缓存, 同时可以继续使用Cache-Control指示缓存策略. 如果缓存无效, 那么返回200和新资源.
- ETag/If-None-Match
服务器可以使用ETag响应头来标识某个资源. 对应的, 浏览器可以使用If-None-Match请求头.
ETag比Last-Modified优先级更高.
与Last-Modified类似, 服务器会检查资源标识符, 如果是最新的, 那么返回304; 如果不是, 那么返回200和新资源与新标识符.
- 缓存策略
Cache-Control除了配置max-age, 还可以配置
- no-cache: 要求使用缓存时, 先进行校验(也就是协商缓存)
no-cache约等于max-age=0, must-revalidate. 但是现在最好用no-cache而不是这两个个组合.
- no-store: 禁止使用缓存
- immutable: 用户刷新时, 不使用协商缓存, 直接强缓存
- must-revalidate: 过期必须重新验证, 不能直接使用
vite/webpack这类打包器对打包产物有一个优化: 它会给每个产物(除了index.html)打上一个资源标识符, 比如index.js, 可能变成index-xxx.js. 当文件修改时, 这个资源标识符也会修改.
这样就可以避免一些麻烦的缓存配置, 只要index.html是最新的, 那么请求的资源一定就是最新的. 因此可以对这些资源进行强缓存
Cache-Control: max-age=31536000, immutable对于index.html, 要求必须是最新的(或者缓存时间较短)
Cache-Control: no-cache有两个概念可能面试会问: 强缓存和协商缓存.
强缓存通常发生在缓存有效时. 直接使用缓存, 不管服务器资源怎么样.
协商缓存通常发生在缓存无效时或者用户刷新时. 这时候会发送一个网络请求到服务器请求资源. 如果服务器觉得缓存有效, 那么返回304; 如果无效, 返回200和新资源.
大部分服务器框架对于返回304默认是没有响应实体的, 即使手动设置也是返回空. 简单提一下, 不必在意.
service worker缓存
sw缓存可以让前端开发者更加自由地控制缓存.
可以使用workbox快速配置缓存, 也可以自己手写一个.
其实没什么好说的, 只是相当于给fetch加了个响应拦截器, 可以自己控制响应; 搭配上Cache API就可以自己控制缓存了.
可能更多用在HTTP缓存难以满足的情况下, 比如离线访问.
对于index.html通常是使用协商缓存, 但是没网的时候网络请求失败, 浏览器也不会自己使用缓存, 而是当作普通的网络错误. 这个时候就需要sw来拦截了.
首次请求index.html时, sw安装时可以使用预缓存把index.html缓存起来; 之后请求index.html可以自己根据响应头和存储的信息, 选择性地更新index.html, 也可以每次请求index.html都进行更新, 总之确保index.html是最新的.
有网的时候使用协商缓存, 然后更新sw缓存中的index.html.
没网的时候, 向index.html的请求肯定是失败的, 这个时候就使用sw缓存里的index.html, 由于其它资源都是强缓存, 因此整个网站静态部分都是可访问的. 这就实现了网站的离线访问.
不过如果网站本身是一个完全的在线应用, 那离线访问其实没多少意义.
对于sw缓存, 通常有这些策略, 可以人为实现, 也可以直接使用workbox
- CacheFirst: 优先缓存, 没缓存就网络请求更新缓存
- CacheOnly
- NetworkFirst: 优先网络, 没网使用缓存
- NetworkOnly
- Stale-With-Revalidate: 先从缓存中读, 同时发起网络请求更新缓存. 但是这样下一次才能看到最新的网页
运行
这个感觉挺杂的, 可能有很多个角度, 我估计也写不全. 目前我能想到的只有
- javascript
- 渲染/动画性能
- 框架特有的优化
- 事件触发的频率
javascript
启用ts
想写出高性能的javascript最简单的办法是使用ts.
对于这样一个函数:
function a(arg) {
// do something
}如果arg一直传递的是Number类型, 那么v8引擎就会进行一个优化, 直接把这段编译成更加底层的机器码.
但是有一天突然传递了String类型参数, 那v8就会进行一个反向优化, 并且再也不会对这段代码进行优化.
因此确保类型唯一可以有效提高性能, 启用ts是最简单的办法了.
避免动态读取对象
像这样的东西
const obj = {
a: 1,
b: 2
}
const key = 'a'
obj[key]
obj.a直接使用obj[key]来读取value的性能是比obj.a读取value的性能略差的. 在C语言中, 也有类似的东西: Struct. 但是实际上汇编或者是机器码中是不存在这样的数据结构的.
C语言的比如obj.a实际上是一种计算偏移, 然后得到对应内存上的数据. 在js里也是类似的, 如果是obj.a那么可以比较直接地计算偏移, 得到value. 动态读取的话就慢一些.
避免使用delete
跟函数一样, v8对对象的读取也会进行优化. 直到使用了delete obj[xxx], v8就会进行反向优化
持续更新中......