從JS物件開始,談一談前端“不可變資料”和函數語言程式設計

LucasHC發表於2017-03-21

作為前端開發者,你會感受到JS中物件(Object)這個概念的強大。我們說“JS中一切皆物件”。最核心的特性,例如從String,到陣列,再到瀏覽器的APIs,“物件”這個概念無處不在。在這裡你可以瞭解到JS Objects中的一切。

同時,隨著React的強勢崛起,不管你有沒有關注過這個框架,也一定聽說過一個概念—不可變資料(immutable.js)。究竟什麼是不可變資料?這篇文章會從JS源頭—物件談起,讓你逐漸瞭解這個函數語言程式設計裡的重要概念。

JS中的物件是那麼美妙:我們可以隨意複製他們,改變並刪除他們的某項屬性等。但是要記住一句話:

“伴隨著特權,隨之而來的是更大的責任。”
(With great power comes great responsibility)

的確,JS Objects裡概念太多了,我們切不可隨意使用物件。下面,我就從基本物件說起,聊一聊不可變資料和JS的一切。

這篇文章緣起於Daniel Leite在2017年3月16日的新鮮出爐文章:Things you should know about Objects and Immutability in JavaScript,我進行了大致翻譯並進行大範圍“改造”,同時改寫了用到的例子,進行了大量更多的擴充套件。

“可變和共享”是萬惡之源

不可變資料其實是函數語言程式設計相關的重要概念。相對的,函數語言程式設計中認為可變性是萬惡之源。但是,為什麼會有這樣的結論呢?

這個問題可能很多程式設計師都會有。其實,如果你的程式碼邏輯可變,不要驚慌,這並不是“政治錯誤”的。比如JS中的陣列操作,很對都會對原陣列進行直接改變,這當然並沒有什麼問題。比如:

let arr = [1, 2, 3, 4, 5];
arr.splice(1, 1); // 返回[2];
console.log(arr); // [1, 3, 4, 5];複製程式碼

這是我們常用的“刪除陣列某一項”的操作。好吧,他一點問題也沒有。

問題其實出現在“濫用”可變性上,這樣會給你的程式帶來“副作用”。先不必關心什麼是“副作用”,他又是一個函數語言程式設計的概念。

我們先來看一下程式碼例項:

const student1 = {
    school: 'Baidu',
    name: 'HOU Ce',
    birthdate: '1995-12-15',
}

const changeStudent = (student, newName, newBday) => {
    const newStudent = student;
    newStudent.name = newName;
    newStudent.birthdate = newBday;
    return newStudent;
}

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"} 
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}複製程式碼

我們發現,儘管建立了一個新的物件student2,但是老的物件student1也被改動了。這是因為JS物件中的賦值是“引用賦值”,即在賦值過程中,傳遞的是在記憶體中的引用(memory reference)。具體說就是“棧儲存”和“堆儲存”的問題。具體圖我就不畫了,理解不了可以單找我。

不可變資料的強大和實現

我們說的“不可變”,其實是指保持一個物件狀態不變。這樣做的好處是使得開發更加簡單,可回溯,測試友好,減少了任何可能的副作用。
函數語言程式設計認為:

只有純的沒有副作用的函式,才是合格的函式。

好吧,現在開始解釋下“副作用”(Side effect):

在電腦科學中,函式副作用指當呼叫函式時,除了返回函式值之外,還對主呼叫函式產生附加的影響。例如修改全域性變數(函式外的變數)或修改引數。
-維基百科

函式副作用會給程式設計帶來不必要的麻煩,給程式帶來十分難以查詢的錯誤,並降低程式的可讀性。嚴格的函式式語言要求函式必須無副作用。

那麼我們避免副作用,建立不可變資料的主要實現思路就是:一次更新過程中,不應該改變原有物件,只需要新建立一個物件用來承載新的資料狀態。

我們使用純函式(pure functions)來實現不可變性。純函式指無副作用的函式。
那麼,具體怎麼構造一個純函式呢?我們可以看一下程式碼實現,我對上例進行改造:

const student1 = {
    school: "Baidu", 
    name: 'HOU Ce',
    birthdate: '1995-12-15',
}

const changeStudent = (student, newName, newBday) => {
    return {
        ...student, // 使用解構
        name: newName, // 覆蓋name屬性
        birthdate: newBday // 覆蓋birthdate屬性
    }
}

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15"} 
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}複製程式碼

需要注意的是,我使用了ES6中的解構(destructuring)賦值。
這樣,我們達到了想要的效果:根據引數,產生了一個新物件,並正確賦值,最重要的就是並沒有改變原物件。

建立純函式,過濾副作用

現在,我們知道了“不可變”到底指的是什麼。接下來,我們就要分析一下純函式應該如何實現,進而生產不可變資料。

其實建立不可變資料方式有很多,在使用原生JS的基礎上,我推薦的方法是使用現有的Objects API和ES6當中的解構賦值(上例已經演示)。現在看一下Objects.assign的實現方式:

const student1 = {
    school: "Baidu", 
    name: 'HOU Ce',
    birthdate: '1995-12-15',
}

const changeStudent = (student, newName, newBday) => Object.assign({}, student, {name: newName, birthdate: newBday})

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15"};
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"};複製程式碼

同樣,如果是處理陣列相關的內容,我們可以使用:.map, .filter或者.reduce去達成目標。這些APIs的共同特點就是不會改變原陣列,而是產生並返回一個新陣列。這和純函式的思想不謀而合。

但是,再說回來,使用Object.assign請務必注意以下幾點:
1)他的複製,是將所有可列舉屬性,複製到目標物件。換句話說,不可列舉屬性是無法完成複製的。
2)物件中如果包含undefined和null型別內容,會報錯。
3)最重要的一點:Object.assign方法實行的是淺拷貝,而不是深拷貝。

第三點很重要,也就是說,如果源物件某個屬性的值是物件,那麼目標物件拷貝得到的是這個屬性物件的引用。這也就意味著,當物件存在巢狀時,還是有問題的。比如下面程式碼:

const student1 = {
    school: "Baidu", 
    name: 'HOU Ce',
    birthdate: '1995-12-15',
    friends: {
        friend1: 'ZHAO Wenlin',
        friend2: 'CHENG Wen'
    }
}

const changeStudent = (student, newName, newBday, friends) => Object.assign({}, student, {name: newName, birthdate: newBday})

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2); 
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15", friends: Object}
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10", friends: Object}

student2.friends.friend1 = 'MA xiao';
console.log(student1.friends.friend1); // "MA xiao"複製程式碼

對student2 friends列表當中的friend1的修改,同時也影響了student1 friends列表當中的friend1。

JS本身的蒼白無力VS不可變資料類庫

以上,我們分析了純JS如何實現不可變資料。這樣處理帶來的一個負面影響在於:一些經典APIs都是shallow處理,比如上文提到的Object.assign就是典型的淺拷貝。如果遇到巢狀很深的結構,我們就需要手動遞迴。這樣做呢,又會存在效能上的問題。

比如我自己動手用遞迴實現一個深拷貝,需要考慮迴圈引用的“死環”問題,另外,當使用大規模資料結構時,效能劣勢盡顯無疑。我們熟悉的jquery extends方法,某一版本(最新版本情況我不太瞭解)的實現是進行了三層拷貝,也沒有達到完備的deep copy。

總之,實現不可變資料,我們必然要關心效能問題。針對於此,我推薦一款已經“大名鼎鼎”的——immutable.js類庫來處理不可變資料。

他的實現既保證了不可變性,又保證了效能大限度優化。原理很有意思,下面這段話,我摘自camsong前輩的文章

Immutable實現的原理是Persistent Data Structure(持久化資料結構),也就是使用舊資料建立新資料時,要保證舊資料同時可用且不變。
同時為了避免deepCopy把所有節點都複製一遍帶來的效能損耗,Immutable使用了Structural Sharing(結構共享),即如果物件樹中一個節點發生變化,只修改這個節點和受它影響的父節點,其它節點則進行共享。

感興趣的讀者可以深入研究下,這是很有意思的。如果有需要,我也願意再寫一篇immutable.js原始碼分析。

總結

我們使用JavaScript操縱物件,這樣的方式很簡單便捷。但是,這樣操控的基礎是在JavaScript靈活機制的熟練掌握上。不然很容易使你“頭大”。

在我開發的百度某部門私信專案中,因為使用了React+Redux技術棧,並且資料結構較為負責,所以我也採用了immutable.js實現。

最後,在前端開發中,函數語言程式設計越來越熱,並且在某種程度上已經取代了“過程式”程式設計和麵向物件思想。

我的感想是在某些特定的場景下,不要畏懼變化,擁抱未來。
就像我很喜歡的葡萄牙詩人安德拉德一首詩中那樣說的:

我同樣不知道什麼是海,
赤腳站在沙灘上,
急切地等待著黎明的到來。

Happy Coding!

PS:百度知識搜尋部大前端繼續招兵買馬,高階工程師、實習生職位均有,產品、運營同樣崗位多多。有意向者火速聯絡。。。

相關文章