1. 程式人生 > >Web優化躬行記(2)——JavaScript

Web優化躬行記(2)——JavaScript

一、語言

1)慎用全域性變數

  當變數暴露在全域性作用域中時,由於全域性作用域比較複雜,因此查詢會比較慢。

  並且還有可能汙染window物件,覆蓋之前所賦的值,發生意想不到的錯誤。

0 == ''     //true
0 == '0'    //false

3)簡寫

  簡寫的方式很多,此處只會列舉其中的幾種,例如用三目運算替代if-else語句,或用&&或||符號替代條件語句。

if (count > 1) {
  ++a;
} else {
  --a;
}
// 簡寫
count > 1 ? (++a) : (--a);

if (count) {
  ++a;
}
// 簡寫
count && (++a)

  利用ES6語法,可以用解構賦值,簡潔明瞭。還有些小技巧包括用箭頭函式表示回撥,塊級作用域變數等。

const { count } = obj;

  關於去除陣列中的重複數,可以採用ES6最新的Set資料結構。

Array.from(new Set(arr));

4)減少魔法數

  魔法數是指意義不明的常量,例如直接在程式碼中使用一個數字1,其判斷條件令人費解。

if(type == 1) { }

  而如果將該數字賦給一個語義化的常量後,就能明確其意圖。

const ERROR_TYPE = 1;
if(type == ERROR_TYPE) { }

5)位運算

  用位運算取代純數學操作,例如對2取模(digit%2)判斷偶數與奇數。

if (digit & 1) {
  // 奇數(odd)
} else {
  // 偶數(even)
}

  位掩碼技術,使用單個數字的每一位來判斷選項是否成立。掩碼中每個選項的值都是2的冪。

var OPTION_A = 1, OPTION_B = 2, OPTION_C = 4, OPTION_D = 8, OPTION_E = 16;
//用按位或運算建立一個數字來包含多個設定選項
var options = OPTION_A | OPTION_C | OPTION_D;
//接下來可以用按位與操作來判斷給定的選項是否可用
//選項A是否在列表中
if(options & OPTION_A) {
  //...
}

  用按位左移(<<)做乘法,用按位右移做除法(>>),例如digit*2可以替換成digit<<2。

6)字串拼接

  除了使用加號(+)或加等(+=)實現字串拼接之外,還可以使用陣列的join()和字串的concat()方法。

["strick", "jane"].join("");
"strick".concat("jane");

  ES6提供的模板字面量是一種能夠嵌入表示式的格式化字串,也可以用來做字串拼接。

str = "My name is \"" + name + "\". M y age is " + age + ".";       //傳統拼接方式
str = `My name is "${name}". My age is ${age}.`;                 //模板字面量方式

7)正則優化

  正則優化包括:

  1. 減少分支數量,縮小分支範圍;

  2. 使用非捕獲陣列;

  3. 只捕獲感興趣的文字以減少後期處理;

  4. 使用合適的量詞;

  5. 化繁為簡,分解複雜的正則。

8)惰性模式

  惰性模式用於減少每次程式碼執行時的重複性分支判斷,通過對物件重定義來遮蔽原物件中的分支判斷。

  惰性模式分為兩種:第一種檔案載入後立即執行物件方法來重定義,第二種是當第一次使用方法物件時來重定義。

var A = {};
//載入時 損失效能 第一次載入時 不損失效能
A.on = (function (dom, type, fn) {
  if (dom.addEventListener) {
    return function (dom, type, fn) {
      dom.addEventListener(type, fn, false);
    };
  } else if (dom.attachEvent) {
    return function (dom, type, fn) {
      dom.attachEvent("on" + type, fn);
    };
  } else {
    return function (dom, type, fn) {
      dom["on" + type] = fn;
    };
  }
})();
//載入時 不損失效能 第一次載入時 損失效能
A.on = function (dom, type, fn) {
  if (dom.addEventListener) {
    A.on = function (dom, type, fn) {
      dom.addEventListener(type, fn, false);
    };
  } else if (dom.attachEvent) {
    A.on = function (dom, type, fn) {
      dom.attachEvent("on" + type, fn);
    };
  } else {
    A.on = function (dom, type, fn) {
      dom["on" + type] = fn;
    };
  }
  //執行重定義on方法
  A.on(dom, type, fn);
};

9)使用快取

  當執行for迴圈時,需要讀取陣列的長度,可以事先做快取。

for (let i = 0, len = arr.length; i < len; i++) {}

  或者在事件處理程式或物件方法中快取this指向。

var obj = {
  name: function () {
    let self = this;
  }
};
btn.addEventListener("click", function(event) {
  let self = this;
}, false);

10)記憶函式

  記憶函式是指能夠快取先前計算結果的函式,避免重複執行不必要的複雜計算,是一種用空間換時間的程式設計技巧。

  具體的實施可以有多種寫法,例如建立一個快取物件,每次將計算條件作為物件的屬性名,計算結果作為物件的屬性值。

  下面的程式碼用於判斷某個數是否是質數(質數又叫素數,是指一個大於1的自然數,除了1和它本身外,不能被其它自然數整除的數),在每次計算完成後,就將計算結果快取到函式的自有屬性digits內。

function prime(number) {
  if (!prime.digits) {
    prime.digits = {};     //快取物件
  }
  if (prime.digits[number] !== undefined) {
    return prime.digits[number];
  }
  var isPrime = false;
  for (var i = 2; i < number; i++) {
    if (number % i == 0) {
      isPrime = false;
      break;
    }
  }
  if (i == number) {
    isPrime = true;
  }
  return (prime.digits[number] = isPrime);
}
prime(87);
prime(17);
console.log(prime.digits[87]);     //false
console.log(prime.digits[17]);     //true

11)閉包

  通常來說,函式的活動物件會隨著執行環境一同銷燬。但引入閉包時,由於引用仍然存在於閉包的作用域中,因此物件無法被銷燬。

function outter(count) {
  count++;
  // 閉包
  function inner() {
    return count + 1;
  }
  return inner();
}

  這意味著閉包需要更多的記憶體開銷。在指令碼程式設計中,要小心地使用閉包。

  推薦將跨作用域的變數儲存到一個區域性變數中,然後直接訪問該區域性變數,如下所示,將count作為引數傳遞給inner()函式。

function outter(count) {
  count++;
  // 閉包
  function inner(count) {
    return count + 1;
  }
  return inner(count);
}

12)節流和去抖動

  節流(throttle)是指預先設定一個執行週期,當呼叫動作的時刻大於等於執行週期則執行該動作,然後進入下一個新週期。適用於mousemove事件、window物件的resize和scroll事件。

function throttle(fn, wait) {
  let start = 0;
  return () => {
    const now = +new Date();
    if (now - start > wait) {
      fn();
      start = now;
    }
  };
}

  去抖動(debounce)是指當呼叫動作n毫秒後,才會執行該動作,若在這n毫秒內又呼叫此動作則將重新計算執行時間。適用於文字輸入的keydown事件,keyup事件,做autocomplete等。

function debounce(fn, wait) {
  let start = null;
  return () => {
    clearTimeout(start);
    start = setTimeout(fn, wait);
  };
}

  節流與去抖動最大的不同的地方就是在計算最後執行時間的方式上。著名的開源工具庫underscore中有內建了兩個方法。

二、應用

1)合理放置指令碼

  指令碼會阻塞頁面渲染,直至全部下載並執行完成後,頁面渲染才會繼續。瀏覽器在解析到body元素之前,不會渲染頁面的任何部分。

  把指令碼放在頁面頂部會導致明顯的延遲,通常表現為空白頁面。因此推薦將所有script元素儘可能放到body元素底部。

2)無阻塞指令碼

  為了解決阻塞的問題,script元素新增了兩個布林屬性,分別是延遲(defer)和非同步(async)。

  1. defer:延遲指令碼執行,直到文件解析完成。

  2. async:儘快執行指令碼,不會阻塞文件解析。

<script src="scripts/jquery.js" defer></script>
<script src="scripts/jquery.js" async></script>

3)動態指令碼

  用JavaScript動態建立script元素,檔案的下載和執行過程不會阻塞頁面其它程序。

var hm = document.createElement("script");
hm.src = "//www.pwstrick.com/hm.js";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);

4)影象上傳

  在上傳影象時,可將其轉換成Base64,相當於將影象做成字串傳送到後臺。在下面的示例中用到了FileReader物件。

var reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = function(e) {
  var img = new Image();
  img.src = this.result;
  console.log(this.result);
};

  注意,Base64影象會比原圖要大。

5)原生方法

  JavaScript引擎提供的原生方法總是最快的。因為原生方法存在於瀏覽器中,並且都是用低階語言編寫的。

  這意味著它們會被編譯成機器碼,成為瀏覽器的一部分,不會像自己寫的JavaScript程式碼那樣受到各種限制。

  CSS查詢被JavaScript原生支援並被jQuery發揚光大。jQuery的選擇器引擎雖然很快,但是仍然比原生方法慢。

  推薦使用原生的querySelector和querySelectorAll()作為選擇器。

6)本地快取

  在HTML5的本地快取出現之前,都喜歡用cookie快取資料。但cookie資料量只有4KB左右,並且每次都會攜帶在HTTP首部中,如果使用cookie儲存過多資料會帶來效能問題。

  而localStorage和sessionStorage資料量一般在2.5M到10M之間(大部分是5M),並且不參與和伺服器之間的通訊,因此比較容易實現網頁或應用的離線化。

7)重排和重繪

  當DOM的變化影響了元素的幾何屬性(寬和高)將會發生重排(reflow),發生重排的情況如下所列。

  1. 新增或刪除可見的DOM元素

  2. 元素位置改變

  3. 元素尺寸改變(包括外邊距、內邊距、邊框寬度、寬、高等屬性)

  4. 內容改變,例如文字改變或圖片被不同尺寸的替換掉。

  5. 頁面渲染器初始化。

  6. 瀏覽器視窗尺寸改變。

  完成重排後,瀏覽器會重新繪製受影響的部分到螢幕中,此過程為重繪(repaint)。

  下面程式碼看上去會重排3次,但其實只會重排1次,大多數瀏覽器通過佇列化修改和批量顯示優化重排版過程。

//渲染樹變化的排隊和重新整理
var ele = document.getElementById('myDiv');
ele.style.borderLeft = '1px';
ele.style.borderRight = '2px';
ele.style.padding = '5px';

  但下列操作將會強迫佇列重新整理並要求所有計劃改變的部分立刻應用:

offsetTop, offsetLeft, offsetWidth, offsetHeight 
scrollTop, scrollLeft, scrollWidth, scrollHeight 
clientTop, clientLeft, clientWidth, clientHeight 
getComputedStyle() (currentStyle in IE)(在 IE 中此函式稱為 currentStyle) 

  像offsetHeight屬性需要返回最新的佈局資訊,因此瀏覽器不得不執行渲染佇列中的“待處理變化”並觸發重排以返回正確的值。

  最小化重繪和重排的方式有兩種:

  1. cssText和class,cssText可以一次設定多個CSS屬性。class也可以一次性設定,並且更清晰,更易於維護,但有前提條件,就是不依賴於執行邏輯和計算的情況。

  2. 批量修改DOM,包括隱藏元素display:none,修改後重新顯示display:block;使用文件片段fragment,在片段上操作節點,再拷貝迴文檔;將原始元素拷貝到一個脫離文件的節點中(例如position:absolute),修改副本,完成後再替換原始元素。

8)定時器

  為了不讓一些複雜的JavaScript任務阻塞執行緒,就需要將其讓出執行緒的控制權,即停止執行,可以通過定時器實現。

  當函式執行時間太長時,可以把它拆分成一系列更小的步驟,把每個獨立的方法放到定時器中回撥,如下所示,其中arguments.callee是指當前正在執行的函式。

let tasks = [openDocumnet, writeText, closeDocument, updateUI];
setTimeout(function() {
  //執行下一個任務
  let task = tasks.shift();
  task();
  //檢查是否還有其他任務
  if (tasks.length > 0) {
    setTimeout(arguments.callee, 25);
  }
}, 25);

9)動畫

  JavaScript早期的動畫是用定時器實現的,但隨著瀏覽器功能的不斷完善,出現了一種更新、效能更高的方法:requestAnimationFrame()。

  requestAnimationFrame()會在重繪之前更新下一幀的動畫,注意,回撥函式自身必須再次呼叫requestAnimationFrame(),如下所示。

function step(timestamp) {
  var progress = timestamp - start;
  element.style.left = Math.min(progress / 10, 200) + 'px';
  if (progress < 2000) {
    window.requestAnimationFrame(step);
  }
}
window.requestAnimationFrame(step);

10)Ajax

  最快的Ajax請求是沒有請求,即避免傳送不必要的請求,例如:

  1. 在服務端,設定HTTP首部資訊以確保響應會被瀏覽器快取。

  2. 在客戶端,把獲取到的資訊快取到本地。

  其它加速Ajax的技術包括:

  1. 資料格式採用輕量級的JSON,解析速度快,通用性與XML相當。

  2. 縮短頁面載入時間,主要內容載入後,再用Ajax獲取次要檔案。

  3. 確保程式碼的健壯性,錯誤不會輸出給使用者。

11)DOMContentLoaded

  當初始的HTML文件被完全載入和解析完成之後,DOMContentLoaded事件被觸發,而無需等待樣式表、影象等資源的完全載入。

document.addEventListener("DOMContentLoaded", function() { }, false);

  另一個load事件應該僅用於檢測一個完全載入的頁面。

  注意,DOMContentLoaded事件必須等待其所屬script之前的樣式表載入解析完成後才會觸發。

12)事件委託

  事件委託(event delegation)是一種提高程式效能、降低記憶體空間的技術手段,它利用了事件冒泡的特性,只需在某個祖先元素上註冊一個事件,就能管理其所有後代元素上同一型別的事件。

  通過事件物件的target屬性,就能分辨出當前執行在哪個事件目標上,如下所示。

container.addEventListener("click", function(event) {
  event.target;
}, false);

  使用委託後就能避免對容器中的每個子元素註冊事件,並且如果在容器中動態新增子元素,新加入的子元素也能使用容器元素上註冊的事件,而不用再單獨繫結一次事件處理程式。

13)SSR

  伺服器端渲染(SSR)是指將單頁應用(SPA)在伺服器端渲染成HTML片段,傳送到瀏覽器,然後交由瀏覽器為其繫結狀態與事件,成為完全可互動頁面的過程。

  其優點是:

  1. 更快的首屏載入速度,無需等待JavaScript完成下載且執行之後才顯示內容。

  2. 更友好的SEO,爬蟲可以直接抓取渲染之後的頁面。

14)MVVM

  MVVM模式是指檢視和資料之間的雙向互通,檢視的修改會反映給資料,反之亦然。

  目前市面上許多庫和框架都會採用MVVM模式的思想,其提升的並不在於效能,而是開發效率,鼓勵開發者操作資料更新檢視,由庫或框架最低限度的操作DOM,減少迴流。

15)虛擬DOM

  虛擬DOM(Virtual DOM)是構建在真實DOM之上的一層抽象,它將DOM元素對映成記憶體中的JavaScript物件(即通過React.createElement()得到的React元素),形成一棵JavaScript物件樹。

  虛擬DOM與模板引擎有些相似,將多次的DOM操作先在對映的JavaScript物件中處理,再將該物件一次性掛載到真實的DOM樹上,避免因瀏覽器重排導致的大量無用計算。

  同構應用也是基於虛擬DOM實現的,虛擬DOM的思想還可應用於其它方面,例如JavaScript錄影回放。

三、HTML5

1)history

  瀏覽器中的歷史瀏覽記錄就像一堆層疊的卡片,在HTML4中,可以使用window.history物件來控制歷史記錄的跳轉。

  HTML5引進了history.pushState()方法和history.replaceState()方法,允許逐條地新增和修改歷史記錄條目。這些方法可以協同window.onpopstate事件一起工作。

  利用全新的history物件,就能讓Ajax就像重定向到新頁面一樣,擁有能夠返回上一頁或進入下一頁的功能。

2)Web Worker

  Web Worker可以在主執行緒(通常是UI執行緒)之外執行程式碼,當在獨立執行緒中執行費時的任務時,就能避免主執行緒被阻塞。

  注意,由於Web Worker沒有繫結UI執行緒,因此它們不能訪問瀏覽器的許多資源,例如從外部執行緒修改DOM會導致介面出現錯誤。

  由於Web Worker有著不同的全域性執行環境,因此需要建立一個完全獨立的JavaScript檔案,其中包含了需要在Worker中執行的程式碼。

  例如下面的code.js,其中message事件用於接收資訊,postMessage()方法用於傳送資訊。

var worker = new Worker("code.js");
worker.onmessage = function (event) {
  console.log(event.data);        //"hello strick"
};
worker.postMessage("strick");

// code.js的內部程式碼
self.onmessage = function (event) {
  var text = `hello ${event.data}`;
  self.postMessage(text);
};

  Worker通過importScripts()方法載入外部JavaScript檔案,它的呼叫過程是阻塞式的,直到所有檔案載入並執行完成之後,指令碼才會繼續執行。注意,不會影響UI響應。

importScripts("foo1.js", foo2.js);

  Web Worker的實際應用包括解析大JSON字串,計算複雜數學運算(例如影象或視訊處理),大陣列排序,任何超過100ms的處理過程,都應該考慮Worker方案。

2)Service Worker

  Service Worker是谷歌發起的實現PWA(Progressive Web App,漸進式Web應用)的一個關鍵角色,它相當於Web應用與瀏覽器之間的一臺代理伺服器。

  Service Worker會在後臺啟動一條Worker執行緒(不能訪問DOM),其工作是把一些資源快取起來(跨域資源無法快取),然後攔截頁面的HTTPS請求,如果快取中有,就從快取裡取,響應200,沒有就走正常的請求流程。

  Service Worker結合Web App Manifest能完成離線使用、斷網時返回200、將一個圖示新增到桌面上等。

3)WebAssembly

  將繁重的計算(如Web遊戲)任務抽離到WebAssembly(WASM)中,它是一種二進位制指令格式,被設計為一種用高階語言(如C/C++/Rust)編譯的可移植物件。

  WebAssembly的目的並不是替代JavaScript,而是與JavaScript共存,允許兩者一起工作。

  通過使用WebAssembly的JavaScript介面,你可以把WebAssembly模組載入到一個JavaScript應用中,這樣在同一個應用中就能同時享用WebAssembly的效能和JavaScript的靈活。

  下載一個simple.wasm示例,其內容如下所示。

(module
  (func $i (import "imports" "imported_func") (param i32))
  (func (export "exported_func")
    i32.const 42
    call $i
  )
)

  由於內部函式$i是從imports.imported_func匯入的,因此需要建立一個物件來反映simple.wasm中的兩級名稱空間。

let importObject = {
  imports: {
    imported_func: (arg) => console.log(arg)
  }
};

  在載入wasm檔案後,使其在Array Buffer中可用,然後就可以使用匯出函數了。

fetch("simple.wasm")
  .then((res) => res.arrayBuffer())
  .then((bytes) => WebAssembly.instantiate(bytes, importObject))
  .then((results) => {
    results.instance.exports.exported_func();
  });

&n