從建立http連線開始,到頁面展示到瀏覽器裡,經歷了載入、執行、渲染,重構的幾個階段。將分享下我自己的心得和其他人的優秀經驗。
載入和執行
瀏覽器是友善的客戶端,對同域名併發請求是有數量限制,過去瀏覽器一般是2個,支援H5的一般是6個;並且伺服器端是可以關閉請求。 有朋友不理解,為什麼不是併發越多越好?舉個例子:百萬級的PV,併發數量過大會造成什麼樣的後果? 由此,所有的優化都是基於這個點和單執行緒而延伸出來的。 所以,前端的資源載入優化有兩個方向
- 開源 增加域名 既然同域名不能太多,那麼就多域名;簡單來說就是cdn,可以是第三方,也可以自己多弄幾個二級域名
- 節流
資源壓縮、按需載入 同域名內的檔案充分的進行壓縮,比如:本來2M的資源,如果壓縮到1M以下(去除空格,gzip等)速度的提升就是50%;再有現在spa是將檔案合併後進行壓縮和打包,如果檔案總體並不大,效能不會有太大影響;一旦開發中引入的UI庫或第三方外掛多了,總檔案體量也不在少數;就有了:按需載入、延時載入的用武之地。比如在webpack打包的時候從template的html中單獨加入某個css或js;更有webpack-http-require的庫。
當然,圖片也需要做很多相應的處理
- css實現效果(按鈕、陰影等)
- 壓縮尺寸和size
- sprite合併
- svg、toff字型圖
- canvas繪製大圖(地圖相關)
阻塞性優化
js檔案載入後是否要立即執行?立即執行是否會影響頁面渲染?過去瀏覽器在載入和執行js檔案時是阻塞狀態,就是按照棧原理一個個來;所以,原來要求把js檔案放到html程式碼底部前,現代瀏覽器某種程度上解決了並行載入的問題,也可以進行預載入,但是執行之後會否對頁面造成重排?所以要靈活應用dns-prefetch、preload和defer|async,當然defer和async不是所有瀏覽器都生效,webkit核心的就沒生效。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Demo</title>
<link rel="dns-prefetch" href="//cdn.com/">
<link rel="preload" href="//js.cdn.com/currentPage-part1.js" as="script">
<link rel="preload" href="//js.cdn.com/currentPage-part2.js" as="script">
<link rel="preload" href="//js.cdn.com/currentPage-part3.js" as="script">
<link rel="prefetch" href="//js.cdn.com/prefetch.js">
</head>
<body>
<!-- html code -->
<script type="text/javascript" src="//js.cdn.com/currentPage-part1.js" defer></script>
<script type="text/javascript" src="//js.cdn.com/currentPage-part2.js" defer></script>
<script type="text/javascript" src="//js.cdn.com/currentPage-part3.js" defer></script>
</body>
</html>
複製程式碼
js執行優化
- 作用域優化,變數層級不要太深或巢狀太多,最好是本級;大家在看各大框架或庫的時候,經常可以看到這種寫法:
(function(w,d){})(window,document)
// 目的就是如此,再比如說的快取某個變數或物件
function check(){
var d = document, t = document.getElementById('t'), l = t.children;
for(let i=0;i<l;i++){
//code
}
}
複製程式碼
- 迴圈優化 迴圈是程式設計中最常見的結構,優化迴圈是效能優化過程中很重要的一部分。一個迴圈的基本優化步驟如下:
減值迭代——大多數迴圈使用一個從0開始,增加到某個特定值的迭代器。在很多情況下,從最大值開始,在迴圈中不斷減值的迭代器更加有效。 簡化終止條件——由於每次迴圈過程都會計算終止條件,故必須保證它儘可能快,即避免屬性查詢或其它O(n)的操作。 簡化迴圈體——迴圈體是執行最多的,故要確保其被最大限度地優化。確保沒有某些可以被很容易移出迴圈的密集計算。 使用後測試迴圈——最常用的for和while迴圈都是前測試迴圈,而如do-while迴圈可以避免最初終止條件的計算,因些計算更快。
for(var i = 0; i < values.length; i++) {
process(values[i]);
}
複製程式碼
優化1:簡化終止條件
for(var i = 0, len = values.length; i < len; i++) {
process(values[i]);
}
複製程式碼
優化2:使用後測試迴圈(注意:使用後測試迴圈需要確保要處理的值至少有一個)
var i values.length - 1;
if(i > -1) {
do {
process(values[i]);
}while(--i >= 0);
}
複製程式碼
- 展開迴圈
當迴圈的次數確定時,消除迴圈並使用多次函式呼叫往往更快 當迴圈的次數不確定時,可以使用Duff Service來優化,基本概念是通過計算迭代的次數是否為8的倍數將一個迴圈展開為一系列語句。如下:
// Jeff Greenberg for JS implementation of Duff's Device
// 假設:values.length > 0
function process(v) {
alert(v);
}
var values = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17];
var iterations = Math.ceil(values.length / 8);
var startAt = values.length % 8;
var i = 0;
do {
switch(startAt) {
case 0 : process(values[i++]);
case 7 : process(values[i++]);
case 6 : process(values[i++]);
case 5 : process(values[i++]);
case 4 : process(values[i++]);
case 3 : process(values[i++]);
case 2 : process(values[i++]);
case 1 : process(values[i++]);
}
startAt = 0;
}while(--iterations > 0);
複製程式碼
如上展開迴圈可以提升大資料集的處理速度。接下來給出更快的Duff裝置技術,將do-while迴圈分成2個單獨的迴圈。(注:這種方法幾乎比原始的Duff裝置實現快上40%。)
// Speed Up Your Site(New Riders, 2003)
function process(v) {
alert(v);
}
var values = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17];
var iterations = Math.floor(values.length / 8);
var leftover = values.length % 8;
var i = 0;
if(leftover > 0) {
do {
process(values[i++]);
}while(--leftover > 0);
}
do {
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
process(values[i++]);
}while(--iterations > 0);
複製程式碼
針對大資料集使用展開迴圈可以節省很多時間,但對於小資料集,額外的開銷則可能得不償失。
- 避免雙重解釋
當JS程式碼想解析JS程式碼時就會存在雙重解釋懲罰,當使用eval()函式或是Function建構函式以及使用setTimeout()傳一個字串時都會發生這種情況。如下
eval("alert('hello world');"); // 避免
var sayHi = new Function("alert('hello world');"); // 避免
setTimeout("alert('hello world');", 100);// 避免
複製程式碼
以上程式碼是包含在字串中的,即在JS程式碼執行的同時必須新啟運一個解析器來解析新的程式碼。例項化一個新的解析器有不容忽視的開銷,故這種程式碼要比直接解析要慢。以下這幾個例子,除了極少情況下eval是必須的,應儘量避免使用上述。對於Function建構函式,直接寫成一般的函式即可。對於setTimeout可以傳入函式作為第一個引數。如下:
alert('hello world');
var sayHi = function() {
alert('hello world');
};
setTimeout(function() {
alert('hello world');
}, 100);
複製程式碼
總之,若要提高程式碼效能,儘可能避免出現需要按照JS解釋的程式碼。
-
效能的其它注意事項 原生方法更快——只要有可能,使用原生方法而不是自已用JS重寫。原生方法是用諸如C/C++之類的編譯型語言寫出來的,要比JS的快多了。很多人認為自定義的排序會比sortby更快,事實比對效果還是原生方法更加優秀。 switch語句較快——若有一系列複雜的if-else語句,可以轉換成單個switch語句則可以得到更快的程式碼,還可以通過將case語句按照最可能的到最不可能的順序進行組織,來進一步優化。 位運算較快——當進行數學運算時,位運算操作要比任何布林運算或算數運算快。選擇性地用位運算替換算數運算可以極大提升複雜計算的效能,諸如取模,邏輯與和邏輯或也可以考慮用位運算來替換。
-
最小化語句數 JS程式碼中的語句數量也會影響所執行的操作的速度,完成多個操作的單個語句要比完成單個操作的多個語句塊快。故要找出可以組合在一起的語句,以減來整體的執行時間。這裡列舉幾種模式
1.多個變數宣告
// 避免
var i = 1;
var j = "hello";
var arr = [1,2,3];
var now = new Date();
// 提倡
var i = 1,
j = "hello",
arr = [1,2,3],
now = new Date();
複製程式碼
2.插入迭代值
// 避免
var name = values[i];
i++;
// 提倡
var name = values[i++];
複製程式碼
3.使用陣列和物件字面量,避免使用建構函式Array(),Object()
// 避免
var a = new Array();
a[0] = 1;
a[1] = "hello";
a[2] = 45;
var o = new Obejct();
o.name = "bill";
o.age = 13;
// 提倡
var a = [1, "hello", 45];
var o = {
name : "bill",
age : 13
};
複製程式碼
4.優化DOM互動 在JS中,DOM無疑是最慢的一部分,DOM操作和互動要消耗大量時間,因為它們往往需要重新渲染整個頁面或者某一個部分,故理解如何優化與DOM的互動可以極大提高指令碼完成的速度。後面會針對性說明
陣列儲存
電腦科學中有個經典問題:通過改變資料儲存的位置來獲得最佳的讀寫效能,資料儲存的位置關係到程式碼執行過程中資料的檢索速度。在JS中這個問題相對簡單,因為只有4種方案。
- 字面量 字面量就代表自身,不儲存在特定位置。JS字面量有:字串、數字、布林、物件、陣列、函式、正規表示式和特殊的null、undefined
- 本地變數 使用var定義的資料儲存單元
- 陣列元素 儲存在JS物件內部,以數字作為索引
- 物件成員 儲存在JS物件內部,以字串作為索引 每一種資料儲存的位置都有不同的讀寫消耗。大多情況下差不多,陣列和物件代價稍高一些,具體表現看瀏覽器的效能和js直譯器。儘量使用字面量和區域性變數,減少陣列項和物件成員的使用。
作用域
理解作用域概念是JS和核心關鍵,不僅從效能還得從功能的角度。簡單說:生效的範圍(域),哪些變數可以被函式訪問,this的賦值,上下文(context)的轉換。說到作用域就不能繞開作用域鏈。理解了作用域鏈和識別符號就理解了作用域。
作用域鏈和識別符號解析
每個函式都是Function物件的例項,Function物件和其它物件一樣,擁有可以程式設計訪問的屬性,和一系列不能通過程式碼訪問而僅供JS引擎存取的內部屬性。其中一個內部屬性是[[Scope]],有ECMA-262標準第三版定義
內部屬性[[Scope]]包含了一個函式被建立的作用域中物件的集合。這個集合被稱為函式的作用域鏈,它決定那些資料能被函式訪問,函式作用域中的每個物件被稱為一個可變物件,每個可變物件都以“鍵值對”的形式存在。當一個函式建立後,他的作用域鏈會被建立此函式的作用域中可訪問的資料物件所填充。例如:
function fn(a,b){
return res = a*b;
}
複製程式碼
當fn建立時,它的作用域鏈中插入了一個物件變數,這個全域性物件代表著所有在全域性範圍內定義的變數。該全域性物件包含window、navigator、document等。fn執行的時候就會用到作用域,並建立執行環境也叫執行上下文。它定義了一個函式執行時的環境,即便是同一個函式,每次執行都建立新的環境,函式執行完畢,環境就銷燬。 每個環境都要根據作用域和作用域鏈解析引數、變數。可以理解為作用域鏈好比一個堆疊,棧頂就是當前的活動物件(環境建立時函式[[Scope]]屬性中的物件集合)大多情況也可以理解為函式內部定義的區域性變數。
而閉包的是根據JS允許函式訪問區域性作用域之外的資料,雖然會帶來效能問題,因為執行環境雖然銷燬,但啟用的物件依然存在,所以可以快取變數,從而不用全域性物件。適用
物件
屬性和方法,兩者都是物件的成員,引用了函式就是方法,非函式就是屬性。為什麼物件訪問慢呢?因為原型鏈問題。
原型和原型鏈
直接看程式碼
function fun(name,age){
this.name = name+'';
this.age = age
}
fun.prototype.getName = function(){
return this.name;
}
var fn = new fun();
true = (fn instanceof fun) //true
true = (fn instanceof Object)
fn.__proto__ = fun.prototype
/*
* fun的原型方法
__proto__ = null
hasOwnProperty = (function)
isPrototypeOf = (function)
propertyIsEnumerable = (function)
toLocaleString = (function)
toString = (function)
valueOf = (function)
*/
複製程式碼
平時普通變數也是這樣一級級向上直到根(window)下,沒有此變數或屬性或方法,才返回undefined;
DOM程式設計
DOM操作代價高昂,這是web application最常見的效能瓶頸,Document Oject Module(DOM)是獨立於語言的,用於操作xml和html文件的的程式介面,而且在瀏覽器中是通過js實現的。 各個公司的瀏覽器渲染和js解釋引擎都不同,著名的V8相信大家都知道,是一個js引擎;但Chrome的渲染是WebCore。每個瀏覽器都有兩套直譯器,並相對獨立。這就意味著每次操作都需要(V8<=>WebCore)==>Browser 兩個直譯器都是需要連線和通訊成本。減少兩直譯器通訊並減少頁面改變的頻率就是優化的方向。
重繪repaint和重排reflow
DOM樹裡的每個需要顯示的節點在渲染樹中至少存在一個對應的節點,隱藏的(display:none)的DOM元素則沒有;渲染樹的節點被稱為幀(frames) 盒(boxes),DOM和渲染樹構建完畢,瀏覽器就開始繪製頁面元素(paint)
何時發生重重繪?當頁面的幾何屬性發生變化,影響到現有的文件流需要重新調整頁面排版的時候。舉幾個例子:
- 新增或刪除可見的DOM元素;
- DOM元素位置改變;
- DOM元素尺寸改變:容器padding、border、margin屬性變化等;
- 容器內的內容變化導致寬高變化:文字行數變多(少)、圖片坍塌、圖片被另一張大圖替換
- 瀏覽器視窗初始化和尺寸改變 重排結束後,就需要重繪。所以,儘可能的避免重排的產生,為了避免或少的進行重繪和重排,需要儘可能少的訪問某些變數:
offsetTop、offsetLeft、offsetWidth、offsetHeight
scrollTop、scrollLeft、scrollWidth、scrollHeight
clientTop、clientLeft、clientWidth、clientHeight
getComputedStyle() (currentStyle in IE)
function scroller(){
var H = document.body.offsetHeight || scrollHeight
return function(){
var args = arguments,ct = this;
// your code
}
}
複製程式碼
為了最小和最少的影響到重繪和重排,應該儘可能少的修改DOM,訪問影響重排的屬性。如果非要修改,儘量尊從三個步驟: 1.元素脫離文件流 2.一次性應用多重改變 3.恢復到文件流中 第一和第三步都會發生重排,所以核心的還是第二步。現在虛擬dom大?,我們稍微瞭解下基礎做法即可。 一次性更新的幾種方式:字串或陣列.join('') innerHTML方式,createElement最後appendChild,document.createDocumentFragment,cloneNode需要改變的節點到快取節點中,改完替換。 再者,動畫時也需要儘可能少重繪和重排,例如:沿對角線,從左上移動到右下角
function move2RB(){
var dom = document.getElementById('id'),curent = dom.style.top;
while(curent<500){
curent++
dom.style.cssText = 'left:'+curent+'px; top:'+curent+'px';
}
}
// 不要寫成每次都去獲取,left=dom.style.left再加1,甚至是dom.style.left = (pareSint(dom.style.left,10)+1)+'px'這種寫法,直接改變className也是可以的。
複製程式碼
總結起來就幾句話:少訪問DOM,在js裡處理計算完了再一次性修改,善用快取和原生API;用現在的三大框架(angular、react、vue)即可不用操心這些 :)
演算法和流程控制
程式碼的整體結構是影響執行速度的主要因素之一,數量與執行速度不一定成正比。組織結構、思路和執行效率才是核心!! JS屬於ECMA的範疇,是一種指令碼類語言,很多流程上的控制,工程化的思路是從java、c等語言上借鑑過來的,所以,知道後端語言的編碼和工程化有助於我們加深理解。
Loop迴圈
- for
for(var i=0;i<10;i++){
// code
}
複製程式碼
倒序可以在大資料量時提高少許效率,i<obj.length;i-- 2. while 前置迴圈
var i=0;
while(i<10){
// code
i++;
}
複製程式碼
後置迴圈
var i=0;
do{
// code
}while(i++<10)
複製程式碼
- for - in
for(var prop in object){
// code
}
複製程式碼
除了for-in迴圈其它效率所差不多,那麼能夠提高效率的點也就兩個
- 每次迭代處理的事務
- 迭代的次數 一般陣列array遍歷寫法的迴圈中,每次都會有如下操作: 1.在控制條件中查詢一次屬性array.length 2.在控制條件中執行一次數值比較 (i<array.length) 3.比較迴圈條件是否滿足,(i<array.length === true) 4.一次自增或自減操作 (i++||i--) 5.陣列、物件查詢 array[i] 6.具體事務處理 將length提前獲取並存到變數中可以減少兩步(1和2),當迴圈複雜度為O(n)時,減少每次迭代的工作量是最有效的,當複雜度大於O(n),需要著重減少迭代次數。
條件判斷
if-else vs switch 條件數量越多,switch的迭代效率會更高;當只有二選一或簡單判斷if-else的易讀性更好。在實際coding中,如果只有二選一,有些情況甚至可以不用if-else,採用三目運算:result = (true||false)?condition0:condition1;還有將最可能發生的條件寫到“if()”裡面,減少判斷次數,延伸開來就是if-elseif的判斷可能性要從大到小。甚至可以採用二分法:
//---假設某引數的值非正即負,或查詢二叉樹,或查詢不同SP的手機號
if(parse>0){
if(parse>10){
//code
}else if(parse<5&&parse>1){
//code
}else{
}
}else{
//code 負數處理
}
複製程式碼
當然,這是個簡單的栗子,還有很多其它的方式可以在程式碼中引入演算法,提高效率,比如星期幾的輸出
function getweek(){
var w = ['日','一','二','三','四','五','六'],
now = new Date(),
d = now.getDay();
return '星期'+w[d];
}
複製程式碼
可以將字串、變數、方法,存到陣列或物件中。因為是引用,效率也非常快
遞迴
1.遞迴
//---階乘
function facttail(n){
if(n==0){
return 1;
}else{
return n*facttail(n-1);
}
}
//---冪次方
function fn(s,n){
if(n==0){
return 1;
}else{
return s*fn(s,n-1);
}
}
複製程式碼
但是遞迴如果結束條件不明確就會導致一直執行,頁面長時間不響應。處於假死狀態!!而且,每個瀏覽器的“呼叫棧”都是有上限的。有興趣的可以自己實驗。為避免此問題,除了明確結束條件,還可以採用“尾遞迴” 2.尾遞迴
//---階乘
function facttail(n,res){
if(n<0){
return 0;
}else if(n==0){
return 1;
}else if(n==1){
return res;
}else{
return facttail(n-1, n*res);
}
}
//---冪次方
function fn(s,n){
if(n==0){
return 1;
}else{
return s*fn(s,n-1);
}
}
複製程式碼
快取記憶
利用閉包特性,某個方法內部可以儲存計算過的資料或變數,比如階乘函式重寫
function memfacttail(n){
if(!memfacttail.cache){
memfacttail.cache = {
"0":1,
"1":1
};
}
if(!memfacttail.cache.hasOwnProperty(n)){
memfacttail.cache.n = n * memfacttail(n-1);
}
return memfacttail.cache.n;
}
複製程式碼
字串和正則
*?+ 這個部份也需要蠻長的篇幅,佔坑先。。。
快速響應的頁面
老生常談的內容,如果讓頁面秒開;可優化的點有哪些?伺服器直渲、首頁優化、元件懶載入、bigpipe、效能監控和針對性優化等等。
瀏覽器執行緒
先挖坑,我會再專門的文章裡共享一點自己的心得
event loop
阮一峰老師說的更好,請移步連結 This link
Web Workers
從Google的Gears外掛提出了Worker Pool API,它就是Web Workers的“原型”,最初希望能夠增強瀏覽器的功能,比如支援離線瀏覽(離線訪問快取頁面,重新上線後提交離線操作),但(2015/11)已經被棄用了。HTML5開始Web Workers API被分離出去,成立單獨的規範。自此,我們可以將計算、編解碼、真正的非同步請求等放到Web Workers裡.
- 執行環境 worker的global context並不是window,而是self,self也提供一系列介面,包括self.JSON、self.Math、self.console等等,最直觀的區別是document物件沒了,但location(readonly)、navigator還在;所以DOM訪問也不存在。要啟用它只能在建立一個獨立的js檔案並通過下面的方式呼叫
// html中直接寫
var worker = new Worker('worker.js')
// 或通過主頁面的js檔案呼叫,例如:main.js
//---主頁面
if (window.Worker) {
var worker = new Worker('worker.js');
var data = {a: 1, b: [1, 2, 3], c: 'string'};
worker.postMessage(data);
worker.onmessage = function(e) {
console.log('main thread received data');
console.log(e.data);
// 接到訊息立即停止worker,onerror將不會觸發
// worker.terminate();
// terminate之後收不到後續訊息,但post不報錯
// worker.postMessage(1);
}
worker.onerror = function(err) {
console.log('main thread received err');
console.log(err.message);
// 阻止報錯
err.preventDefault();
}
}
複製程式碼
worker.js
//---處理js,可以引入其它依賴
// importScripts('lib.js');
// importScripts('a.js', 'b.js');
onmessage = function(e) {
console.log(self); // 看看global變數身上有些什麼
var data = e.data;
console.log('worker received data');
console.log(data);
var res = data;
res.resolved = true;
postMessage(res);
setTimeout(function() {
throw new Error('error occurs');
// close,立即停止,相當於主執行緒中的worker.terminate()
// close();
}, 100);
};
複製程式碼
- 通訊 主執行緒和worker執行緒收發訊息方式一致(postMessage發,onmessage/onerror收,資料從MessageEvent的data屬性取),PS:執行緒之間傳遞的是值copy,而不是共享引用
- 載入外部檔案 importScripts可以引入其它js檔案,外部檔案中的全域性變數將被粘在self上,worker裡可以直接引用。importScripts是同步的,下載並執行完畢後執行下一行,所以,需要注意阻塞性問題。 應用範圍:
- 音訊/視訊解碼 如果嘗試過audioContext.decodeAudioData之類的操作就會發現,我們迫切需要一個能“乾重活”的後臺執行緒
- 圖片預處理 比如頭像上傳前的裁剪,甚至新增水印、拼合、添馬賽克,如果在客戶端能夠完成,就能避免大量的臨時檔案傳輸
- 排序等資料處理演算法 減輕伺服器壓力,遇到超大資料或超過200ms不能處理完畢的方法 資料過大的JSON物件,超出允許時間
//---main
var worker = new Worker('worker.js')
worker.onmessage = (e)=>{
var jsonData = e.data // 回傳回來的資料
evaluateData(jsonData)
}
worker.postmessage(jsonText)
///---worker
self.onmessage = (e){
var jsonText = e.data // main傳過來的資料
var jsonData = JSON.parse(jsonText) // 解析轉換
self.postMessage(jsonData)
}
複製程式碼
- 客戶端模版 比如markdown,或者服務端返回JSON,客戶端拿到後交給後臺執行緒解析並應用模版HTML生成頁面,這些操作都由客戶端完成的話,需要傳輸的東西就更少了
- 共享worker 必須是同源!必須是同源!必須是同源!
//---main
var sWorker = new SharedWorker('worker.js')
sWorker.port.start()
//---first
first.onchange = function() {
sWorker.port.postMessage([first.value,second.value]);
console.log('Message posted to worker');
}
//---first
second.onchange = function() {
sWorker.port.postMessage([first.value,second.value]);
console.log('Message posted to worker');
}
sWorker.port.onmessage = function(e) {
result1.textContent = e.data;
console.log('Message received from worker');
}
複製程式碼
通過這種方式建立前端的“類似”多執行緒
工具
debug工具list
- YUI
- Firebug
- IE
- Safari
- Chrome
優化工具,具體請搜尋,就不當搬運工了。
- page speed
- Fiddler
- YSlow
- dynaTrace