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

摘要: 原始數據類型和引用數據類型的副本做爲參數傳遞給函數。javascript

Fundebug經受權轉載,版權歸原做者全部。前端

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

若是你錯過了前面的章節,能夠在這裏找到它們:node

  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如何將值傳遞給函數,在互聯網上有不少誤解和爭論。大體認爲,參數爲原始數據類時使用按值傳遞,參數爲數組、對象和函數等數據類型使用引用傳遞web

按值傳遞 和 引用傳遞參數 主要區別簡單能夠說:編程

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

但答案是 JavaScript 對全部數據類型都使用按值傳遞。它對數組和對象使用按值傳遞,但這是在的共享傳參拷貝的引用中使用的按值傳參。這些說有些抽象,先來幾個例子,接着,咱們將研究JavaScript在 函數執行期間的內存模型,以瞭解實際發生了什麼。小程序

按值傳參

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

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監控工具Fundebug

內存模型

JavaScript 在執行期間爲程序分配了三部份內存:代碼區調用堆棧。 這些組合在一塊兒稱爲程序的地址空間。

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

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

例如:

var corn = 95
let lion = 100
複製代碼

在這裏,變量 cornlion 值在執行期間存儲在堆棧中。

堆:是分配 JavaScript 引用數據類型(如對象)的地方。 與堆棧不一樣,內存分配是隨機放置的,沒有 LIFO策略。 爲了防止堆中的內存漏洞,JS引擎有防止它們發生的內存管理器。

class Animal {}

// 在內存地址 0x001232 上存儲 new Animal() 實例
// tiger 的堆棧值爲 0x001232
const tiger = new Animal()

// 在內存地址 0x000001 上存儲 new Objec實例
// `lion` 的堆棧值爲 0x000001
let lion = {
    strength: "Very Strong"
}
複製代碼

Hereliontiger 是引用類型,它們的值存儲在堆中,並被推入堆棧。它們在堆棧中的值是堆中位置的內存地址

激活記錄(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 函數有兩個參數 num1num2。函數被調用,傳入值分別爲 90100ab

記住:值數據類型包含值,而引用數據類型包含內存地址。

在調用 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

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

相關文章
相關標籤/搜索