者:皮皮
JavaScript 是單線程運行的,所以在在執行效率上并不是很高,隨著用戶體驗的日益重視,前端性能對用戶體驗的影響備受關注,但由于性能問題相對復雜,接下來我們來了解下JavaScript如何提高性能;
由于 JavaScript 的阻塞特性,在每一個<script>出現的時候,無論是內嵌還是外鏈的方式,它都會讓頁面等待腳本的加載解析和執行,并且<script>標簽可以放在頁面的<head>或者<body>中,因此,如果我們頁面中的 css 和 js 的引用順序或者位置不一樣,即使是同樣的代碼,加載體驗都是不一樣的。示例如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>js 引用的位置性能優化</title>
<script type="text/javascript" src="index-1.js"></script>
<script type="text/javascript" src="index-2.js"></script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div>
</body>
</html>1.2.3.4.5.6.7.8.9.10.11.12.13.14.
其后面的內容將會被掛起等待,直到index-1.js 加載、執行完畢,才會執行第二個腳本文件 index-2.js,這個時候頁面又將被掛起等待腳本的加載和執行完成,一次類推,這樣用戶打開該界面的時候,界面內容會明顯被延遲,我們就會看到一個空白的頁面閃過,這種體驗是明顯不好的,因此 我們應該盡量的讓內容和樣式先展示出來,將 js 文件放在 最后,以此來優化用戶體驗。如下所示:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>js 引用的位置性能優化</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="index-1.js"></script>
<script type="text/javascript" src="index-2.js"></script>
</body>
</html>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.
這段代碼展示了在 HTML 文檔中放置<script>標簽的推薦位置。盡管腳本下載會阻塞另一個腳本,但是頁面的大部分內容都已經下載完 成并顯示給了用戶,因此頁面下載不會顯得太慢。這是雅虎特別性能小組提出的優化 JavaScript 的首要規則:將腳本放在底部。
由于每個<script>標簽初始下載時都會阻塞頁面渲染,所以減少頁面包含的<script>標簽數量有助于改善這一情況。這不僅針對外鏈腳本,內嵌腳本的數量同樣也要限制。瀏覽器在解析 HTML 頁面的過程中每遇到一個<script>標簽,都會因執行腳本而導致一定的延時,因此最小化延遲時間將會明顯改善頁面的總體性能。
這個問題在處理外鏈 JavaScript 文件時略有不同。考慮到 HTTP 請求會帶來額外的性能開銷,因此下載單個 100Kb 的文件將比下載 5 個 20Kb 的文件更快。也就是說,減少頁面中外鏈腳本的數量將會改善性能。
通常一個大型網站或應用需要依賴數個 JavaScript 文件。您可以把多個文件合并成一個,這樣只需要引用一個<script>標簽,就可以減少性能消耗。文件合并的工作可通過離線的打包工具或者一些實時的在線服務來實現。
需要特別提醒的是,把一段內嵌腳本放在引用外鏈樣式表的之后會導致頁面阻塞去等待樣式表的下載。這樣做是為了確保內嵌腳本在執行時能獲得最精確的樣式信息。因此,建議不要把內嵌腳本緊跟在標簽后面。
有一點我們需要知道:頁面加載的過程中,最耗時間的不是 js 本身的加載和執行,相比之下,每一次去后端獲取資源,客戶端與后臺建立鏈接才是最耗時的,也就是大名鼎鼎的Http 三次握手,當然,http 請求不是我們這一次討論的主題,因此,減少 HTTP 請求,是我們著重優化的一項,事實上,在頁面中 js 腳本文件加載很很多情況下,它的優化效果是很顯著的。
在 JavaScript 性能優化上,減少腳本文件大小并限制 HTTP 請求的次數僅僅是讓界面響應 迅速的第一步,現在的 web 應用功能豐富,js 腳本越來越多,光靠精簡源碼大小和減少 次數不總是可行的,即使是一次 HTTP 請求,但文件過于龐大,界面也會被鎖死很長一段 時間,這明顯不好的,因此,無阻塞加載技術應運而生。簡單來說, 就是 頁面在加載完成后才加載 s js 代碼,也就是在 w window 對象的 d load 事件觸 發后才去下載腳本。要實現這種方式,常用以下幾種方式:
HTML4 為<script>標簽定義了一個擴展屬性:defer。Defer 屬性指明本元素所含的腳本不會修改 DOM,因此代碼能安全地延遲執行。defer 屬性只被 IE 4 和 Firefox 3.5 更高版本的瀏覽器所支持,所以它不是一個理想的跨瀏覽器解決方案。在其他瀏覽器中,defer 屬性會被直接忽略,因此<script>標簽會以默認的方式處理,也就是說會造成阻塞。然而,如果您的目標瀏覽器支持的話,這仍然是個有用的解決方案。
<script type="text/javascript" src="index-1.js" defer></script>1.
帶有 defer 屬性的<script>標簽可以放置在文檔的任何位置。對應的 JavaScript 文件將在頁面解析到<script>標簽時開始下載,但不會執行,直到 DOM 加載完成,即 onload事件觸發前才會被執行。當一個帶有 defer 屬性的 JavaScript 文件下載時,它不會阻塞瀏覽的其他進程,因此這類文件可以與其他資源文件一起并行下載。·任何帶有 defer 屬性的<script>元素在 DOM 完成加載之前都不會被執行,無論內嵌或者是外鏈腳本都是如此。
HTML5 規范中也引入了 async 屬性,用于異步加載腳本,其大致作用和 defer 是一樣的,都是采用的并行下載,下載過程中不會有阻塞,但 不同點在于他們的執行時機,c async 需要加載完成后就會自動執行代碼 ,但是 r defer 需要等待頁面加載完成后才會執行。
把代碼以動態的方式添加的好處是:無論這段腳本是在何時啟動下載,它的下載和執行過程都不會阻塞頁面的其他進程,我們甚至可以直接添加帶頭部 head 標簽中,都不會影響其他部分。因此,作為開發的你肯定見到過諸如此類的代碼塊:
var script=document.createElement('script');
script.type='text/javascript';
script.src='file.js';
document.getElementsByTagName('head')[0].appendChild(script);1.2.3.4.
這種方式便是動態創建腳本的方式,也就是我們現在所說的動態腳本創建。通過這種方式下載文件后,代碼就會自動執行。但是在現代瀏覽器中,這段腳本會等待所有動態節點加載完成后再執行。這種情況下,為了確保當前代碼中包含的別的代碼的接口或者方法能夠被成功調用,就必須在別的代碼加載前完成這段代碼的準備。解決的具體操作思路是:現代瀏覽器會在 script 標簽內容下載完成后接收一個load 事件,我們就可以在 load 事件后再去執行我們想要執行的代碼加載和運行,在 IE 中,它會接收 loaded 和 complete事件,理論上是 loaded 完成后才會有 completed,但實踐告訴我們他兩似乎并沒有個先后,甚至有時候只會拿到其中的一個事件,我們可以單獨的封裝一個專門的函數來體現這個功能的實踐性,因此一個統一的寫法是:
function LoadScript(url, callback) {
var script=document.createElement('script');
script.type='text/javascript';
// IE 瀏覽器下
if (script.readyState) {
script.onreadystatechange=function () {
if (script.readyState=='loaded' || script.readyState== 'complete') {
// 確保執行兩次
script.onreadystatechange=null;
// todo 執行要執行的代碼
callback()
}
}
} else {
script.onload=function () {
callback();
}
}
script.src='file.js';
document.getElementsByTagName('head')[0].appendChild(script);
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.
LoadScript 函數接收兩個參數,分別是要加載的腳本路徑和加載成功后需要執行的回調函數,LoadScript 函數本身具有特征檢測功能,根據檢測結果(IE 和其他瀏覽器),來決定腳本處理過程中監聽哪一個事件。實際上這里的 LoadScript()函數,就是我們所說的 LazyLoad.js(懶加載)的原型。
通過 XMLHttpRequest 對象來獲取腳本并注入到頁面也是實現無阻塞加載的另一種方式,這個我覺得不難理解,這其實和動態添加腳本的方式是一樣的思想,來看具體代碼:
var xhr=new XMLHttpRequest();
xhr.open('get', 'file-1.js', true);
xhr.onreadystatechange=function () {
if (xhr.readyState===4) {
if (xhr.status >=200 && xhr.status < 300 || xhr.status===304) {
// 如果從后臺或者緩存中拿到數據,則添加到 script 中并加載執行。
var script=document.createElement('script');
script.type='text/javascript';
script.text=xhr.responseText;
// 將創建的 script 添加到文檔頁面
document.body.appendChild(script);
}
}
}1.2.3.4.5.6.7.8.9.10.11.12.13.14.
通過這種方式拿到的數據有兩個優點:其一,我們可以控制腳本是否要立即執行,因為我們知道新創建的 script 標簽只要添加到文檔界面中它就會立即執行,因此,在添加到文檔界面之前,也就是在 appendChild()之前,我們可以根據自己實際的業務邏輯去實現需求,到想要讓它執行的時候,再 appendChild()即可。其二:它的兼容性很好,所有主流瀏覽器都支持,它不需要想動態添加腳本的方式那樣,我們自己去寫特性檢測代碼;但由于是使用了 XHR 對象,所以不足之處是獲取這種資源有“域”的限制。資源 必須在同一個域下才可以,不可以跨域操作。
減少 JavaScript 對性能的影響有以下幾種方法:
使用<script>標簽的 defer 屬性(僅適用于 IE 和 Firefox 3.5 以上版 本);
使用動態創建的<script>元素來下載并執行代碼;
使用 XHR 對象下載 JavaScript 代碼并注入頁面中。
通過以上策略,可以在很大程度上提高那些需要使用大量 JavaScript 的 Web 網站和應用的實際性能。
來源: Python共享之家
者:lzg9527
原文鏈接:https://segmentfault.com/a/1190000038180453
webpack ensure 有人稱它為異步加載,也有人稱為代碼切割,他其實就是將 js 模塊給獨立導出一個.js 文件,然后使用這個模塊的時候,再創建一個 script 對象,加入到 document.head 對象中,瀏覽器會自動幫我們發起請求,去請求這個 js 文件,然后寫個回調函數,讓請求到的 js 文件做一些業務操作。
需求: main.js 依賴兩個 js 文件: A.js 是點擊 aBtn 按鈕后,才執行的邏輯, B.js 是點擊 bBtn 按鈕后,才執行的邏輯。
webpack.config.js ,我們先來寫一下 webpack 打包的配置的代碼
const path=require('path') // 路徑處理模塊
const HtmlWebpackPlugin=require('html-webpack-plugin')
const { CleanWebpackPlugin }=require('clean-webpack-plugin') // 引入CleanWebpackPlugin插件
module.exports={
entry: {
index: path.join(__dirname, '/src/main.js'),
},
output: {
path: path.join(__dirname, '/dist'),
filename: 'index.js',
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, '/index.html'),
}),
new CleanWebpackPlugin(), // 所要清理的文件夾名稱
],
}
index.html 代碼如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webpack</title>
</head>
<body>
<div id="app">
<button id="aBtn">按鈕A</button>
<button id="bBtn">按鈕B</button>
</div>
</body>
</html>
入口文件 main.js 如下
import A from './A'
import B from './B'
document.getElementById('aBtn').onclick=function () {
alert(A)
}
document.getElementById('bBtn').onclick=function () {
alert(B)
}
A.js 和 B.js 的代碼分別如下
// A.js
const A='hello A'
module.exports=A
// B.js
const B='hello B'
module.exports=B
此時,我們對項目進行 npm run build , 打包出來的只有兩個文件
由此可見,此時 webpack 把 main.js 依賴的兩個文件都同時打包到同一個 js 文件,并在 index.html 中引入。但是 A.js 和 B.js 都是點擊相應按鈕才會執行的邏輯,如果用戶并沒有點擊相應按鈕,而且這兩個文件又是比較大的話,這樣是不是就導致首頁默認加載的 js 文件太大,從而導致首頁渲染較慢呢?那么有能否實現當用戶點擊按鈕的時候再加載相應的依賴文件呢?
webpack.ensure 就解決了這個問題。
下面我們將 main.js 改成異步加載的方式
document.getElementById('aBtn').onclick=function () {
//異步加載A
require.ensure([], function () {
let A=require('./A.js')
alert(A)
})
}
document.getElementById('bBtn').onclick=function () {
//異步加載b
require.ensure([], function () {
let B=require('./B.js')
alert(B)
})
}
此時,我們再進行一下打包,發現多了 1.index.js 和 2.index.js 兩個文件。而我們打開頁面時只引入了 index.js 一個文件,當點擊按鈕 A 的時候才引入 1.index.js 文件,點擊按鈕 B 的時候才引入 2.index.js 文件。這樣就滿足了我們按需加載的需求。
require.ensure 這個函數是一個代碼分離的分割線,表示回調里面的 require 是我們想要進行分割出去的,即 require('./A.js') ,把 A.js 分割出去,形成一個 webpack 打包的單獨 js 文件。它的語法如下
require.ensure(dependencies: String[], callback: function(require), chunkName: String)
我們打開 1.index.js 文件,發現它的代碼如下
(window.webpackJsonp=window.webpackJsonp || []).push([
[1],
[
,
function (o, n) {
o.exports='hello A'
},
],
])
由上面的代碼可以看出:
webpack4 官方文檔提供了模塊按需切割加載,配合 es6 的按需加載 import() 方法,可以做到減少首頁包體積,加快首頁的請求速度,只有其他模塊,只有當需要的時候才會加載對應 js。
import() 的語法十分簡單。該函數只接受一個參數,就是引用包的地址,并且使用了 promise 式的回調,獲取加載的包。在代碼中所有被 import() 的模塊,都將打成一個單獨的包,放在 chunk 存儲的目錄下。在瀏覽器運行到這一行代碼時,就會自動請求這個資源,實現異步加載。
下面我們將上述代碼改成 import() 方式。
document.getElementById('aBtn').onclick=function () {
//異步加載A
import('./A').then((data)=> {
alert(data.A)
})
}
document.getElementById('bBtn').onclick=function () {
//異步加載b
import('./B').then((data)=> {
alert(data.B)
})
}
此時打包出來的文件和 webpack.ensure 方法是一樣的。
為什么需要懶加載?
像 vue 這種單頁面應用,如果沒有路由懶加載,運用 webpack 打包后的文件將會很大,造成進入首頁時,需要加載的內容過多,出現較長時間的白屏,運用路由懶加載則可以將頁面進行劃分,需要的時候才加載頁面,可以有效的分擔首頁所承擔的加載壓力,減少首頁加載用時。
vue 路由懶加載有以下三種方式
這種方法主要是使用了 resolve 的異步機制,用 require 代替了 import 實現按需加載
export default new Router({
routes: [
{
path: '/home',',
component: (resolve)=> require(['@/components/home'], resolve),
},
{
path: '/about',',
component: (resolve)=> require(['@/components/about'], resolve),
},
],
})
這種模式可以通過參數中的 webpackChunkName 將 js 分開打包。
export default new Router({
routes: [
{
path: '/home',
component: (resolve)=> require.ensure([], ()=> resolve(require('@/components/home')), 'home'),
},
{
path: '/about',
component: (resolve)=> require.ensure([], ()=> resolve(require('@/components/about')), 'about'),
},
],
})
vue-router 在官網提供了一種方法,可以理解也是為通過 Promise 的 resolve 機制。因為 Promise 函數返回的 Promise 為 resolve 組件本身,而我們又可以使用 import 來導入組件。
export default new Router({
routes: [
{
path: '/home',
component: ()=> import('@/components/home'),
},
{
path: '/about',
component: ()=> import('@/components/home'),
},
],
})
在 webpack 打包過程中,經常出現 vendor.js , app.js 單個文件較大的情況,這偏偏又是網頁最先加載的文件,這就會使得加載時間過長,從而使得白屏時間過長,影響用戶體驗。所以我們需要有合理的分包策略。
在 Webapck4.x 版本之前,我們都是使用 CommonsChunkPlugin 去做分離
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module, count) {
return (
module.resource &&
/.js$/.test(module.resource) &&
module.resource.indexOf(path.join(__dirname, './node_modules'))===0
)
},
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'common',
chunks: 'initial',
minChunks: 2,
}),
]
我們把以下文件單獨抽離出來打包
webpack 4 最大的改動就是廢除了 CommonsChunkPlugin 引入了 optimization.splitChunks 。如果你的 mode 是 production ,那么 webpack4 就會自動開啟 Code Splitting 。
它內置的代碼分割策略是這樣的:
雖然在 webpack4 會自動開啟 Code Splitting ,但是隨著項目工程的最大,這往往不能滿足我們的需求,我們需要再進行個性化的優化。
我們先找到一個優化空間較大的項目來進行操作。這是一個后臺管理系統項目,大部分內容由 3-4 個前端開發,平時開發周期較短,且大部分人沒有優化意識,只是寫好業務代碼完成需求,日子一長,造成打包出來的文件較大,大大影響性能。
我們先用 webpack-bundle-analyzer 分析打包后的模塊依賴及文件大小,確定優化的方向在哪。
然后我們再看下打包出來的 js 文件
看到這兩張圖的時候,我內心是崩潰的,槽點如下
吐槽完之后我們就要開始做正事了。正是因為有這么多槽點,我們才更好用來驗證我們優化方法的可行性。
抽離 echart 和 iview
由上面分析可知, echart 和 iview 文件太大,此時我們就用到 webpack4 的 optimization.splitChunks 進行代碼分割了,把他們單獨抽離打包成文件。(為了更好地呈現優化效果,我們先把 xlsx.js 去掉)
vue.config.js 修改如下:
chainWebpack: config=> {
config.optimization.splitChunks({
chunks: 'all',
cacheGroups: {
vendors: {
name: 'chunk-vendors',
test: /[/]node_modules[/]/,
priority: 10,
chunks: 'initial'
},
iview: {
name: 'chunk-iview',
priority: 20,
test: /[/]node_modules[/]_?iview(.*)/
},
echarts: {
name: 'chunk-echarts',
priority: 20,
test: /[/]node_modules[/]_?echarts(.*)/
},
commons: {
name: 'chunk-commons',
minChunks: 2,
priority: 5,
chunks: 'initial',
reuseExistingChunk: true
}
}
})
},
此時我們再用 webpack-bundle-analyzer 分析一下
打包出來的 js 文件
從這里可以看出我們已經成功把 echart 和 iview 單獨抽離出來了,同時 vendor.js 也相應地減小了體積。此外,我們還可以繼續抽離其他更多的第三方模塊。
CDN 方式
雖然第三方模塊是單獨抽離出來了,但是在首頁或者相應路由加載時還是要加載這樣一個幾百 kb 的文件,還是不利于性能優化的。這時,我們可以用 CDN 的方式引入這樣插件或者 UI 組件庫。
<head>
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/iview/3.5.4/styles/iview.css" />
</head>
<body>
<div id="app"></div>
<script src="https://cdn.bootcss.com/vue/2.6.8/vue.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/iview/3.5.4/iview.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/xlsx/0.16.8/xlsx.mini.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/xlsx/0.16.8/cpexcel.min.js"></script>
</body>
configureWebpack: (config)=> {
config.externals={
vue: 'Vue',
xlsx: 'XLSX',
iview: 'iView',
iView: 'ViewUI',
}
}
npm uninstall vue iview echarts xlsx --save
此時我們在來看一下打包后的情況
打包出來的 js 文件
well done ! 這時基本沒有打包出大文件了,首頁加載需要的 vendor.js 也只有幾十 kb,而且我們還可以進一步優化,就是把 vue 全家桶的一些模塊再通過 cdn 的方法引入,比如 vue-router , vuex , axios 等。這時頁面特別是首頁加載的性能就得到大大地優化了。
多開發者可能平時并不關心自己維護的頁面是否存在內存泄漏,原因可能是剛開始簡單的頁面內存泄漏的速度很緩慢,在造成嚴重卡頓之前可能就被用戶刷新了,問題也就被隱藏了,但是隨著頁面越來越復雜,尤其當你的頁面是 SAP 方式交互時,內存泄漏的隱患便越來越嚴重,直到突然有一天用戶反饋說:“操作一會兒頁面就卡住不動了,也不知道為什么,以前不這樣的呀”。
這篇文章通過一些簡單的例子介紹內存泄漏的調查方法、總結內存泄漏出現的原因和常見情況,并針對每種情況總結如何避免內存泄漏。希望能對大家有所幫助。
先看一個簡單的例子,下面是這個例子對應的代碼:
代碼 1
代碼 1 的邏輯很簡單:點擊“add date”按鈕時會向 dateAry 數組中 push 3000 個 new Date 對象,點擊“clear”按鈕時將 dateAry 清空。很明顯,“add date”操作會造成內存占用不斷增長,如果將這個邏輯用在實際應用中便會造成內存泄漏(不考慮故意將代碼邏輯設計成這樣的情況),下面我們看一下如何調查這種內存增長出現的原因以及如何找出內存泄漏點。
1 heap snapshot
為了避免瀏覽器插件的干擾,我們在 chrome 中新建一個無痕窗口打開上述代碼。然后在 chrome 的 devtools 中的 Memory 工具中找到 “Heap Snapshot”工具,點擊左上角的錄制按鈕錄制一個 Snapshot,然后點擊“add date”按鈕,在手動觸發 GC(Garbage Collect)之后,再次錄制一個 Snapshot,反復執行上述操作若干次,像圖 1 中操作的那樣,得到一系列的 Snapshot。
圖 1 錄制 Snapshot
圖 2 是我們剛剛得到的 Snapshot 組,其中的第一個是頁面初始加載的時候錄制的,不難發現,從第二個開始,每個 Snapshot 相比于上一個其大小都增加了約 200KB,我們點擊選擇 Snapshot 2,在 class filter 輸入框中處輸入 date,可以得到 Snapshot 2 中所有被 Date 構造器構造出來的 JS 對象,也就是 Date 對象,這里看到的構造器跟瀏覽器內部的實現有關,不必跟 JS 的對象對應。
選中一個 Date 對象,在下面的面板中可以看到所選對象的持有鏈以及相關持有對象的內存的保留大小(Retained Size),從圖中可以看出選中的 Date 對象是 Array 的第 1 個元素(index 從 0 開始),而這個 Array 的持有者是 system/Context 上下文中的 dateAry,system/Context 上下文就是代碼中 script 標簽的上下文,我們可以看到在這個 dataAry 的保留大小是 197KB,我們再切到 Snapshot 3,用相同的方式查看內存持有和大小,可以發現 Snapshot 3 中的 dataAry 的保留大小變成了 386KB,相比于 Snapshot 2 增漲了約 200KB!逐一比較后面的 Snapshot 4、5 后也能得到相同的對比結果,即下一個 Snapshot 中的 dateAry 比上一個的保留大小大約 200KB。
圖 2 錄制的 Snapshot 組
參考【代碼 1】我們可以知道,“add date”按鈕在被點擊時,會向 dateAry 數組中 push 3000 個新的 Date 對象,而在圖 2 中的 Date 構造器的右側可以看到這 3000 個 Date 對象(Date x 3000),它對應的正式我們的循環創建的那 3000 個 Date 對象。綜合上面的操作我們可以知道,chorome devtools 中的 Memroy 的 Heap Snapshot 工具可以錄制某一個時刻的所有內存對象,也就是一個“快照”,快照中按“構造器”分組,展示了所有被記錄下來的 JS 對象。
如果這個頁面是一個實際服務于用戶的網站的某個頁面話(用戶可能非常頻繁的點擊“add date”按鈕,作者可能想記錄用戶點擊的次數?也許吧,雖然我也不知道他什么要這么做)隨著用戶使用時間的增長,“add date”按鈕的反應就會越來越慢,整體頁面也隨之越來越卡,原因除了系統的內存資源被占用之外,還有 GC 的頻率和時長增長,如圖 3 所示,因為 GC 執行的過程中 JS 的執行是被暫停的,所以頁面就會呈現出越來越卡的樣子。
圖 3 Performance 錄制的 GC 占比
圖 4 chrome 的任務管理器
最終:
圖 5 內存占用過高導致瀏覽器崩潰
那么,在這個“實際”的場景下,如何找出那“作祟”的 3000 個 Date 對象呢?我們首先想到的應該是就是:之前不是錄制了好多個 Snapshot 嗎?可不可以把它們做對比找到“差異”呢,從差異中找到增長的地方不就行了?思路非常正確,在此之前我們再分析一下這幾個 Snapshot:每次點擊“add date”按鈕、手動觸發 GC、得到的 Snapshot 的大小相比上一次都有所增加,如果這種內存的增長現象不符合“預期”的話(顯然在這個“實際”的例子中是不符合預期的),那么這里就有很大的嫌疑存在內存泄漏。
這個時候我們選中 Snapshot 2,在圖 2 所示的 " Summary" 處選擇“Comparison”,在右側的 "All objects" 處選擇 Snapshot 1,這樣一來,Constructor 里展示便是 Snapshot 1 和 Snapshot 2 的對比,通過觀察不難發現,圖中的 +144KB 最值得懷疑,于是我們選中它的構造器 Date,展開選中任意子項看詳情,發現其是被 Array 構造器構造出來的 dateAry 持有的(即 dateAry 中的一員),并且 dateAry 被三個地方持有,其中系統內部的 array 我們不用理會,圖 6 中寫有 "context in ()" 地方給了我們持有 dateAry 的 context 所在的位置,點擊便可以跳到代碼所在的位置了,整個操作如圖 6 所示:
圖 6 定位代碼位置
這里有一個值得注意的地方,圖 6 中的 “context in () @449305” 中的 "()",這里之所以展示為了 "()" 是因為代碼中用了“匿名函數”(代碼 2 中第 2 行的箭頭函數):
// 【寫入 date】
pushDate.addEventListener("click", ()=> {
dateCount.innerHTML=`${++dateNum}`;
for (let j=0; j < 3000; ++j) {
dateAry.push(new Date());
}
});
代碼 2 匿名函數
但是如果我們給函數起一個名字,如下面的代碼所示,也就是如果我們使用具名函數(代碼3 第 2 行函數 add)或者將函數賦值給一個變量并使用這個變量(第 10 和 18 行的行為)的時候,devtools 中都可以看到相應的函數的名字,這也就可以幫助我們更好的定位代碼,如圖 7 所示。
// 【寫入 date】
pushDate.addEventListener("click", function add() {
dateCount.innerHTML=`${++dateNum}`;
for (let j=0; j < 3000; ++j) {
dateAry.push(new Date());
}
});
const clear=document.querySelector(".clear");
const doClear=function () {
dateAry=[];
dateCount.innerHTML="0";
};
// 【回收內存】
clear.addEventListener("click", doClear);
代碼 3 具名函數
圖 7 具名函數方便定位
這樣我們便找到了代碼可疑的地方,只需要將代碼的作者抓過來對著他一頓“分析”這個內存泄漏的問題基本就水落石出了。
其實,Snapshot 除了“Comparison”之外還有一個更便捷的用于對比的入口,在這里直接可以看到在錄制 Snapshot 1 和 Snapshot 2 兩個時間點之間被分配出來的內存,用這種方式也可以定位到那個可疑的 Date x 3000:
圖 8 Snapshot 比較器
上文件介紹的是用 Heap Snapshot 尋找內存泄漏點的方法,這個方法的優點:可以錄制多個 Snapshot,然后方便的兩兩比較,并且能看到 Snapshot 中的全量內存,這一點是下文要講的“Allocation instrumentation on timeline”方法不具備的,并且這種方法可以更加方便地查找后面會講的因 Detached Dom 導致的內存泄漏。
2 Allocation instrumentation on timeline
但是,不知道你有沒有覺得,這種高頻率地錄制 Snapshot、對比、再對比的方式有點兒麻煩?我需要不斷的去點擊“add date”,然后鼠標又要跑過去點擊手動 GC、錄制 Snapshot、等待錄制完畢,再去操作,再去錄制。有沒有簡單一些的方式來查找內存泄漏?這個時候我們回到 Memory 最初始的界面,你突然發現 “Heap snapshot”下面還有一個 radio:“Allocation instrumentation on timeline”,并且這個 radio 下面的介紹文案的最后寫著:“Use this profile type to isolate memory leaks”,原來這是一個專門用于調查內存泄漏的工具!于是,我們選中這個 radio,點擊開始錄制按鈕,然后將注意力放在頁面上,然后你發現當點擊“add date”按鈕時,右面錄制的 timeline 便會多出一個心跳:
圖 9 Allocation instrumentation on timeline
如圖 9 所示,每當我們點擊“add date”按鈕時,右面都有一個對應的心跳,當我們點擊“clear”按鈕時,剛才出現的所有心跳便全都“縮回”去了,于是我們得出結論:每一個“心跳”都是一次內存分配,其高度代表內存分配的量,在之后的時間推移過程中,如果剛才心跳對應的被分配的內存被 GC 回收了,“心跳”便會跟著變化為回收之后的高度。于是,我們便擺脫了在 Snapshot 中來回操作、錄制的窘境,只需要將注意力集中在頁面的操作上,并觀察哪個操作在右邊的時間線變化中是可疑的。
經過一系列操作,我們發現“add date”這個按鈕的點擊行為很可疑,因為它分配的內存不會自動被回收,也就是只要點擊一次,內存就會增長一點,我們停止錄制,得到了一個 timeline 的 Snapshot,這個時候如果我們點擊某個心跳的話:
圖 10 點擊某個心跳
熟悉的 Date x 3000 又出現了(圖 11),點擊一個 Date 對象看持有鏈,接下來便跟上文 Snapshot 的持有鏈分析一樣了:
圖 11 通過 timeline 找到泄漏點
這個方法的優點上文已經說明,可以非常直觀、方便的觀察內存隨可疑操作的分配與回收過程,可以方便的觀察每次分配的內存。它的缺點:錄制時間較長時 devtools 收集錄制結果的時間會很長,甚至有時候會卡死瀏覽器;下文會講到 detached DOM,這個工具不能比較出 detached DOM,而 heap snapshot 可以。
3 performance
devtools 中的 Performance 面版中也有一個 Memory 功能,下面看一下它如何使用。我們把 Memory 勾選上,并錄制一個 performance 結果:
圖 12 Performance 的錄制過程
在圖 12 中可以看到,在錄制的過程中我們連續點擊“add date”按鈕 10 次,然后點擊一次“clear”按鈕,然后再次點擊“add date” 10 次,得到的最終結果如圖 13 所示:
圖 13 Performance 的錄制結果
在圖 13 中我們可以得到下面的信息:
圖 14 通過 Performance 定位問題代碼
這種方法的優點:可以直觀得看到內存的總體走勢,并且同時得到所有操作過程中的函數調用棧和時間等信息。缺點:沒有具體的內存分配的細節,錄制的過程不能實時看到內存分配的過程。
1 全局
JS 采用標記清掃法去回收無法訪問的內存對象,被掛載在全局對象(在瀏覽器中即指的是 window 對象,在垃圾回收的角度上稱其為根節點,也叫 GC root)上的屬性所占用內存是不會被回收的,因為其是始終可以訪問的,這也符合“全局”的命名含義。
解決方案就是避免用全局對象存儲大量的數據。
2 閉包(closure)
我們把【代碼 1】稍加改動便可以得到一個閉包導致內存泄漏的版本:
代碼 3 閉包導致內存泄漏
將上述代碼加載到 chrome 中,并用 timeline 的方式錄制一個 Snapshot,得到的結果如圖 15 所示:
圖 15 閉包的錄制結果
我們選中 index=2 的心跳,可以看到 Constructor 里面出現了一個 "(closure)",我們展開這個 closure,可以看到里面的 "inner()",inner() 后面的 "()" 表示 inner 是一個函數,這時候你可能會問:“圖中的 Constructor 的 Retained Size 大小都差不多,為什么你要選 (closure)?”,正是因為沒有明顯占比較高的 Retained Size 我們才隨便選一個調查,后面你會發現不管你選了哪一個最后的調查鏈路都是殊途同歸的。
我們在下面的 Retainers 中看下 inner() 的持有細節:從下面的 Retainers 中可以看出 inner() 這個 closure 是某個 Array 的第 2 項(index 從 0 開始),而這個數組的持有者是 system/Context(即全局) 中的 ary,通過觀察可以看到 ary 的持有大小(Retained Size)是 961KB 大約等于 192KB 的 5 倍,5 即是我們點擊“add date”按鈕的次數,而下面的 5 個 "previous in system/Context" 每個大小都是 192KB,而它們最終都是被某個 inner() 閉包持有,至此我們便可以得出結論:全局中有一個 ary 數組,它的主要內存是被 inner() 填充的,通過藍色的 index.html:xx 處的代碼入口定位到代碼所在地看一下一切就都了然了,原來是 inner() 閉包內部持有了一個大對象,并且所有的 inner() 閉包及其持有的大對象都被 ary 對象持有,而 ary 對象是全局的不會被回收,導致了內存泄漏(如果這種行為不符合預期的話)。返回去,如果這個時候你選擇上面提到的 system/Context 構造器,你會看到(見圖 16,熟悉吧):
圖 16 system/Context
也就是你選擇的 system/Context 其實是 inner() 閉包的上下文對象(context),而此上下文持有了 192KB 內存,通過藍色的 index.html:xx 又可以定位到問題代碼了。如果你像圖 17 一樣選擇了 Date 構造器進行查看的話也可以最終定位到問題,此處將分析過程留給讀者自己進行:
圖 17 選中 Date 構造器
3 Detached DOM
我們先看一下下面的代碼,并用 chrome 載入它:
代碼 4 Detached Dom
然后我們采用 Heap Snapshot 的方式將點擊“del”按鈕前后的兩個 snapshot 錄制下來,得到的結果如圖 6 所示。我們選用和 snapshot 1 對比的方式并在 snapshot 2 的過濾器中輸入 "detached"。我們觀察得到的篩選結果的 "Delta" 列,其中不為 0 的列如下:
要解釋上述表格需要先介紹一個知識點:DOM 對象被回收需要同時滿足兩個條件,1、DOM 在 DOM 樹中被刪掉;2、DOM 沒有被 JS 對象引用。其中第二點還是比較容易被忽視的。正如上面的例子所示,Detached HTMLButtonElement +1 代表有一個 button DOM 被從組件樹中刪掉了,但是仍有 JS 引用之(我們不考慮有意為之的情況)。
相似的,Detached EventListener 也是因為 DOM 被刪掉了,但是事件沒有解綁,于是 Detached 了,解決方案也很簡單:及時解綁事件即可。
于是解決的方法就很簡單了:參見代碼 5,回掉函數 del 在執行完畢時臨時變量會被回收,于是兩個條件就都同時滿足了,DOM 對象就會被回收掉,事件解綁了,Detached EventListener 也就沒有了。值得注意的是 table 元素,如果一個 td 元素發生了 detached,則由于其自身引用了自己所在的 table,于是整個 table 就也不會被回收了。
代碼 5 Detached DOM 的解決方法
圖 18 Detached DOM 的 Snapshot
Performance monitor 工具
DOM/event listener 泄漏在編寫輪播圖、彈窗、toast 提示這種工具的時候還是很容易出現的,chrome 的 devtools 中有一個 Performance monitor 工具可以用來幫助我們調查內存中是否有 DOM/event listener 泄漏。首先看一下代碼 6:
代碼 6 不斷增加 DOM NODE
按照我們圖 19 的方式打開 Performance monitor 面版:
圖 19 打開 Performance monitor 工具
DOM Nodes 右側的數量是當前內存中的所有 DOM 節點的數量,包括當前 document 中存在的和 detached 的以及計算過程中臨時創建的,每當我們點擊一次“add date”按鈕,并手動觸發 GC 之后 DOM Nodes 的數量就 + 2,這是因為我們向 document 中增加了一個 button 節點和一個 button 的文字節點,就像圖 20 中所示。如果你寫的 toast 組件在臨時插入到 document 并過一會兒執行了 remove 之后處于了 detached 狀態的話,Performance monitor 面版中的 DOM Nodes 數量就會不斷增加,結合 snapshot 工具你便可以定位到問題所在了。值得一提的是,有的第三方的庫的 toast 便存在這個問題,不知道你被坑過沒有。
圖 20 不斷增加的 DOM Nodes
4 console
這一點可能有人不會留意到,控制臺打印的內容是需要始終保持引用的存在的,這一點也是值得注意的,因為打印過多過大對象的話也是會造成內存泄漏的,如圖 21 所示(配合代碼 7)。解決方法便是不要肆意打印對象到控制臺中,只打印必要的信息出來。
代碼 7 console 導致內存泄漏
圖 21 console 導致的內存泄漏
本文用了幾個簡單的小例子介紹了內存泄漏出現的時機、尋找泄漏點的方法并將各種方法的優缺點進行了對比,總結了避免出現內存泄漏的注意點。希望能對讀者有所幫助。文中如果有本人理解錯誤或書寫錯誤的地方歡迎留言指正。
參考
https://commandlinefanatic.com/cgi-bin/showarticle.cgi?article=art038
https://developer.chrome.com/docs/devtools/memory-problems/
https://www.bitdegree.org/learn/chrome-memory-tab
作者 | 木及
原文鏈接:http://click.aliyun.com/m/1000287965/
本文為阿里云原創內容,未經允許不得轉載。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。