序
曾经看过一篇文章, 有一句话这样说:
只有在大学的图书馆里, 你才能真正赚回你交的学费.
临近毕业, 还想再去图书馆多转转. 偶然在架子上发现了这本书, 一看作者是写大名鼎鼎的红宝书的人, 就很感兴趣. 再者, 最近用 JavaScript 刷 LeetCode 发现, 提交显示 JavaScript 要比 Go 语言或 Python 有更大的时间和内存消耗, 也使我把了解 JavaScript 内存机制和性能优化提上了日程.
本书虽然有部分章节涉及到的问题有一定年代感, 比如最后一章[工具], 由于前端技术的快速迭代和浏览器的不断支持下, 已经不适用了. 但是这本书的前面章节详细地从 JS 运行, 访问, 代码结构优化, 异步编程等多方面讲解了 JavaScript 优化的策略, 解答了我刷题时的疑惑, 也让我认识到之前秋招面试的时候遇到的一些坑, 还是有许多需要引起重视的知识体系需要不断扩容.
总之, 这本书不仅是对前端性能优化基础知识的一种补充掌握, 还有很多底层原理的实现值得学习, 推荐有项目经验并希望提升 web 应用性能的前端开发人员阅读.
前端性能优化
下面是我认知的前端性能优化的策略, 本书主要着手 JavaScript 优化展开阐述.
JavaScript 优化
非核心代码异步加载
浏览器缓存
使用 CDN
DNS 预解析
优化资源
清理不必要的依赖
高性能 JavaScript
早期, IE 浏览器的 JS 引擎基于 "静态垃圾回收机制(Static Garbage Collection)", 该引擎监视内存中固定数量的对象来确定何时进行垃圾回收. 随着 Web 应用的日益发展, JS 引擎吃不消了.
虽然其他浏览器有着更加完善的 GC 和更好的性能, 但大多数都是使用 JS 解释器来执行.
这也正解释了开篇刷 LeetCode 题时的困惑, 解释型代码为什么没有编译型代码快?
因为, 解释型代码必须经历把代码转换成计算机指令的过程. 无论解释器多么智能, 都会带来一些性能的消耗.
而编译器已经有了各种各样的优化, 可以基于词法分析去判断代码想实现什么, 产生完成任务的运行最快的机器码. 解释器很少有这样的优化, 往往代码怎么写就怎么被执行.
2008 年, JS 引擎收获最大的一次性能升级, 该引擎的研发代号为 V8.V8 是一款为 JavaScript 打造的实时 (JIT) 编译引擎, 它把 JavaScript 代码转化为机器码来执行. 紧接着其他浏览器也优化了 JS 引擎, 这些只是编译器层面的优化, 代码的性能依然需要开发人员关注.
一, 浏览器中的 JavaScript
浏览器中 JS 代码的执行可能会阻塞浏览器的其他进程, 下边列出了几点棘手的问题以及优化方式.
脚本阻塞: 将 < script > 标签放在页面底部,</body > 闭合标签之前.
延迟时间:
内嵌 < script > 不紧跟 < link > 标签
运用打包工具, 合并 JS 文件
无阻塞加载 JS: 关键是在 Windows 对象的 load 事件触发后再下载脚本
使用 < script > 标签的 defer 属性 注意: defer 属性仅当 src 属性声明时才生效
动态脚本加载: 使用动态创建的 < script > 元素来下载并执行代码 注意: 需要通过侦听事件, 跟踪并确保脚本下载完成并准备就绪 优势是跨浏览器兼容性和易用, 也是最通用的无阻塞加载的策略.
使用 XHR 对象下载 JavaScript 代码并注入页面中
局限性: JavaScript 文件必须和所请求的页面同域, 不适用大型 Web 项目.
无阻碍脚本加载工具: YUI3,LazyLoad,LABjs
通过以上策略, 可以极大地提高 JavaScript 的 Web 应用的性能. 此外, 还有一些策略例如: 减少 JS 文件的大小, 限制 HTTP 请求数. 这两点策略, 随着 Web 应用的日益复杂, 可行性也随之降低, 也不是做的越极致效果越好, 需要实际情况具体分析.
二, 数据存储的位置
数据存储的位置关系到数据的检索速度, 直接影响代码执行的效率. JavaScript 有以下四种基本的数据存储位置:
字面量: 值的记法, 包括: 字符串, 数字, 布尔值, 对象, 数组, 函数, 正则表达式, 还有特殊的 null 和 undefined 值
本地变量: 使用 var/let/const 关键字定义的数据存储单元
数组元素: 以数字为索引, 存储在 JavaScript 数组对象内部
对象成员: 以字符串作为索引, 存储在 JavaScript 对象内部
标识符解析的性能
在函数的执行过程中, 每遇到一个变量, 都会经历一次标识符解析过程以决定从哪里获取或存储数据. 该过程的搜索执行环境是作用域链, 这个搜索过程会影响性能.
注意: 总的趋势是, 标识符所在位置越深, 它的读写速度越慢. 若采用优化过的 JavaScript 引擎的浏览器性能损失会大大减少.
原型链和嵌套成员也遵从此关系.
注意作用域链的改变
可以在执行时改变作用域链, 影响性能的语句:
with 语句: 会导致一个新的变量对象被置于作用域链的首位, 造成访问特定对象的属性非常快, 而访问局部变量则变慢. 建议: 弃用
try-catch 语句中的 catch 子句: 会把异常对象推入一个变量对象并置于作用域的首位. 建议: 将错误委托给一个函数处理
闭包
闭包的 [[scope]] 属性包含了与执行环境作用域链相同的对象的引用, 同时会影响内存开销和执行速度, 应小心使用闭包.
策略
可以通过把常用的数组元素, 跨域变量保存在局部变量中来改善性能.
这种策略不推荐用于对象的成员方法, 会改变 this 的值.
三, DOM 编程
浏览器中通常会把 DOM 和 JavaScript 独立实现, 所以访问 DOM 元素消耗很大.
策略: 减少访问 DOM 的次数, 把运算留给 ECMAScript 一端.
innerhtml 对比 DOM 方法
旧版浏览器中, 使用 innerHTML 会更快一些. 在基于 WebKit 内核的新版浏览器中, 用 DOM 略胜一筹.
策略: 根据可读性, 稳定性, 团队习惯, 代码风格来综合决定.
节点克隆
节点克隆 element.cloneNode()比创建新元素 document.createElement 更有效率, 但不明显.
HTML 集合
返回值是集合的方法:
- document.getElementByName()
- document.getElementByClassName()
- Document.getElementByTagName()
返回值是集合的属性:
- document.images
- document.links
- document.forms
- document.forms[0].elements
HTML 集合是包含 DOM 节点引用的类数组对象. 和数组的区别是没有 push 和 slice 方法, 有 length 属性和数字索引的方式访问元素.
HTML 集合低效之源: 假定实时态 assumed to be live
策略:
把集合的长度缓存到一个局部变量中, 在循环条件的退出语句中使用该变量.
使用数组拷贝.
function toArr() { for (var i = 0, arr = [], len = coll.length; i <len; i++) { arr[i] = coll[i]; } return arr;}
遍历 DOM
属性名 | 被替代的属性 |
---|---|
children | childNodes |
childElementCount | childNodes.length |
firstElementChild | firstChild |
lastElementChild | lastChild |
nextElementSibling | nextSibling |
previousElementSibling | previousSibling |
选择器 API
queryAelectorAll()和 firstElementChild()方法使用 CSS 选择器作为参数并返回一个 NodeList, 不会返回 HTML 集合. 适合处理大量组合查询.
重排和重绘
在浏览器的渲染过程中, 浏览器会在下载完页面所有组件之后, 解析并生成两个数据结构:
- DOM Tree(DOM 树)
- Render Tree(渲染树)
一旦上述两种结构构建完成, 浏览器就开始绘制 (paint) 页面元素.
注: 对重排和重绘的理解是非常必要的
重排 Reflow
定义: 当 DOM 结构的变化影响了元素的几何属性, 浏览器需要根据样式来重新计算元素出现的位置. 浏览器会使渲染树中受到影响的部分失效, 并重新构造渲染树.
触发 Reflow 的条件:
添加或删除可见的 DOM 元素
元素位置改变: 如, 添加动画效果
元素尺寸改变: 如, 改变边框宽高, 内外边距等
内容改变: 如, 改变段落文字行数, 图片替换等
浏览器 Resize 窗口(移动端不会出现)
修改默认字体
页面渲染器初始化
特别的: 当滚动条出现时, 会触发整个页面的重排
重绘 Repaint
定义: 完成重排后, 浏览器会根据渲染树重新绘制受影响的部分到屏幕中.
不是所有的 DOM 变化都会影响几何属性, 例如改变一个元素的背景色只会发生一次重绘.
特别的, 要注意分析改变所影响的阶段是重排还是重绘.
综上, 重排和重绘都是昂贵的操作, 会导致 Web 应用反应迟钝. 所以, 应该尽可能减少这类过程的发生.
渲染树的变化的排队和刷新
浏览器会通过队列化批量执行来优化重排过程.
以下获取布局的操作会导致队列刷新:
- offsetTop,offsetLeft,offsetWidth,offsetHeight
- scrollTop,scrollLeft,scrollWidth,scrollHeight
- clientTop,clientLeft,clientWidth,clientHeight
- getComputedStyle()
修改样式时, 应避免以上属性.
策略: 不要在布局信息改变时操作它.
最小化重排和重绘
策略:
合并多次对 DOM 和样式的修改, 然后一次处理掉.(n -> 1)
如: cssText 属性, className 属性等.
尽量减少 offsets 等布局信息的获取次数, 方法是获取一次起始位置的值, 在动画循环中, 直接使用变量.
让元素脱离动画流: 拖放代理
使用绝对定位页面上的动画元素, 将其脱离文档流.
让元素动起来, 这时会临时覆盖部分页面, 只会发生小规模重绘.
当动画结束时恢复定位, 从而只会下移一次文档的其他元素.
在元素很多时, 避免使用: hover
批量修改 DOM
关键:"离线" 操作 DOM 树, 使用缓存, 减少访问布局信息的次数. 策略:
使元素脱离文档流
隐藏元素(display:none), 应用修改, 重新显示.
使用文档片段在当前 DOM 之外构建一个子树(document.createDocumentFragment()), 再把它拷贝回文档.(推荐)
将原始元素拷贝到一个脱离文档流的节点中, 修改副本, 完成后再替换原始元素.
对其应用多重改变
把元素带回文档中
事件委托
之前写过一篇理解 DOM 事件处理程序和事件委托 https://www.jianshu.com/p/5a554380b2d6 的文章, 涉及事件模式的基本概念, 事件流, 事件委托的实现等的阐述, 如果大家对以上概念有所遗忘, 欢迎点击链接查看原文.
每绑定一个事件处理器都会加重页面负担, 延长执行时间, 消耗更多的内存(因为浏览器会跟踪每个事件处理器).
一个优雅的策略就是利用事件委托.
可以将冗长的浏览器兼容性代码移入可重用的类库:
访问事件对象, 判断事件源
取消文档树中的冒泡
阻止默认动作
四, 算法和流程控制
大部分性能问题的来源是低效的算法或工具编写出的糟糕代码.
循环
代码执行的大部分消耗在循环.
JS 循环的类型:
for 循环
注: for 循环初始化中 var 语句会创建一个函数级的变量, 应尽可能使用 ES6 中的 let 语句定义循环级变量.
while 循环: 和 for 类似, 是最简单的前测循环
do-while 循环: 唯一的后测循环, 循环体至少运行一次.
for-in 循环: 枚举任何对象的属性名 key
for-of 循环: ES6 新特性, 枚举任何对象的值 value
拓展知识: for-in 和 for-of 区别 https://bitsofco.de/for-in-vs-for-of/
所返回的属性:
对象的实例属性
从原型链中继承的属性
循环性能: for-in 明显慢
由于每次操作会同时搜索实例和原型属性, 查询散列键, 会产生更多开销. 所以, 除了明确需要迭代一个属性数量未知的对象, 其他情况应避免使用 for-in.
若其他循环的性能都差不多, 其实只有两个因素可以提升整体性能:
减少每次迭代的工作量: 限制循环中的耗时操作总数
最小化属性查找 关键: 减少对象成员及数组项的查找次数 策略: 只查找一次属性, 并把值存到一个局部变量中. 例如: var len = items.length;
倒序循环 通常, 数组项的顺序与所要执行的任务无关. 倒序循环是编程语言中一种通用的性能优化方式.
当循环复杂度为 O(n)时, 减少每次迭代的工作量是最有效的. 当复杂度大于 O(n), 建议着重减少迭代次数.
减少迭代的次数 达夫设备(Duff's Device): 循环体展开技术, 一次迭代中实际执行了多次迭代的操作.
迭代数超过 1000, 使用 Duff's Device 的执行效率将明显提升.
基于函数的迭代 forEach() 明显慢
原因: 对每个数组项调用外部方法所带来的额外开销.
条件语句
if-else 对比 switch 基于测试条件的数量选择: 条件数量越大, 越倾向于使用 switch, 易读性强且速度快.
大多数语言对 switch 语句的实现都采用了 branch table(分支表)索引进行优化.
优化 if-else
最小化到达正确分支前所需条件判断的次数 策略: 条件语句按照从大概率到小概率的顺序排列
把 if-else 组织成一系列嵌套的 if-else 语句 策略: 二分法把值域分成一系列区间, 逐步缩小范围. 适用范围: 有多个值域需要测试. 查找表 当条件语句数量很大或有大量散离值需要测试时, 使用数组和普通对象构建查找表访问数据比较快.
优点: 当单个键和单个值之间存在逻辑映射时, 随着候选值增加, 几乎不产生额外开销.
递归
传统算法的递归实现: 阶层函数 潜在问题;
假死 策略: 为了安全在浏览器工作, 可以迭代和 Memoization 结合使用.
浏览器调用栈大小限制 Call stack size limites 当超过最大调用栈容量时, 浏览器会报错, 可以用 try-catch 定位. 策略: ES6 中使用尾递归就不会发生栈溢出, 相对节省性能.
五, 字符串和正则表达式
字符串连接
方法 | 示例 |
---|---|
The + operator | str = "a" + "b" + "c"; |
The += operator | str = "a"; str += "b"; str += "c"; |
array.join() | str = ["a", "b", "c"].join(""); |
string.concat() | str ="a"; str = str.concat("b","c"); |
转义字符"" | 在每一行的最后,都加上转义斜线 \ |
使用 es6 模版字符串 | 使用键盘 1 左边的字符 ` 拼接 |
字符串连接优化
str += 'zhu' + 'yue'; //2 个以上的字符串拼接, 会在内存中产生临时字符串 str = str + 'zhu' + 'yue'; // 推荐, 直接附加内容给 str, 提速 10%~40%
浏览器合并字符串时分配的方法: 除 IE 外, 为表达式左侧的字符串分配更多的内存, 然后简单地将第二个字符串拷贝至它的末尾.
正则表达式优化
基本概念: 正则表达式 注意避免: 回溯失控
使用正则表达式和倒序循环可以简单实现 trim 方法, 去首尾空白.
优化正则表达式的策略:
具体化分隔符之间的字符串匹配模式
使用预查和反向引用的模拟原子组
避免嵌套量词与回溯失控
关注如何让匹配更快失败
以简单必需的字元开始
使用量词模式, 使它们后面的字元互斥
较少分支数量, 缩小分支范围
把正则表达式赋值给变量并重用
化繁为简
何时不使用正则表达式
在特定位置上提取并检查字符串的值: slice,substr,substring
查找特定字符串位置, 或者判断它们是否存在: indexOf,lastIndexOf
六, 快速响应的用户界面
Web Workers 引入了一个接口, 能使代码运行且不占用浏览器线程的时间.
Worker 的运行环境:
一个 navigator 对象, 只包括四个属性: appName,appVersion,user Agent 和 platform
一个 location 对象(与 Windows.location 相同, 不过所有属性都是只读的)
一个 importScripts() 方法, 用来加载 Worker 所用到的外部 JavaScript 文件
所有的 ECMAScript 对象
XMLHTTPRequest 构造器
setTimeout() 和 setInterval() 方法
一个 close() 方法, 可以立即停止 Worker 运行.
Web Workers 实际应用
Web Workers 适用于:
处理纯数据
与浏览器无关的长时间运行脚本
编码 / 解码大字符串
复杂数学运算, 如: 图像和视频
大数组排序
例子: 解析一个很大的 JSON 字符串
var worker = new Worker("jsonParser.js");// 数据就位时, 调用事件处理器 worker.onmessage = function (event) { //JSON 结构被回传回来 var jsonData = event.data; // 使用 JSON 结构 evaluateData(jsonData);};// 传入要解析的大段 JSON 字符串 worker.postMessage(jsonText);
jsonParser.JS 文件中 Worker 中负责解析 JSON 的代码:
// 当 JSON 数据存在时, 该事件处理器会被调用 self.onmessage = function (event) { //JSON 字符串由 event.data 传入 var jsonText = event.data; // 解析 var jsonData = JSON.parse(jsonText); // 回传结果 self.postMessage(jsonData);}
超过 100 毫秒的处理过程, 应该考虑 Worker 方案.
七, Ajax
常常使用 XMLHttpRequest(XHR),Dynamic script tag insertion,multipart XHR 技术向服务器请求数据.
XMLHttpRequest: 可以参考之前写过的文章 用原生 JS 封装 Ajax https://www.jianshu.com/p/76b32c84216a Dynamic script tag insertion: 可以跨域请求数据 multipart XHR: 将服务端资源打包成约定好的字符串分割的长字符串, 并发送到客户端.
数据格式: JSON
此章节优化主要是有效的利用浏览器缓存, 还有本章没有提及的现在逐渐开始流行的 fetch API 也值得讨论.
八, 编程实践
避免双重求值, 即在 JavaScript 代码中执行另一段 JavaScript 代码, 是 JavaScript 运行期性能优化的关键.
使用 Object/Array 直接量
通过延迟加载和条件预加载, 避免重复工作
使用语言中速度快的部分, 如: 位操作(& | ^ ~), 原生方法
九, 构建并部署高性能 JavaScript 应用
构建和部署的过程对基于 JS 的 Web 应用的性能有着巨大影响. 这个过程中最重要的步骤有:
使用 Gzip 合并, 压缩 JS 文件, 能够减少约 70% 的体积.
通过正确设置 HTTP 响应头来缓存 JS 文件, 通过向文件名增加时间戳来避免缓存问题.
使用 CDN 提供 JS 文件; CDN 不仅可以提升性能, 也帮助管理文件的压缩与缓存.
使用 Webpack 构建.
拓展: 前端构建工具的发展
十, 工具
主要分析方面:
性能分析
网络分析
总结
JavaScript 在不断发展和扩充它的边界, 我们也要不断学习大量的优化技术和方法. 当把这些策略应用在项目中时, 将会看到性能的明显提升, 这也就是细节决定成败.
来源: https://juejin.im/post/5c9458d8f265da6115608d78