Travel in Time.

克服JS 的奇怪部分 #3:當 function 被呼叫時

Published on
understanding-the-weird-part

上一篇提到 function 是裝著程式碼的容器,有 code property 屬性可以呼叫,當它被呼叫之後、會建立新的執行環境,執行環境將決定裡面的程式碼如何執行。

創造階段,JS 幫你設定的東西:

  • Variable Environment:函式中變數所存放的地方,EC 建立時會順便建立自己的變數環境
  • this:依據函式被呼叫的方式來決定,如果是以 object 形式呼叫的話,JS 引擎會自動幫你指向包含它的物件
  • Outer Environment:函式的外部環境參考,取決於函式被寫在什麼地方(在自己的變數環境找不到變數時,會順著 Scope Chain 往外面找、直到全域)
  • arguments:包含了所有你傳入 function 的變數

this 變數

一般情況,在 function 裡面呼叫 this 是指向全域 Window

function a() {
  console.log(this)
  this.newvariable = 'hello' // 在全域物件 Window 新增屬性
}

var b = function () {
  console.log(this)
}

a() // Window
b() // Window
console.log(newvariable) // hello

當函數是連結到物件的方法時,事情就不一樣了!

this => 物件

var c = {
  name: 'The c object',
  log: function () {
    this.name = 'Update c object'
    console.log(this)
  },
}

c.log() // {name: "Update c object", log: ƒ}

這時,裡面新增一個 setName 函數呼叫時會改變 this.name 的值。

但實際上卻沒有改到 this.name 的值,反而在 Window 物件中多出來一個 name 屬性,代表剛剛 setName 函數裡面的 this 指向的是全域物件、而不是 c object!

var c = {
  name: 'The c object',
  log: function () {
    this.name = 'Update c object'
    console.log(this)

    var setName = function (newname) {
      this.name = newname
    }
    setName('Update again!') // 🔴 not work!
  },
}

c.log() // {name: "Update c object", log: ƒ}

這是因為,setName 執行時是作為一般函式,而不是作為某某物件的方法執行,因此它的 this 變數會是 JS 引擎預設的 this,指向全域 Window 物件。

如果要解決 this 的問題,通常會在物件方法裡面先寫一行 var self = this,因為物件是 by reference 來設定值,self 會指向跟目前 this 一樣的記憶體位置,這樣之後要用到同一變數,就不需要考慮 this 指向被 JS 改變的問題,仍然指向整個 c 物件。

var c = {
  name: 'The c object',
  log: function () {
    var self = this //  ✅ 最開始就指定,確保用到對的物件
    self.name = 'Update c object'
    console.log(self)
    var setName = function (newname) {
      self.name = newname // 順著 scope chain 尋找
    }
    setName('Update again!')
  },
}

c.log() // {name: "Update again!", log: ƒ}

arguments

過去的 arguments

一般以為 arguments 就是 parameter 的另一稱呼,但在 JS 裡面幫你設定了一個叫 arguments 關鍵字,包含了所有你傳入的值

  • 執行 function statement 時已經先預留這些值的記憶體空間(hoisting)
  • 可以省略傳入或部分
  • arguments 類似於陣列(array-like),有一些陣列的方法
greet('zoe')

function greet(first, second, third) {
  console.log(first)
  console.log(second)
  console.log(third)
  console.log(arguments) // ['zoe'] 因為只有傳入一個參數
}

ES6 之後,可以在參數設定給預設值

function greet(first, second, third = 'en') {
  // # 檢查是否有帶參數
  if (arguments.length === 0) {
    console.log('Missing parameters!')
    return // return statement 將離開函式
  }

  console.log(first)
  console.log(second)
  console.log(third)
  console.log(arguments)
}

greet() // Missing parameters!
greet('John') // John undefined en ['John']
greet('John', 'Doe', 'es') // John Doe ess ['John', 'Doe', 'es']

spread parameter

在 ES6 之後可以透過 ... 取得沒有直接寫出來的參數,會是一個陣列來存這些

function greet(first, second, third, ...others) {
  console.log(others) // ["hello", "zoe"]
}

greet('John', 'Doe', 'es', 'hello', 'zoe')

最後補充一個面試常考到的 IIFE

IIFE

立即呼叫的函式表示式(Immediately-invoked Function Expressions)

利用 function expression 創造函數物件之後,馬上呼叫

// function expression
var greet = (function (name) {
  return 'hello ' + name
})()

console.log(greet) // hello undefined

不像 function statement 沒有函式名稱時會報錯

JS 在解析下面這段程式碼時,預期它是 function statement,因為用了 function 作為開頭,當沒有給出名稱時就會拋出錯誤

function (name) { // 🔴 Unexpected token!
    return 'hello' + name
}

用括號包著,可以讓它變成 function expresstion

📔 JS 認為用 () 包著的東西是一個表示式,會以 expression 方式來讀取。而 IIFE 指的就是透過 function expression 來建立函式,立即執行它,在同一時間創造和執行

;(function (name) {
  return 'hello' + name
})(
  // IIFE
  function (name) {
    var greeting = 'Hello'
    return greeting + name
  }
)('John') // 用 () 呼叫

使用立即函式的好處,除了可以立即執行程式碼,省略多餘呼叫,還可以用來避免汙染全域執行環境的東西,減少開發時因相同命名相衝的 bug

以下圖為例,可以把 IIFE 的變數鎖定在 Execution Context

把東西包在 IIFE 裡面,就不會跟其他 Execution Context 衝突到