前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >消失的魔术:隐藏在js引用和原型链背后的超级能力

消失的魔术:隐藏在js引用和原型链背后的超级能力

作者头像
否子戈
发布2018-09-28 15:51:30
7120
发布2018-09-28 15:51:30
举报
文章被收录于专栏:

js这门语言有很多诟病,然而很多被无视的点,构成了js最为美妙的语言特性。这篇文章将带你走进魔术般的引用型数据类型和原型链背后,寻找那些被遗忘的超能力。并且,基于这些超能力,我们将实现功能极其复杂,但可以达到极为绝妙的架构设计。

引用型数据类型

称法有很多,但是在我这里,我统一称这种借鉴于java的数据结构为引用型数据类型。除去几种基本数据类型,其他所有类型都是引用型数据类型。所谓引用型数据类型,是指变量保持内存地址指针,当该指针对应的具体内容发生变化时,指向同一指针的所有变量同时发生变化。

这是一个极其复杂的设计,这里的“复杂”既包含原理上的,也包含情感上的。一台机器的内存是有限的,虽然独立的栈存储数据更有利于快速读取,但是会很快消耗完内存。而堆存储由于没有特定结构,而且js还是弱类型语言,这让读取数据又变的很慢。两难之间取舍,最后引用型数据类型成为js这门语言最原始的力量,支撑着所有程序的发展。

这就是js的“原力”,引用型数据类型决定了js的基因,很多语言特性成为那样,很大程度是因为基因决定。

举个例子,我们不能在遍历一个数组的时候,随意删除数组的某个元素:

代码语言:javascript
复制
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)
}

在遍历过程中,我们删掉了一个元素,导致数组的长度变短,而实际循环并没有被调整,因此,我们必须写一行代码进行调整:

代码语言:javascript
复制
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的规则,如果直接修改一个对象,会导致数据虽变但值仍相等的情况:

代码语言:javascript
复制
let a = { test: 1 }
let b = a
b.test = 2
// a === b => true

这在react的组件撰写中非常危险,它使得shouldComponentUpdate等钩子不能被正常触发。

看上去这是一个大坑,大而特大的坑。但是,如果我们换一个角度,我们在什么情况下需要这样的力量?

代码语言:javascript
复制
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数据来实现,虽然上面这样写没有什么错,但是,我们为何不直接写成:

代码语言:javascript
复制
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,我讲原型链。

一个优秀的原型链保证一个数据体系拥有最小单位。让我们来看下一个例子:

代码语言:javascript
复制
var obj1 = {
  a: 1,
  b: 2,
}
var obj2 = Object.assign({}, a, { b: 3 })

这段代码的目的是,创建一个obj2,使它跟obj1有大致相仿的结构,但是也有自己的特殊性。然而,仔细观察,我们会发现,obj2是对obj1的浅复制。它在表层拥有完全独立的存储空间,如果我们按照这样的方法复制一万个obj,我们会发现,内存被吃的很快。而原型链继承就像一个带着白手套的魔术师,用更为简洁的语言去描述相同结构的数据。

代码语言:javascript
复制
var obj1 = {
  a: 1,
  b: 2,
}
var obj2 = Object.create(obj1)
obj2.b = 3

虽然代码的行数增加了,然而内在的机理却在发生变化。obj2保持了最小的内存消化,但同时拥有了和obj1相似的数据结构。更为重要的是,你是否还记得前面我们谈到data被共用的场景。我们让千万个obj共用data作为一个结构模型,但使用最少量但内存消耗:

代码语言:javascript
复制
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,对于一个应用的不同环境,这些底层镜像大家都相同了,但却可以跑出千万多姿的应用出来。

同样的道理,状态的改变,只是在原有状态的基础上做一点小小的定制而已,有必要把整个状态都保存起来吗?不需要,只需要保存变化过的那一点点就可以了,其他的所有,我们从上一个状态继承即可。这是最最最适合魔术师原型链发挥魔力的地方了。

代码语言:javascript
复制
母状态 -> 状态b -> 状态c -> 当前状态

在这个应用场景里,被保存过的状态从来不会被修改,它们安静的沉睡。你可以让当前的界面,犹如游标卡尺般,在这个链条上来回游动,像那些被玩儿坏的鬼畜剪辑般,游刃有余。

你可能还是git的忠实粉丝,喜欢merge功能喜欢到爆炸。在这样的原型链模型里面,你也可以轻松做到数据的版本管理:

代码语言:javascript
复制
母状态
|
状态a
|
状态b
|    \
|    状态c
|       \
状态d     |
|       /
|    状态e
|    /
状态f
|
当前状态

这样的结构,基于redux可以实现吗?或许还差那么一些,然而,基于原型链却是轻轻松松。任何一个状态,都可以由两部分组成,一部分是来自对上一个状态的继承,另一部分是来自自己独特的特殊数据。而相对而言,这些特殊数据的量总是小的。

你可能有个疑问,上面将“状态e”merge到“状态f”的过程怎么去实现呢?有两种方式:

代码语言:javascript
复制
var f = Object.create(d)
Object.assgin(f, e)

这种方式把d这条线当作master,接收来自e这条分支的pull request。

代码语言:javascript
复制
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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2018-09-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 唐霜 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引用型数据类型
  • 原型链继承
  • 核级应用:数据快照vs数据版本控制
  • 小结
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档