消失的魔術:隱藏在js引用和原型鏈背後的超級能力

js這門語言有很多詬病,然而很多被無視的點,構成了js最爲美妙的語言特性。這篇文章將帶你走進魔術般的引用型數據類型和原型鏈背後,尋找那些被遺忘的超能力。並且,基於這些超能力,我們將實現功能極其複雜,但可以達到極爲絕妙的架構設計。

引用型數據類型

稱法有很多,但是在我這裏,我統一稱這種借鑑於java的數據結構爲引用型數據類型。除去幾種基本數據類型,其他所有類型都是引用型數據類型。所謂引用型數據類型,是指變量保持內存地址指針,當該指針對應的具體內容發生變化時,指向同一指針的所有變量同時發生變化。

這是一個極其複雜的設計,這裏的“複雜”既包含原理上的,也包含情感上的。一臺機器的內存是有限的,雖然獨立的棧存儲數據更有利於快速讀取,但是會很快消耗完內存。而堆存儲由於沒有特定結構,而且js還是弱類型語言,這讓讀取數據又變的很慢。兩難之間取捨,最後引用型數據類型成爲js這門語言最原始的力量,支撐着所有程序的發展。

這就是js的“原力”,引用型數據類型決定了js的基因,很多語言特性成爲那樣,很大程度是因爲基因決定。

舉個例子,我們不能在遍歷一個數組的時候,隨意刪除數組的某個元素:

let arr = [1, 2, 3, 4]
for (let i = 0, len = arr.length; i < len; i ++) {
  let item = arr[i]
  if (item === 2) {
    arr.splice(i, 1)
  }
  console.log(i, item)
}

在遍歷過程中,我們刪掉了一個元素,導致數組的長度變短,而實際循環並沒有被調整,因此,我們必須寫一行代碼進行調整:

let arr = [1, 2, 3, 4]
for (let i = 0, len = arr.length; i < len; i ++) {
  let item = arr[i]
  if (item === 2) {
    arr.splice(i, 1)    len = arr.length
  }
  console.log(i, item)
}

這樣的操作我們司空見慣。

內存引用帶來了很多副作用,因此當我們使用redux時,必須遵循它那一整套reducer的規則,如果直接修改一個對象,會導致數據雖變但值仍相等的情況:

let a = { test: 1 }
let b = a
b.test = 2
// a === b => true

這在react的組件撰寫中非常危險,它使得shouldComponentUpdate等鉤子不能被正常觸發。

看上去這是一個大坑,大而特大的坑。但是,如果我們換一個角度,我們在什麼情況下需要這樣的力量?

let a = {
  data: {},
  say() {
    alert(this.data.msg)
  },
}
let b = {
  get data() {
    return a.data
  },
  say() {
    alert('my msg:' + this.data.msg)
  },
}

上面這段代碼,我們的期望是,a和b共用同一個data,雖然它們在自己的行爲上不同,但是它們的行爲基於相同的data數據來實現,雖然上面這樣寫沒有什麼錯,但是,我們爲何不直接寫成:

let data = {}
let a = {
  data,
  say() {
    alert(this.data.msg)
  },
}
let b = {
  data,
  say() {
    alert('my msg:' + this.data.msg)
  },
}

這樣的語義不是更明確嗎?我們這裏非常明確的表述,a和b使用相同的data,當data改變時,同時影響它們的行爲。

這樣的例子你完全看不出它的威力,原因在於data太過簡單。倘若,data是一個跨模塊的龐大數據體系,它貫穿於你的整個應用,用戶在pageA對data進行了修改,希望這個修改被帶到pageB,如果通過函數來逐層傳遞,那估計得寫N個函數吧。然而,實際上,我們只需要一個引用數據,不需要任何額外的內存開銷。

原型鏈繼承

再見識了上面的data的有趣之處後,我們再來看js的原型鏈繼承。在js裏面,各種花哨的操作實在是太多太多了,比如通過new關鍵字創建一個實例,比如通過extends繼承一個類,比如令人抓狂的this……這些風騷的操作背後,原型鏈繼承起到了黑色幽默的決定作用。

我相信你玩兒過docker,我不講docker,我講原型鏈。

一個優秀的原型鏈保證一個數據體系擁有最小單位。讓我們來看下一個例子:

var obj1 = {
  a: 1,
  b: 2,
}
var obj2 = Object.assign({}, a, { b: 3 })

這段代碼的目的是,創建一個obj2,使它跟obj1有大致相仿的結構,但是也有自己的特殊性。然而,仔細觀察,我們會發現,obj2是對obj1的淺複製。它在表層擁有完全獨立的存儲空間,如果我們按照這樣的方法複製一萬個obj,我們會發現,內存被吃的很快。而原型鏈繼承就像一個帶着白手套的魔術師,用更爲簡潔的語言去描述相同結構的數據。

var obj1 = {
  a: 1,
  b: 2,
}
var obj2 = Object.create(obj1)
obj2.b = 3

雖然代碼的行數增加了,然而內在的機理卻在發生變化。obj2保持了最小的內存消化,但同時擁有了和obj1相似的數據結構。更爲重要的是,你是否還記得前面我們談到data被共用的場景。我們讓千萬個obj共用data作爲一個結構模型,但使用最少量但內存消耗:

var data = {
  a: 1,
  b: 2,
}
var obj1 = Object.create(data)
Object.assign(obj1, {
  a: 3,
})
var obj2 = Object.create(data)
Object.assign(obj2, {
  b: 4,
})

當我修改data時,所有的obj都在調整,但是它們又不會丟失自己的特殊性。

原型鏈繼承,就像是js世界的圖騰,所有的js文化都在圍繞着它發展壯大。這似乎有點危言聳聽,但如果你認爲angular是一個不錯的框架的話,一定還記得angular中關於作用域的一些列描述。父級作用域在子作用域中仍然有效,但子作用域優先級更高。它背後的原理,就是利用原型鏈的繼承來實現。

核級應用:數據快照vs數據版本控制

前面講了那麼多,有沒有更感性的方式,讓我們可以對這些無關痛癢的話題更加在意呢?當然有的,我們需要自己手擼一個東西,讓這些零零碎碎的興奮可以落地成核,炸開一個新宇宙。

我們知道,在使用redux時,我們可以做到一個功能,就是恢復數據,或者將連續的狀態動態設置,形成界面的連續變化,終而形成肉眼可觀的影像。我們的認知告訴我們,這個原理很簡單,redux管理的是狀態,應用一個狀態對應一個界面,把每一個狀態的變化保存起來,就可以得到連續的狀態,也就可以得到連續的界面變化。

可是啊,如此龐大的狀態,每一個變動可能就是一個微小的粒子,保存起來?也許還是太年輕。

我已經提到過docker了,不知道你還對它有沒有興趣。每一個容器基於一個鏡像,鏡像層層疊疊,就像是人類文明一樣,後人站在前人的肩膀上。底層的鏡像可以被不同的上層鏡像使用,這樣,就減少了同樣的內容在docker中重複出現的情況。不同的應用都基於Ubuntu,只要一個Ubuntu鏡像就行了,apache、php,對於一個應用的不同環境,這些底層鏡像大家都相同了,但卻可以跑出千萬多姿的應用出來。

同樣的道理,狀態的改變,只是在原有狀態的基礎上做一點小小的定製而已,有必要把整個狀態都保存起來嗎?不需要,只需要保存變化過的那一點點就可以了,其他的所有,我們從上一個狀態繼承即可。這是最最最適合魔術師原型鏈發揮魔力的地方了。

母狀態 -> 狀態b -> 狀態c -> 當前狀態

在這個應用場景裏,被保存過的狀態從來不會被修改,它們安靜的沉睡。你可以讓當前的界面,猶如遊標卡尺般,在這個鏈條上來回遊動,像那些被玩兒壞的鬼畜剪輯般,遊刃有餘。

你可能還是git的忠實粉絲,喜歡merge功能喜歡到爆炸。在這樣的原型鏈模型裏面,你也可以輕鬆做到數據的版本管理:

母狀態
|
狀態a
|
狀態b
|    \
|    狀態c
|       \
狀態d     |
|       /
|    狀態e
|    /
狀態f
|
當前狀態

這樣的結構,基於redux可以實現嗎?或許還差那麼一些,然而,基於原型鏈卻是輕輕鬆鬆。任何一個狀態,都可以由兩部分組成,一部分是來自對上一個狀態的繼承,另一部分是來自自己獨特的特殊數據。而相對而言,這些特殊數據的量總是小的。

你可能有個疑問,上面將“狀態e”merge到“狀態f”的過程怎麼去實現呢?有兩種方式:

var f = Object.create(d)
Object.assgin(f, e)

這種方式把d這條線當作master,接收來自e這條分支的pull request。

var f = new Proxy({}, {
  get: (target, prop) => {
    return target[prop] || e[prop] || d[prop]
  },
})
Object.defineProperties(f, {
  $$master: { value: d },
  $$branck: { value: e },
})

這種方式則繞一些,通過創建代理來保證同時保持了兩個狀態的同時繼承,同時定義了兩個隱式的屬性來保存繼承的來源,方便後期查找。這種方法比方法1更好的地方在於,方法1把狀態e merge到f之後,和e就沒有關係了,merge的過程,要求把e這條分支的所有改動都找出來,然後一併賦值給f,這樣其實面臨了前面說的保存了一些其實不用保存的數據。而方法2則很好的解決這個問題,它不需要把數據從整個e這條分支檢索出來,仍然保留了原型鏈繼承的模式。

小結

也許,你對js的愛憎分明,你渴望它更加完美,但是非常遺憾的是,每一門語言都不可能完美,否則世界上只需要一門語言,然而正式因爲語言的多樣性,這個世界才更加有趣。對js原始衝動的琢磨,或許就是一個興趣的開始,你不需要糾結於語言的語法和憋足的數據類型,你領略了它原力中的super power之後,就可以享受這一場魔術盛宴了。

最後的小廣告,剛剛完成了objext這個包的開發,它很好的利用了原型鏈的特點,實現了複雜但接口又很簡單的功能,歡迎品嚐 https://github.com/tangshuang/objext。

-----------------------------------------

本文申請加入騰訊雲自媒體分享計劃

https://cloud.tencent.com/developer/support-plan?invite_code=u6gyjlypetip

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章