JavaScript 新手的踩坑日記

一縷殤流化隱半邊冰霜發表於2017-05-19

引語

在1995年5月,Eich 大神在10天內就寫出了第一個指令碼語言的版本,JavaScript 的第一個代號是 Mocha,Marc Andreesen 起的這個名字。由於商標問題以及很多產品已經使用了 Live 的字首,網景市場部將它改名為 LiveScript。在1995年11月底,Navigator 2.0B3 發行,其中包含了該語言的原型,這個版本相比之前沒有什麼大的變化。在1995年12月初,Java 語言發展壯大,Sun 把 Java 的商標授權給了網景。這個語言被再次改名,變成了最終的名字——JavaScript。在之後的1997年1月,標準化以後,就成為現在的 ECMAScript。

近一兩年在客戶端上用到 JS 的地方也越來越多了,筆者最近接觸了一下 JS ,作為前端小白,記錄一下近期自己“踩坑”的成長經歷。

一. 原始值和物件

在 JavaScript 中,對值的區分就兩種:

1.原始值:BOOL,Number,String,null,undefined。
2.物件:每個物件都有唯一的標識且只嚴格的等於(===)自己。

null,undefined沒有屬性,連toString( )方法也沒有。

false,0,NaN,undefined,null,' ' ,都是false。

typeof 運算子能區分原始值和物件,並檢測出原始值的型別。
instanceof 運算子可以檢測出一個物件是否是特定建構函式的一個例項或者是否為它的一個子類。

運算元 typeof
undefined 'undefined'
null object
布林值 boolean
數字 number
字串 string
函式 function
其他的常規值 object
引擎建立的值 可能返回任意的字串

null 返回的是一個 object,這個是一個不可修復的 bug,如果修改這個 bug,就會破壞現有程式碼體系。但是這不能表示 null 是一個物件。

因為第一代 JavaScript 引擎中的 JavaScript 值表示為32位的字元。最低3位作為一種標識,表示值是物件,整數,浮點數或者布林值。物件的標識是000,而為了表現 null ,引擎使用了機器語言 NULL 的指標,該字元的所有位都是0。而 typeof 就是檢測值的標誌位,這就是為什麼它會認為 null 是一個物件了。

所以判斷 一個 value 是不是一個物件應該按照如下條件判斷:


function isObject (value) {
  return ( value !== null 
    && (typeof value === 'object' 
    || typeof value === 'function'));
}複製程式碼

null 是原型鏈最頂端的元素


Object.getPrototypeOf(Object.prototype)

< null複製程式碼

判斷 undefined 和 null 可以用嚴格相等判斷:


if(x === null) {
  // 判斷是否為 null
}

if (x === undefined) {
  // 判斷是否為 undefined
}

if (x === void 0 ) {
  // 判斷是否為 undefined,void 0 === undefined
}

if (x != null ) {
 // 判斷x既不是undefined,也不是null
 // 這種寫法等價於 if (x !== undefined && x !== null )
}複製程式碼

在原始值裡面有一個特例,NaN 雖然是原始值,但是它和它本身是不相等的。


NaN === NaN
<false複製程式碼

原始值的建構函式 Boolean,Number,String 可以把原始值轉換成物件,也可以把物件轉換成原始值。


// 原始值轉換成物件
var object = new String('abc')


// 物件轉換成原始值
String(123)
<'123'複製程式碼

但是在物件轉換成原始值的時候,需要注意一點:如果用 valueOf() 函式進行轉換的時候,轉換一切正確。


new Boolean(true).valueOf()
<true複製程式碼

但是使用建構函式將包裝物件轉換成原始值的時候,BOOL值是不能正確被轉換的。


Boolean(new Boolean(false))
<true複製程式碼

建構函式只能正確的提取出包裝物件中的數字和字串。

二. 寬鬆相等帶來的bug

在 JavaScript 中有兩種方式來判斷兩個值是否相等。

  1. 嚴格相等 ( === ) 和嚴格不等 ( !== ) 要求比較的值必須是相同的型別。
  2. 寬鬆相等 ( == ) 和寬鬆不等 ( != ) 會先嚐試將兩個不同型別的值進行轉換,然後再使用嚴格等進行比較。

寬鬆相等就會遇到一些bug:


undefined == null // undefined 和 null 是寬鬆相等的
<true

2 == true  // 不要誤認為這裡是true
<false

1 == true 
<true

0 == false
<true 

' ' == false // 空字串等於false,但是不是所有的非空字串都等於true
<true

'1' == true
<true

'2' == true
<false

'abc' == true // NaN === 1
<false複製程式碼

關於嚴格相等( Strict equality ) 和 寬鬆相等( Loose equality ),GitHub上有一個人總結了一張圖,挺好的,貼出來分享一下,Github地址在這裡

JavaScript 新手的踩坑日記

但是如果用 Boolean( ) 進行轉換的時候情況又有不同:

轉換成BOOL值
undefined false
null false
BOOL 與輸入值相同
數字 0,NaN 轉換成false,其他的都為 true
字串 ' '轉換成false,其他字串都轉換成true
物件 全為true

這裡為何物件總是為true ?
在 ECMAScript 1中,曾經規定不支援通過物件配置來轉換(比如 toBoolean() 方法)。原理是布林運算子 || 和 && 會保持運算數的值。因此,如果鏈式使用這些運算子,會多次確認相同值的真假。這樣的檢查對於原始值型別成本不大,但是對於物件,如果能通過配置來轉換布林值,成本很大。所以從 ECMAScript 1 開始,物件總是為 true 來避免了這些成本轉換。

三. Number

JavaScript 中所有的數字都只有一種型別,都被當做浮點數,JavaScript 內部會做優化,來區分浮點陣列和整數。JavaScript 的數字是雙精度的(64位),基於 IEEE 754 標準。

由於所有數字都是浮點數,所以這裡就會有精度的問題。還記得前段時間網上流傳的機器人的漫畫麼?

JavaScript 新手的踩坑日記

精度的問題就會引發一些奇妙的事情


0.1 + 0.2 ;  // 0.300000000000004

( 0.1 + 0.2 ) + 0.3;    // 0.6000000000001
0.1 + ( 0.2 + 0.3 );    // 0.6

(0.8+0.7+0.6+0.5) / 4   // 0.65
(0.6+0.7+0.8+0.5) / 4   // 0.6499999999999999複製程式碼

變換一個位置,加一個括號,都會影響精度。為了避免這個問題,建議還是轉換成整數。


( 8 + 7 + 6 + 5) / 4 / 10 ;  // 0.65
( 6 + 8 + 5 + 7) / 4 / 10 ;  // 0.65複製程式碼
轉換成Number值
undefined NaN
null 0
BOOL false = 0,true = 1
數字 與原值相同
字串 解析字串中的數字(忽略開頭和結尾的空格);空字元轉換成0。
物件 呼叫 ToPrimitive( value,number) 並轉換成原始型別

在數字裡面有4個特殊的數值:

  1. 2個錯誤值:NaN 和 Infinity
  2. 2個0,一個+0,一個-0。0是會帶正號和負號。因為正負號和數值是分開儲存的。

typeof NaN
<"number"複製程式碼

(吐槽:NaN 是 “ not a number ”的縮寫,但是它卻是一個數字)

NaN 是 JS 中唯一一個不能自身嚴格相等的值:


NaN === NaN
<false複製程式碼

所以不能通過 Array.prototype.indexOf 方法去查詢 NaN (因為陣列的 indexOf 方法會進行嚴格等的判斷)。


[ NaN ].indexOf( NaN )
<-1複製程式碼

正確的姿勢有兩種:

第一種:


function realIsNaN( value ){
  return typeof value === 'number' && isNaN(value);
}複製程式碼

上面這種之所以需要判斷型別,是因為字串轉換會先轉換成數字,轉換失敗為 NaN。所以和 NaN 相等。


isNaN( 'halfrost' )
<true複製程式碼

第二種方法是利用 IEEE 754 標準裡面的定義,NaN 和任意值比較,包括和自身進行比較,都是無序的


function realIsNaN( value ){
  return value !== value ;
}複製程式碼

另外一個錯誤值 Infinity 是由表示無窮大,或者除以0導致的。

判斷它直接用 寬鬆相等 == ,或者嚴格相等 === 判斷即可。

但是 isFinite() 函式不是專門用來判斷Infinity的,是用來判斷一個值是否是錯誤值(這裡表示既不是 NaN,又不是 Infinity,排除掉這兩個錯誤值)。

在 ES6 中 引入了兩個函式專門判斷 Infinity 和 NaN的,Number.isFinite() 和 Number.isNaN() 以後都建議用這兩個函式進行判斷。

JS 中整型是有一個安全區間,在( -2^53 , 2^53)之間。所以如果數字超過了64位無符號的整型數字,就只能用字串進行儲存了。

利用 parseInt() 進行轉換成數字的時候,會有出錯的時候,結果不可信:


parseInt(1000000000000000000000000000.99999999999999999,10)
<1複製程式碼

parseInt( str , redix? ) 會先把第一個引數轉換成字串:


String(1000000000000000000000000000.99999999999999999)
<"1e+27"複製程式碼

parseInt 不認為 e 是整數,所以在 e 之後的就停止解析了,所以最終輸出1。

JS 中的 % 求餘操作符並不是我們平時認為的取模。


-9%7
<-2複製程式碼

求餘操作符會返回一個和第一個運算元相同符號的結果。取模運算是和第二個運算元符號相同。

所以比較坑的就是我們平時判斷一個數是否是奇偶數的問題就會出現錯誤:


function isOdd( value ){
  return value % 2 === 1;
}

console.log(-3);  // false
console.log(-2);  // false複製程式碼

正確姿勢是:


function isOdd( value ){
  return Math.abs( value % 2 ) === 1;
}

console.log(-3);  // true
console.log(-2);  // false複製程式碼

四. String

字串比較符,是無法比較變音符和重音符的。


'ä' < 'b'
<false

'á' < 'b'
<false複製程式碼

五. Array

建立陣列的時候不能用單個數字建立陣列。


new Array(2)  // 這裡的一個數字代表的是陣列的長度
<[ , , ]

new Array(2,3,4)
<[2,3,4]複製程式碼

刪除元素會刪出空格,但是不會改變陣列的長度。


var array = [1,2,3,4]
array.length
<4
delete array[1]

array
<[1, ,3,4]
array.length
<4複製程式碼

所以這裡的刪除不是很符合我們之前的刪除,正確姿勢是用splice


var array = [1,2,3,4,56,7,8,9]
array.splice(1,3)
array
<[1, 56, 7, 8, 9]
array.length
<5複製程式碼

針對陣列裡面的空缺,不同的遍歷方法行為不同

在 ES5 中:

方法 針對空缺
forEach() 遍歷時跳過空缺
every() 遍歷時跳過空缺
some() 遍歷時跳過空缺
map() 遍歷時跳過空缺,但是最終結果會保留空缺
filter() 去除空缺
join() 把空缺,undefined,null轉化為空字串
toString() 把空缺,undefined,null轉化為空字串
sort() 排序時保留空缺
apply() 把每個空缺轉化為undefined

在 ES6 中:規定,遍歷時不跳過空缺,空缺都轉化為undefined

方法 針對空缺
Array.from() 空缺都轉化為undefined
...(擴充套件運算子有) 空缺都轉化為undefined
copyWithin() 連空缺一起復制
fill() 遍歷時不跳過空缺,視空缺為正常的元素
for...of 遍歷時不跳過空缺
entries() 空缺都轉化為undefined
keys() 空缺都轉化為undefined
values() 空缺都轉化為undefined
find() 空缺都轉化為undefined
findIndex() 空缺都轉化為undefined p0p0

六. Set 、Map、WeakSet、WeakMap

資料結構 特點
Set 類似於陣列,但是成員值唯一,注意(這裡是一個例外),這裡 NaN 等於自身
WeakSet 成員只能是物件,而不能是其他型別的值。物件的引用都是弱引用,所以不能引用 WeakSet 的成員,不可遍歷它(因為遍歷的過程中隨時都可以消失)
Map 類似於物件,鍵值對的集合,鍵的範圍不限於字串,各種型別都可以,是“值—值”的對映,這一點區別於物件的“字串—值”的對映
WeakMap 於 Map 類似,區別在於它只接受物件作為鍵名( null 除外),鍵名指向的物件也不計入垃圾回收機制中,它也無法遍歷,也無法清空clear

七. 迴圈

先說一個 for-in 的坑:


var scores = [ 11,22,33,44,55,66,77 ];
var total = 0;
for (var score in scores) {
  total += score;
}

var mean = total / scores.length;

mean;複製程式碼

一般人看到這道題肯定就開始算了,累加,然後除以7 。那麼這題就錯了,如果把陣列裡面的元素變的更加複雜:


var scores = [ 1242351,252352,32143,452354,51455,66125,74217 ];複製程式碼

其實這裡答案和陣列裡面元素是多少無關。只要陣列元素個數是7,最終答案都是17636.571428571428。

原因是 for-in 迴圈的是陣列下標,所以 total = ‘00123456’ ,然後這個字串再除以7。

迴圈方式 遍歷物件 副作用
for 寫法比較麻煩
for-in 索引值(鍵名),而非陣列元素 遍歷所有(非索引)屬性,以及繼承過來的屬性(可以用hasOwnProperty()方法排除繼承屬性),主要是為遍歷物件而設計的,不適用於遍歷陣列
forEach 不方便break,continue,return
for...of 內部通過呼叫 Symbol.iterator 方法,實現遍歷獲得鍵值 不可遍歷普通的物件,因為沒有 Iterator 介面

遍歷物件的屬性,ES6 中有6種方法:

迴圈方式 遍歷物件
for...in 迴圈遍歷物件自身的和繼承的可列舉屬性(不包含Symbol屬性))
Object.key(obj) 返回一個陣列,包括物件自身的(不含繼承的)所有可列舉屬性(不含Symbol屬性)
Object.getOwnPropertyNames(obj) 返回一個陣列,包含物件自身的所有屬性(不含 Symbol 屬性,但是包含不可列舉的屬性)
Object.getOwnPropertySymbols(obj) 返回一個陣列,包含物件自身的所有 Symbol 屬性
Reflect.ownKeys(obj) 返回一個陣列,包含物件自身的所有屬性,不管屬性名是 Symbol 或者字串或者是否可列舉
Reflect.enumerate(obj) 返回一個 Iterator物件,遍歷物件自身的和繼承的所有可列舉屬性(不包含 Symbol 屬性),與 for...in迴圈相同

八. 隱式轉換 / 強制轉換 帶來的bug


var formData = { width : '100'};

var w = formData.width;
var outer = w + 20;

console.log( outer === 120 ); // false;
console.log( outer === '10020'); // true複製程式碼

九. 運算子過載

在 JavaScript 無法過載或者自定義運算子,包括等號。

十. 函式宣告和變數宣告的提升

先舉一個函式提升的例子。


function foo() {
  bar();
  function bar() {
    ……
  }
}複製程式碼

var 變數也具有提升的特性。但是把函式賦值給變數以後,提升的效果就會消失。


function foo() {
  bar(); // error!
  var bar = function () {
    ……
  }
}複製程式碼

上述函式就沒有提升效果了。

函式宣告是做了完全提升,變數宣告只是做了部分提升。變數的宣告才有提升的作用,賦值的過程並不會提升。

JavaScript 支援詞法作用域( lexical scoping ),即除了極少的例外,對變數 foo 的引用會被繫結到宣告 foo 變數最近的作用域中。ES5中 不支援塊級作用域,即變數定義的作用域並不是離其最近的封閉語句或程式碼塊,而包含它們的函式。所有的變數宣告都會被提升,宣告會被移動到函式的開始處,而賦值則仍然會在原來的位置進行。


function foo() {
  var x = -10;
  if ( x < 0) {
    var tmp = -x;
    ……
 }
 console.log(tmp);  // 10
}複製程式碼

這裡 tmp 就有變數提升的效果。

再舉個例子:


foo = 2;
var foo; 
console.log( foo );複製程式碼

上面這個例子還是輸出2,不是輸出undefined。

這個經過編譯器編譯以後,其實會變成下面這個樣子:


var foo; 
foo = 2;
console.log( foo );複製程式碼

變數宣告被提前了,賦值還在原地。 為了加深一下這句話的理解,再舉一個例子:


console.log( a ); 
var a = 2;複製程式碼

上述程式碼會被編譯成下面的樣子:


var foo;
console.log( foo ); 
foo = 2;複製程式碼

所以輸出的是undefined。

如果變數和函式都存在提升的情況,那麼函式提升優先順序更高


foo(); // 1
var foo;
function foo() { 
    console.log( 1 );
}
foo = function() { 
    console.log( 2 );
};複製程式碼

上面經過編譯過會變成下面這樣子:


function foo() { 
   console.log( 1 );
}
foo(); // 1
foo = function() { 
   console.log( 2 );
};複製程式碼

最終結果輸出是1,不是2 。這就說明了函式提升是優先於變數提升的。

為了避免變數提升,ES6中引入了 let 和 const 關鍵字,使用這兩個關鍵字就不會有變數提升了。原理是,在程式碼塊內,使用 let 命令宣告變數之前,該變數都是不可用的,這塊區域叫“暫時性死區”(temporal dead zone,TDZ)。TDZ 的做法是,只要一進入到這一區域,所要使用的變數就已經存在了,變數還是“提升”了,但是不能獲取,只有等到宣告變數的那一行出現,才可以獲取和使用該變數。

ES6 的這種做法也給 JS 帶來了塊級作用域,(在 ES5 中只有全域性作用於和函式作用域),於是立即執行匿名函式(IIFE)就不在必要了。

十一. arguments 不是陣列

arguments 不是陣列,它只是類似於陣列。它有length屬性,可以通過方括號去訪問它的元素。不能移除它的元素,也不能對它呼叫陣列的方法。

不要在函式體內使用 arguments 變數,使用 rest 運算子( ... )代替。因為 rest 運算子顯式表明了你想要獲取的引數,而且 arguments 僅僅只是一個類似的陣列,而 rest 運算子提供的是一個真正的陣列。

下面有一個把 arguments 當陣列用的例子:


function callMethod(obj,method) {
  var shift = [].shift;
  shift.call(arguments);
  shift.call(arguments);
  return obj[method].apply(obj,arguments);
}

var obj = {
  add:function(x,y) { return x + y ;}
};

callMethod(obj,"add",18,38);複製程式碼

上述程式碼直接報錯:



Uncaught TypeError: Cannot read property 'apply' of undefined
    at callMethod (<anonymous>:5:21)
    at <anonymous>:12:1複製程式碼

出錯的原因就在於 arguments 並不是函式引數的副本,所有命名引數都是 arguments 物件中對應索引的別名。因此通過 shift 方法移除 arguments 物件中的元素之後,obj 仍然是 arguments[0] 的別名,method 仍然是 arguments[1] 的別名。看上去是在呼叫 obj[add],實際上是在呼叫17[25]。

還有一個問題,使用 arguments 引用的時候。


function values() {
  var i = 0 , n = arguments.length;
  return {
      hasNext: function() {
        return i < n;
      },
      next: function() {
        if (i >= n) {
            throw new Error("end of iteration");
        }
        return arguments[i++];
      }
  }
}

var it = values(1,24,53,253,26,326,);
it.next();   // undefined
it.next();   // undefined
it.next();   // undefined複製程式碼

上述程式碼是想構造一個迭代器來遍歷 arguments 物件的元素。這裡之所以會輸出 undefined,是因為有一個新的 arguments 變數被隱式的繫結到了每個函式體內,每個迭代器 next 方法含有自己的 arguments 變數,所以執行 it.next 的引數時,已經不是 values 函式中的引數了。

更改方式也簡單,只要宣告一個區域性變數,next 的時候能引用到這個變數即可。


function values() {
  var i = 0 , n = arguments.length,a = arguments;
  return {
      hasNext: function() {
        return i < n;
      },
      next: function() {
        if (i >= n) {
            throw new Error("end of iteration");
        }
        return a[i++];
      }
  }
}

var it = values(1,24,53,253,26,326,);
it.next();   // 1
it.next();   // 24
it.next();   // 53複製程式碼

十二. IIFE 引入新的作用域

在 ES5 中 IIFE 是為了解決 JS 缺少塊級作用域,但是到了 ES6 中,這個就可以不需要了。

十三. 函式中 this 的問題

在巢狀函式中不能訪問方法中的 this 變數。


var halfrost = {
    name:'halfrost',
    friends: [ 'haha' , 'hehe' ],
    sayHiToFriends: function() {
      'use strict';
      this.friends.forEach(function (friend) {
          // 'this' is undefined here
          console.log(this.name + 'say hi to' + friend);
      });
    }
}

halfrost.sayHiToFriends()複製程式碼

這時就會出現一個TypeError: Cannot read property 'name' of undefined。

解決這個問題有兩種方法:

第一種:將 this 儲存在變數中。


sayHiToFriends: function() {
  'use strict';
  var that = this;
  this.friends.forEach(function (friend) {
      console.log(that.name + 'say hi to' + friend);
  });
}複製程式碼

第二種:利用bind()函式

使用bind()給回撥函式的this繫結固定值,即函式的this


sayHiToFriends: function() {
  'use strict';
  this.friends.forEach(function (friend) {
      console.log(this.name + 'say hi to' + friend);
  }.bind(this));
}複製程式碼

第三種:利用 forEach 的第二個引數,把 this 指定一個值。


sayHiToFriends: function() {
  'use strict';
  this.friends.forEach(function (friend) {
      console.log(this.name + 'say hi to' + friend);
  }, this);
}複製程式碼

到了 ES6 裡面,建議能用箭頭函式的地方用箭頭函式。

簡單的,單行的,不會複用的函式,都建議用箭頭函式,如果函式體很複雜,行數很多,還應該用傳統寫法。

箭頭函式裡面的 this 物件就是定義時候的物件,而不是使用時候的物件,這裡存在“繫結關係”。

這裡的“繫結”機制並不是箭頭函式帶來的,而是因為箭頭函式根本就沒有自己的 this,導致內部的 this 就是外層程式碼塊的 this,正因為這個特性,也導致了以下的情況都不能使用箭頭函式:

  1. 不能當做建構函式,不能使用 new 命令,因為沒有 this,否則會丟擲一個錯誤。
  2. 不可以使用 argument 物件,該物件在函式體內不存在,非要使用就只能用 rest 引數代替。也不能使用 super,new.target 。
  3. 不可以使用 yield 命令,不能作為 Generator 函式。
  4. 不可以使用call(),apply(),bind()這些方法改變 this 的指向。

十四. 非同步

非同步程式設計有以下幾種:

  1. 回撥函式callback
  2. 事件監聽
  3. 釋出 / 訂閱
  4. Promise物件
  5. Async / Await

(這個日記可能一直未完待續......)

相關文章