團隊中作code review有一段時間了,最近一直在思考一個問題,拋開業務邏輯,單純從代碼層面如何評價一段代碼的好壞?javascript
好和壞都是相對的,一段不那麼好的代碼通過優化以後,如何標準化的給出重構先後的差別呢?html
咱們全部的代碼都跑在計算機上,計算機的核心是CPU和內存。從這個角度來看,效率高的代碼應當佔用更少的CPU時間,更少的內存空間。java
所以,問題就演變爲優化一段代碼,到底優化了多少CPU的使用以及內存空間的使用?算法
在數據結構與算法中,經常使用大O來表示算法的時間複雜度,常見的時間複雜度以下所示:(來源《算法》第四版) json
時間複雜度這個東西,是描述一個算法在問題規模不斷增大時對應的時間增加曲線。因此,這些增加數量級並非一個準確的性能評價,能夠理解爲一個近似值,時間的增加近似於logN、NlogN的曲線。以下圖所示:數組
上面是關於時間複雜度的解釋,下面經過具體樣例來看看代碼的時間複雜度瀏覽器
代碼一:緩存
(function count(arr=[1,2,3,4,5,6,7,8,9,10]){
let num = 0
for(let i=0;i<arr.length;i++){
let item = arr[i]
num = num + item
}
return num
})()
複製代碼
這是一段求數組中數字總和的代碼,咱們粗略估計上述代碼在CPU中表達式運算的時間都是同樣的,計爲avg_time,那麼咱們來算一下上面的代碼須要多少個avg_time.bash
首先從第二行開始,表達式賦值計爲1個avg_time;代碼的三、四、5行分別要運行10次,其中第三行比較特殊,每次運行須要計算arr.length以及i++,因此這裏須要(2+1+1)*10
個avg_time;總共就是(2+1+1)*10+1=41
個avg_timesession
接着,咱們來對上面的代碼優化一番,以下所示: 代碼二
(function count(arr=[1,2,3,4,5,6,7,8,9,10]){
let num = 0
let len = arr.length
while(len--){
num = num + arr[len]
}
return num
})()
複製代碼
不難算出,優化後的代碼只耗費了1+1+(1+1)*10=22
個avg_time,代碼二相對於代碼一,節約了41-22=19
個avg_time,代碼性能提高19/41=46.3%
!
1.靈活使用break、continue、return
這三個關鍵字通常用在減小循環次數,達到目的,當即退出。以下所示:
(function check(arr=[1,2,3],target=2){
let len = arr.length
while(len--){
if(arr[len]===target){
// 再也不繼續後續循環
return len
}
}
return -1
})()
複製代碼
2.空間換時間
常見的作法是利用緩存,把上次的計算結果存起來,避免重複計算。
3.更優的數據結構與算法
根據不一樣的狀況選擇合適的數據結構與算法,例如,若是須要頻繁的從一組數據中經過關鍵key查詢出數據,若是要從json對象和數組中選擇,那麼能夠優先考慮使用json對象來避免數組的遍歷查詢。
評價一段代碼,除了看它執行須要多少時間,還須要看看須要多少空間,談到代碼的空間佔用,必須就得知道JS的內存管理
JS的內存管理分爲三部分:
內存分配。
這裏包含包含代碼自己以及靜態數據與動態數據所須要的內存,其中代碼自己與靜態數據會分配在stack上,可變的動態數據會分配在heap上
使用分配的內存。
內存回收。
這裏,放一張JS Runtime的圖
是指stack中內存的分配,基礎數據類型的數據就放在stack中。另外,stack是有固定大小的,超過stack的長度,就會報錯,因此必須得節約着用。
// 故意來一次爆棧體驗
function foo(){
foo()
}
foo()
// 結果
VM201:1 Uncaught RangeError: Maximum call stack size exceeded
at foo (<anonymous>:1:13)
at foo (<anonymous>:2:3)
at foo (<anonymous>:2:3)
at foo (<anonymous>:2:3)
at foo (<anonymous>:2:3)
at foo (<anonymous>:2:3)
at foo (<anonymous>:2:3)
at foo (<anonymous>:2:3)
at foo (<anonymous>:2:3)
at foo (<anonymous>:2:3)
複製代碼
咱們是怎麼達到爆棧目的的呢?由於全部的函數調用,在內存中都存在一個函數調用棧,咱們不斷無結束條件的遞歸調用,最終撐破了stack。
如圖所示:
可能你會問怎麼證實函數調用棧的存在呢?請看以下代碼:
function second() {
throw new Error('function call stack');
}
function first() {
second();
}
function start() {
first();
}
start();
// 結果以下
VM266:2 Uncaught Error: function call stack at second (<anonymous>:2:11) at first (<anonymous>:5:5) at start (<anonymous>:8:5) at <anonymous>:10:1 複製代碼
從上面的運行結果能夠看出函數調用棧的順序,start先入棧,接着first,最後second;打印順序爲首選打印second,最後打印start;知足棧的先進後出的數據結構特性。
瞭解上面知識點的核心目的仍是在於指導咱們寫出更優的代碼,咱們知道基本數據類型都放在棧中,對象都放在堆中。另外,經過《JavaScript權威指南》第六版第三章能夠知道,js中的數字都是雙精度類型,佔64位8個字節的空間,字符佔16位2個字節的空間。
有了這個知識,咱們就能夠估算出咱們的代碼大體佔用了多少內存空間。
這些畢竟都是理論知識,不由要懷疑一下,的確是這樣的嗎?下面咱們利用爆棧的原理,經過代碼實際瞧瞧
let count = 0
try{
function foo() {
count++
foo()
}
foo()
}finally{
console.log(count)
}
// 最終的打印結果爲:15662
複製代碼
咱們知道一個數字佔8個字節,棧的大小固定;稍微變動一下代碼
let count = 0
try{
function foo() {
let local = 58 //數字,佔8個字節
count++
foo()
}
foo()
}finally{
console.log(count)
}
// 最終的打印結果爲:13922
複製代碼
那麼咱們能夠利用以下方法算一下棧的總大小
N = 棧中單個元素的大小
15662 * N = 13922 * (N + 8) // 兩次函數調用,棧的總大小相等
(15662 - 13922) * N = 13922 * 8
1740 * N = 111376
N = 111376 / 1740 = 64 bytes
Total stack size = 15662 * 64 = 1002368 = 0.956 MB
複製代碼
注:不通環境可能結果不太同樣
接下來,咱們來肯定一下數字類型是否佔8個字節空間
let count = 0
try{
function foo() {
//數字,佔8個字節,這裏就佔16個字節
let local = 58
let local2 = 85
count++
foo()
}
foo()
}finally{
console.log(count)
}
// 最終的打印結果爲:12530
複製代碼
計算一下Number的內存佔用大小
// 總的棧內存空間/棧中元素數量 = 單個棧元素大小
1002368/12530 = 80
// 對比不帶任何額外變量的代碼,單個棧元素大小是64,這裏新增兩個16,加起來正好爲80
80 = 64+8+8
複製代碼
經實際驗證,在Chrome、Safari、Node環境下,不論變量的值是什麼類型,在stack中都佔8個字節。對於字符串貌似跟預期不太同樣,不論多長的字符串實踐代表在stack中都佔8個字節,懷疑瀏覽器默認把字符串轉換爲了對象,最終佔用heap空間
是指heap中內存的分配,全部對象都放在heap中,stack中只放對象的引用。
這裏有一篇數組佔用多少內存空間的文章:How much memory do JavaScript arrays take up in Chrome?
低內存佔用,從靜態內存分配方面能夠考慮,儘可能少的使用基礎類型變量;從動態內存分配的角度,讓代碼更簡潔、不要毫無節制的new一個對象
、少在對象放東西;
下面是一些小技巧:
1.三目運算符
// 條件賦值
if(a===1){
b = 'aa'
}else{
b = 'bb'
}
// 可簡化爲
b = a===1 ? 'aa' : 'bb'
複製代碼
2.直接返回結果
if(a===1){
return true
}else{
return false
}
// 可簡化爲
return a===1
複製代碼
一時半會兒想不到好的樣例,上面的樣例至少節約了代碼的空間佔用!......歡迎評論補充......
個人理解是,當函數調用棧爲空時,佔用的佔內存隨之清空;只有堆內存中的數據才須要經過垃圾回收機制來回收。
常見的垃圾回收算法以下:
引用計數
對沒有對象的引用計數,若是沒有任何外部引用時,則清除該對象;引用計數算法有一個弊端就是沒法清除循壞依賴的對象。
標記清除:
每次回收,從根對象開始遍歷,能遍歷到的對象則記爲可用,不能遍歷到的對象則爲須要垃圾回收的對象。此種算法可以解決對象循環依賴的問題。
綜合算法:
實際上垃圾回收是一個很複雜的過程,垃圾回收器會根據內存的不通狀況採起不一樣的垃圾回收算法,來實現效率的最大化。
這裏有一篇垃圾回收的文章:A tour of V8: Garbage Collection 已經被翻譯爲了中文,點進去就知道了。
從上面的垃圾回收機制不難看出,當某些狀況內存沒法被回收且不斷增長時,內存溢出就會產生。下面是幾種常見的會有內存溢出風險的代碼。
1.控制全局變量
從垃圾回收的原理咱們能夠知道,全局變量確定是不會被回收的。因此咱們應當儘可能把數據綁定到全局變量上,更應該避免經過用戶操做持續的增長全局變量數據的大小。
另外還須要特別注意意外的全局變量產生,例如:
function foo(arg) {
a = "some text";
this.b = "some text";
}
// 會在window對象上新增a,b屬性
foo()
複製代碼
2.setInterval注意內存佔用
因爲setInterval一直處於活動狀態,形成它所依賴的數據一直沒法回收。特別容易出現數據越積越多狀況
3.注意閉包
閉包裏依賴了主函數的數據,爲了讓閉包續繼訪問到數據,必須避免當主函數退出時,回收閉包依賴主函數的變量所對應的數據,從而帶來內存溢出風險。
資料: