天天看點

JavaScript 是如何工作的:JavaScript 的共享傳遞和按值傳遞

摘要: 原始資料類型和引用資料類型的副本作為參數傳遞給函數。

Fundebug

經授權轉載,版權歸原作者所有。

這是專門探索 JavaScript 及其所建構的元件的系列文章的第 22 篇。

如果你錯過了前面的章節,可以在這裡找到它們:

  1. JavaScript 是如何工作的:引擎,運作時和調用堆棧的概述!
  2. JavaScript 是如何工作的:深入V8引擎&編寫優化代碼的5個技巧!
  3. JavaScript 是如何工作的:記憶體管理+如何處理4個常見的記憶體洩漏!
  4. JavaScript 是如何工作的:事件循環和異步程式設計的崛起+ 5種使用 async/await 更好地編碼方式!
  5. JavaScript 是如何工作的:深入探索 websocket 和HTTP/2與SSE +如何選擇正确的路徑!
  6. JavaScript 是如何工作的:與 WebAssembly比較 及其使用場景!
  7. JavaScript 是如何工作的:Web Workers的建構塊+ 5個使用他們的場景!
  8. JavaScript 是如何工作的:Service Worker 的生命周期及使用場景!
  9. JavaScript 是如何工作的:Web 推送通知的機制!
  10. JavaScript 是如何工作的:使用 MutationObserver 跟蹤 DOM 的變化!
  11. JavaScript 是如何工作的:渲染引擎和優化其性能的技巧!
  12. JavaScript 是如何工作的:深入網絡層 + 如何優化性能和安全!
  13. JavaScript 是如何工作的:CSS 和 JS 動畫底層原理及如何優化它們的性能!
  14. JavaScript 是如何工作的:解析、抽象文法樹(AST)+ 提升編譯速度5個技巧!
  15. JavaScript 是如何工作的:深入類和繼承内部原理+Babel和 TypeScript 之間轉換!
  16. JavaScript 是如何工作的:存儲引擎+如何選擇合适的存儲API!
  17. JavaScript 是如何工作的:Shadow DOM 的内部結構+如何編寫獨立的元件!
  18. JavaScript 是如何工作的:WebRTC 和對等網絡的機制!
  19. JavaScript 是如何工作的:編寫自己的 Web 開發架構 + React 及其虛拟 DOM 原理!
  20. JavaScript 是如何工作的:子產品的建構以及對應的打包工具
  21. JavaScript 是如何工作的:JavaScript 的記憶體模型

關于JavaScript如何将值傳遞給函數,在網際網路上有很多誤解和争論。大緻認為,參數為原始資料類時使用按值傳遞,參數為數組、對象和函數等資料類型使用引用傳遞。

按值傳遞 和 引用傳遞參數 主要差別簡單可以說:

  • 按值傳遞:在函數裡面改變傳遞的值不會影響到外面
  • 引用傳遞:在函數裡面改變傳遞的值會影響到外面

但答案是 JavaScript 對所有資料類型都使用按值傳遞。它對數組和對象使用按值傳遞,但這是在的共享傳參或拷貝的引用中使用的按值傳參。這些說有些抽象,先來幾個例子,接着,我們将研究JavaScript在 函數執行期間的記憶體模型,以了解實際發生了什麼。

按值傳參

在 JavaScript 中,原始類型的資料是按值傳參;對象類型是跟Java一樣,拷貝了原來對象的一份引用,對這個引用進行操作。但在 JS 中,string 就是一種原始類型資料而不是對象類。

let setNewInt = function (i) {
    i = i + 33;
};

let setNewString = function (str) {
    str += "cool!";
};

let setNewArray = function (arr1) {
    var b = [1, 2];
    arr1 = b;
};

let setNewArrayElement = function (arr2) {
    arr2[0] = 105;
};


let i = -33;
let str = "I am ";
let arr1 = [-4, -3];
let arr2 = [-19, 84];


console.log('i is: ' + i + ', str is: ' + str + ', arr1 is: ' + arr1 + ', arr2 is: ' + arr2);

setNewInt(i);
setNewString(str);
setNewArray(arr1);
setNewArrayElement(arr2);

console.log('現在, i is: ' + i + ', str is: ' + str + ', arr1 is: ' + arr1 + ', arr2 is: ' + arr2);           

運作結果

i is: -33, str is: I am , arr1 is: -4,-3, arr2 is: -19,84
現在, i is: -33, str is: I am , arr1 is: -4,-3, arr2 is: 105,84           

這邊需要注意的兩個地方:

1)第一個是通過

setNewString

方法把字元串

str

傳遞進去,如果學過面向對象的語言如C#,Java 等,會認為調用這個方法後

str

的值為改變,引用這在面向對象語言中是

string

類型的是個對象,按引用傳參,是以在這個方法裡面更改

str

外面也會跟着改變。

但是 JavaScript 中就像前面所說,在JS 中,string 就是一種原始類型資料而不是對象類,是以是按值傳遞,是以在

setNewString

中更改

str

的值不會影響到外面。

2)第二個是通過

setNewArray

方法把數組

arr1

傳遞進去,因為數組是對象類型,是以是引用傳遞,在這個方法裡面我們更改 arr1 的指向,是以如果是這面向對象語言中,我們認為最後的結果arr1 的值是重新指向的那個,即

[1, 2]

,但最後列印結果可以看出 arr1 的值還是原先的值,這是為什麼呢?

共享傳遞

Stack Overflow上Community Wiki 對上述的回答是:對于傳遞到函數參數的對象類型,如果直接改變了拷貝的引用的指向位址,那是不會影響到原來的那個對象;如果是通過拷貝的引用,去進行内部的值的操作,那麼就會改變到原來的對象的。

可以參考博文

JavaScript Fundamentals (2) – Is JS call-by-value or call-by-reference?
function changeStuff(state1, state2)
{
  state1.item = 'changed';
  state2 = {item: "changed"};
}

var obj1 = {item: "unchanged"};
var obj2 = {item: "unchanged"};

changeStuff(obj1, obj2);
console.log(obj1.item);  // obj1.item 會被改變  
console.log(obj2.item);  // obj2.item 不會被改變           

緣由: 上述的

state1

相當于

obj1

, 然後

obj1.item = 'changed'

,對象

obj1

内部的

item

屬性進行了改變,自然就影響到原對象

obj1

。類似的,

state2

也是就

obj2

,在方法裡 state2 指向了一個新的對象,也就是改變原有引用位址,這是不會影響到外面的對象(obj2),這種現象更專業的叫法:call-by-sharing,這邊為了友善,暫且叫做 共享傳遞。

代碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 調試,這邊順便給大家推薦一個好用的BUG監控工具

記憶體模型

JavaScript 在執行期間為程式配置設定了三部分記憶體:代碼區,調用堆棧和堆。 這些組合在一起稱為程式的位址空間。

JavaScript 是如何工作的:JavaScript 的共享傳遞和按值傳遞

代碼區:這是存儲要執行的JS代碼的區域。

調用堆::這個區域跟蹤目前正在執行的函數,執行計算并存儲局部變量。變量以後進先出法存儲在堆棧中。最後一個進來的是第一個出去的,數值資料類型存儲在這裡。

例如:

var corn = 95
let lion = 100           
JavaScript 是如何工作的:JavaScript 的共享傳遞和按值傳遞

在這裡,變量

corn

lion

值在執行期間存儲在堆棧中。

堆:是配置設定 JavaScript 引用資料類型(如對象)的地方。 與堆棧不同,記憶體配置設定是随機放置的,沒有 LIFO政策。 為了防止堆中的記憶體漏洞,JS引擎有防止它們發生的記憶體管理器。

class Animal {}

// 在記憶體位址 0x001232 上存儲 new Animal() 執行個體
// tiger 的堆棧值為 0x001232
const tiger = new Animal()

// 在記憶體位址 0x000001 上存儲 new Objec執行個體
// `lion` 的堆棧值為 0x000001
let lion = {
    strength: "Very Strong"
}           
JavaScript 是如何工作的:JavaScript 的共享傳遞和按值傳遞

Here

lion

tiger

是引用類型,它們的值存儲在堆中,并被推入堆棧。它們在堆棧中的值是堆中位置的記憶體位址。

激活記錄(Activation Record),參數傳遞

我們已經看到了 JS 程式的記憶體模型,現在,讓我們看看在 JavaScript 中調用函數時會發生什麼。

// 例子一
function sum(num1,num2) {
    var result = num1 + num2
    return result
}
var a = 90
var b = 100
sum(a, b)           

每當在 JS 中調用一個函數時,執行該函數所需的所有資訊都放在堆棧上。這個資訊就是所謂的激活記錄(Activation Record)。

這個 Activation Record,我直譯為激活記錄,找了好多資料,沒有看到中文一個比較好的翻譯,如果朋友們知道,歡迎留言。

激活記錄上的資訊包括以下内容:

  • SP 堆棧指針:調用方法之前堆棧指針的目前位置。
  • RA 傳回位址:這是函數執行完成後繼續執行的位址。
  • RV 傳回值:這是可選的,函數可以傳回值,也可以不傳回值。
  • 參數:将函數所需的參數推入堆棧。
  • 局部變量:函數使用的變量被推送到堆棧。

我們必須知道這一點,我們在

js

檔案中編寫的代碼在執行之前由 JS 引擎(例如 V8,Rhino,SpiderMonke y等)編譯為機器語言。

是以以下的代碼:

let shark = "Sea Animal"           

會被編譯成如下機器碼:

01000100101010
01010101010101           

上面的代碼是我們的js代碼等價。 機器碼和 JS 之間有一種語言,它是彙編語言。 JS 引擎中的代碼生成器在最終生成機器碼之前,首先是将 js 代碼編譯為彙編代碼。

為了了解實際發生了什麼,以及在函數調用期間如何将激活記錄推入堆棧,我們必須了解程式是如何用彙編表示的。

為了跟蹤函數調用期間參數是如何在 JS 中傳遞的,我們将例子一的代碼使用彙編語言表示并跟蹤其執行流程。

先介紹幾個概念:

ESP:(Extended Stack Pointer)為擴充棧指針寄存器,是指針寄存器的一種,用于存放函數棧頂指針。與之對應的是 EBP(Extended Base Pointer),擴充基址指針寄存器,也被稱為幀指針寄存器,用于存放函數棧底指針。

EBP:擴充基址指針寄存器(extended base pointer) 其記憶體放一個指針,該指針指向系統棧最上面一個棧幀的底部。

EBP 隻是存取某時刻的 ESP,這個時刻就是進入一個函數内後,cpu 會将ESP的值賦給 EBP,此時就可以通過 EBP 對棧進行操作,比如擷取函數參數,局部變量等,實際上使用 ESP 也可以。

// 例子一
function sum(num1,num2) {
    var result = num1 + num2
    return result
}
var a = 90
var b = 100
var s = sum(a, b)           

我們看到

sum

函數有兩個參數

num1

num2

。函數被調用,傳入值分别為

90

100

a

b

記住:值資料類型包含值,而引用資料類型包含記憶體位址。

在調用 sum 函數之前,将其參數推入堆棧

ESP->[......] 

ESP->[   100 ]
     [   90  ]
     [.......]           

然後,它将傳回位址推送到堆棧。傳回位址存儲在EIP 寄存器中:

ESP->[Old EIP]
     [   100 ]
     [   90  ]
     [.......]           

接下來,它儲存基指針

ESP->[Old EBP]
     [Old EIP]
     [   100 ]
     [   90  ]
     [.......]           

然後更改 EBP 并将調用儲存寄存器推入堆棧。

ESP->[Old ESI]
     [Old EBX]
     [Old EDI]
EBP->[Old EBP]
     [Old EIP]
     [   100 ]
     [   90  ]
     [.......]           

為局部變量配置設定空間:

ESP->[       ]
     [Old ESI]
     [Old EBX]
     [Old EDI]
EBP->[Old EBP]
     [Old EIP]
     [   100 ]
     [   90  ]
     [.......]           

這裡執行加法:

mov ebp+4, eax ; 100
add ebp+8, eax ; eax = eax + (ebp+8)
mov eax, ebp+16
ESP->[   190 ]
     [Old ESI]
     [Old EBX]
     [Old EDI]
EBP->[Old EBP]
     [Old EIP]
     [   100 ]
     [   90  ]
     [.......]           

我們的傳回值是190,把它賦給了 EAX。

mov ebp+16, eax           
EAX 是"累加器"(accumulator), 它是很多加法乘法指令的預設寄存器。

然後,恢複所有寄存器值。

[   190 ] DELETED
     [Old ESI] DELETED
     [Old EBX] DELETED
     [Old EDI] DELETED
     [Old EBP] DELETED
     [Old EIP] DELETED
ESP->[   100 ]
     [   90  ]
EBP->[.......]           

并将控制權傳回給調用函數,推送到堆棧的參數被清除。

[   190 ] DELETED
            [Old ESI] DELETED
            [Old EBX] DELETED
            [Old EDI] DELETED
            [Old EBP] DELETED
            [Old EIP] DELETED
            [   100 ] DELETED
            [   90  ] DELETED
[ESP, EBP]->[.......]           

調用函數現在從 EAX 寄存器檢索傳回值到

s

的記憶體位置。

mov eax, 0x000002 ;  // s 變量在記憶體中的位置           

我們已經看到了記憶體中發生了什麼以及如何将參數傳遞彙編代碼的函數。

調用函數之前,調用者将參數推入堆棧。是以,可以正确地說在 js 中傳遞參數是傳入值的一份拷貝。如果被調用函數更改了參數的值,它不會影響原始值,因為它存儲在其他地方,它隻處理一個副本。

function sum(num1) {
    num1 = 30
}
let n = 90
sum(n)
// `n` 仍然為 90           

讓我們看看傳遞引用資料類型時會發生什麼。

function sum(num1) {
    num1 = { number:30 }
}
let n = { number:90 }
sum(n)
// `n` 仍然是 { number:90 }           

用彙編代碼表示:

n -> 0x002233         
Heap:                       Stack:
002254                      012222
...                         012223 0x002233
002240                      012224
002239                      012225
002238
002237
002236
002235
002234
002233 { number: 90 }
002232
002231 { number: 30 }
Code:
 ...
000233 main:   // entry point
000234 push n  // n 值為 002233 ,它指向堆中存放 {number: 90} 位址。 n 被推到堆棧的 0x12223 處.
000235 ; // 儲存所有寄存器
...
000239 call sum ;  // 跳轉到記憶體中的`sum`函數
000240
 ...

000270 sum:
000271 ; // 建立對象 {number: 30} 内在位址主 0x002231
000271 mov 0x002231, (ebp+4) ;  // 将記憶體位址為 0x002231 中 {number: 30} 移動到堆棧 (ebp+4)。(ebp+4)是位址 0x12223 ,即 n 所在位址也是對象 {number: 90} 在堆中的位置。這裡,堆棧位置被值 0x002231 覆寫。現在,num1 指向另一個記憶體位址。
000272 ; // 清理堆棧
...
000275 ret ; // 回到調用者所在的位置(000240)           

我們在這裡看到變量

n

儲存了指向堆中其值的記憶體位址。 在

sum

函數執行時,參數被推送到堆棧,由

sum

函數接收。

sum

函數建立另一個對象

{number:30}

,它存儲在另一個記憶體位址

002231

中,并将其放在堆棧的參數位置。 将前面堆棧上的參數位置的對象

{number:90}

的記憶體位址替換為新建立的對象

{number:30}

的記憶體位址。

這使得

n

保持不變。是以,複制引用政策是正确的。變量

n

被推入堆棧,進而在

sum

執行時成為

n

的副本。

此語句

num1 = {number:30}

在堆中建立了一個新對象,并将新對象的記憶體位址配置設定給參數

num1

。 注意,在

num1

指向

n

之前,讓我們進行測試以驗證:

// example1.js
let n = { number: 90 }
function sum(num1) {
    log(num1 === n)
    num1 = { number: 30 }
    log(num1 === n)
}
sum(n)


$ node example1
true
false           

是的,我們是對的。就像我們在彙編代碼中看到的那樣。最初,

num1

引用與

n

相同的記憶體位址,因為

n

被推入堆棧。

然後在建立對象之後,将

num1

重新配置設定到對象執行個體的記憶體位址。

讓我們進一步修改我們的例子1:

function sum(num1) {
    num1.number = 30
}
let n = { number: 90 }
sum(n)
// n 成為了 { number: 30 }           

這将具有與前一個幾乎相同的記憶體模型和彙編語言。這裡隻有幾件事不太一樣。在

sum

函數實作中,沒有新的對象建立,該參數受到直接影響。

...
000270 sum:
000271 mov (ebp+4), eax ; // 将參數值複制到 eax 寄存器。eax 現在為 0x002233
000271 mov 30, [eax]; // 将 30 移動到 eax 指向的位址           

num1

是(ebp+4),包含

n

的位址。值被複制到

eax

中,

30

被複制到

eax

指向的記憶體中。任何寄存器上的花括号

[]

都告訴 CPU 不要使用寄存器中找到的值,而是擷取與其值對應的記憶體位址号的值。是以,檢索

0x002233

{number: 90}

值。

看看這樣的

答案

原始資料類型按值傳遞,對象通過引用的副本傳遞。

具體來說,當你傳遞一個對象(或數組)時,你無形地傳遞對該對象的引用,并且可以修改該對象的内容,但是如果你嘗試覆寫該引用,它将不會影響該對象的副本- 即引用本身按值傳遞:

function replace(ref) {
    ref = {};           // 這段代碼不影響傳遞的對象
}
function update(ref) {
    ref.key = 'newvalue';  // 這段代碼确實會影響對象的内容
}
var a = { key: 'value' };
replace(a);  // a 仍然有其原始值,它沒有被修改的
update(a);   // a 的内容被更改           

從我們在彙編代碼和記憶體模型中看到的。這個答案百分之百正确。在

replace

函數内部,它在堆中建立一個新對象,并将其配置設定給

ref

參數,

a

對象記憶體位址被重寫。

update

函數引用

ref

參數中的記憶體位址,并更改存儲在存儲器位址中的對象的

key

屬性。

總結

根據我們上面看到的,我們可以說原始資料類型和引用資料類型的副本作為參數傳遞給函數。不同之處在于,在原始資料類型,它們隻被它們的實際值引用。JS 不允許我們擷取他們的記憶體位址,不像在C與C++程式設計學習與實驗系統,引用資料類型指的是它們的記憶體位址。

Learning JavaScript: Call By Sharing, Parameter Passing

關于Fundebug

專注于JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了10億+錯誤事件,付費客戶有Google、360、金山軟體、百姓網等衆多品牌企業。歡迎大家

免費試用