阅读 436

🍉 11665字 | JavaScript基础大复习

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

本文会随时有更新,更新会在标题上体现日期,方便查看。收藏专栏或文章不怕迷路噢~ ECMA-262 规范 镇楼 🙏🏻 ,祝看到此文的小伙伴快乐暴富~

变量在内存中如何存储(第1版)

JavaScript 内存空间分为两块 —— 栈内存和堆内存。

一般将原始数据类型的变量直接存放在栈内存中(便于快速读写和内存释放),而对于引用数据类型则是将变量存放在堆内存中,对应会产生一个堆内存的内存地址(hashCode),栈内存中该变量值则是存储了这个地址,当我们访问该变量时,会在按照这个地址找到对应堆内存中的数据值。

这么说可能还有些云里雾里,我们再补充一下知识路径,更好理解这个概念。

插一嘴, JavaScript 规定了几种数据类型?

8 种(7 种原始数据类型 + 1 中引用数据类型)

原始类型值(存储在栈内存中)描述
null变量值为null( typeof null === 'object' ,值为null的变量会被垃圾回收)
undefined一个没有被赋值的变量默认值为undefined(有变量提升的情况下,在赋值语句前打印变量,默认值也是undefined)
boolean变量值为 true 或者 false
number数值型(精度丢失!浏览器对请求返回数据json中是数值型的数据,末尾会有抹0的动作)
string字符串型(在做js加法运算时,会先将变量转换成原始类型,如果加号两边的变量有任一个为字符串类型,则另一个也会强制转换成字符串类型做字符串)
symbol一个永远不会和其他值相等的值(可以用在对象内部私有变量做唯一标识)
bigInt任意精度的整数(很多人都以为只有小数才会遇见精度丢失问题,其实整数也会, BigInt(n) 生成的整数不会出现精度丢失)
引用类型值(存储在堆内存中)描述
object一组键值对的集合(还有诸如Date/Function/Array之类的子类型)

ps:这里需要注意这里的类型指的都是 值 的类型(下面👇 会有对此点的说明)

e.g.

typeof undefined === "undefined" // true
typeof true === "boolean" // true
typeof 32 === "number" // true
typeof "js" === "string" // true
typeof { name: 'Mike' } === "object" // true
// ES6 新加入
typeof Symbol() === "symbol" // true
// ES2020 新加入
typeof BigInt(123) === "bigint" // true
// 注意!!
typeof null === "object" // true
// object 下的子类型
typeof(function() {}) === "function" // true
typeof(new Date()) === "object" // true
typeof [] === "object" // true
复制代码

null 和 undefined 的区别 @0716

  • null:不应该有值了
    • 当我们将一个变量置为null,则表示该变量已经使用完成,再也不会与其他变量产生关系,该变量所占用的内存会被释放。
  • undefined:期待有值但未被赋值
    • 当一个变量的值为undefined时,表明这个变量还未被赋值,它会期待有下一次的赋值,该变量还是会对内存造成占用。(ps:关于垃圾回收可能之后会在下文更新,敬请期待~)
    • 当我们在做读操作时,诸如获取某个对象属性、某个变量、某个函数,我们会发现当期待有值却没有值的时候,我们都会得到undefined,它被作为期待有值时的默认值。

判断变量类型的方法(plus如何准确的判断数组类型)@0716

判断变量的三种方法,及其优缺点

  1. typeof :

✅ 优点:通过 typeof xxx 直接输出类型字面量( "undefined" 、 "object" 等)

typeof 123 // "number"
typeof true // "boolean"
typeof {} // "object"
复制代码

❎ 缺点:无法准确判断 null 类型,以及 object 类型衍生的一些子类型(如:Date、Array)

// 注意!!
typeof null // "object"
// object 下的子类型
typeof(function() {}) // "function"
typeof(new Date()) // "object"
typeof [] // "object"
复制代码

📌 更多内容可参考 MDN typeof >

  1. instanceof :

✅ 优点:通过 xxx instanceof ConstructorPrototype 判断 xxx 的原型链上是否存在某个构造函数的原型,即可以检测某个对象是否是继承自某个类型,对于引用类型可以精确判断出它的真正类型

123 instanceof Number // false
new Number(123) instanceof Number // true
复制代码

❎ 缺点: instanceof 右侧需提供构造函数,那么意味着我们必须提前知道被检测变量和某种数据类型有关,并且**如果不是通过构造函数创建的实例(即原始类型值),这个判断会失效,没有相关构造函数的实例就更加会不适用。并且只要是原型链上存在过的构造函数原型那么都会得到true,对于复杂的引用类型判断会不准确。(探索这里的时候就可以联系到原始类型及其包装对象,相关内容快速跳转👇)

📌 更多内容可参考 MDN instanceof >

手写 instanceof @0718

ps: 这个实现会涉及到原型原型链实例对象的一些概念,如果觉得有需要讲一下的小伙伴可以给我评论下,我可以补一个。

使用:Number(123) instanceof Number

  • 左侧接收一个实例对象,右侧接收一个构造函数

    const myInstanceof = (inst, Constructor) => {
      // ...
    }
    复制代码
  • 遍历实例对象的原型链(直到null),查找是否存在构造函数原型

    构造函数与实例对象关系:实例对象.constructor === 构造函数

    构造函数原型与实例对象关系:实例对象.__proto__ === 构造函数.prototype

    const myInstanceof = (inst, Constructor) => {
      // 构造函数的原型对象
      const prototype = Constructor && Constructor.prototype
      // 原型链
      const proto = inst && inst.__proto__
      // 还有原型链可以遍历
      while(proto) {
      	if (proto === prototype) {
          // 存在
        	return true
      	}
        // 不存在 -> 再往上一层原型链
      	proto = proto.__proto__
      }
      // 原型链遍历结束,不存在
      return false
    }
    复制代码
  • 完善函数,补全一些校验,及可前置预判条件

    • Constructor 必须是个函数
    • inst 防空
    • 上面段落有提到过,instanceof的缺点,对于值类型无法进行判断,直接忽略返回false
    const myInstanceof = (inst, Constructor) => {
      // Constructor 必须是个函数
      if (typeof Constructor !== ’function‘) {
        // typeof 适用于判断一个变量是不是函数,下面段落也会有提到
        throw new Error('Type Error')
      }
      // 上面段落有提到过,instanceof的缺点,对于值类型无法进行判断,直接忽略
      const baseTypes = ['null', 'undefined', 'boolean', 'string', 'Symbol', 'BigInt', 'number']
      // inst 防空
      if (!inst || baseTypes.includes(typeof inst)) {
        return false
      }
      // 构造函数的原型对象
      const prototype = Constructor && Constructor.prototype
      // 原型链
      const proto = inst && inst.__proto__
      // 还有原型链可以遍历
      while(proto) {
      	if (proto === prototype) {
          // 存在
        	return true
      	}
        // 不存在 -> 再往上一层原型链
      	proto = proto.__proto__
      }
      // 原型链遍历结束,不存在
      return false
    }
    复制代码

差不多实现就是这样,小伙伴们可以按照步骤,自己尝试一下,写完之后可以多些几个Test Case,和instanceof关键字比较下结果,完整代码已上传仓库,有问题也欢迎评论探讨~

typeof 可以判断哪些类型? instanceof 做了什么? @0719

typeof可以用来判断原始类型(除null外),引用类型中可以用来判断function。

instanceof左侧接收一个实例对象,右侧接收一个构造函数,它会遍历实例对象的原型链检测是否存在构造函数的原型对象。

typeof null? null instanceof Object? @0719
typeof null // "object"
null instanceof Object // false
复制代码
  1. Object.prototype.toString.call() :

✅ 优点:调用Object.prototype.toString.call() 能准确输出变量类型,包括null、undefined、以及具体引用类型

❎ 缺点:暂无(注意:在改写过Object.prototype.toString()的情况下可能会失效)

📌 使用可参考 MDN toString >,规范可参考 ECMA262 toString >

如何准确的判断数组类型 @0719

  1. Object.prototype.toString.call(variable):在不该写toString的情况下可准确判断

  2. Array.isArray(variable):在支持ES5的环境下可准确判断(📌 规范可参考 ECMA262 isArray >)

包装对象

包装对象、装箱、拆箱,这几个关键字想必大家肯定多少看到过几次,包括上面的 instanceof 实践中也有碰到相关的case,我们来一步一步理解一下包装对象的意义。

对象是什么 @0720

计算机语言中对象是一组键值对的集合,那本质上我们为什么要有对象呢?这就要追溯到面向对象语言的特点:

面向对象关键字:对象,封装,继承,多态

  • 通过封装将现实世界中的事物抽象成可以描述事物的对象(模型)
  • 将有着相同意义的对象归纳成类
  • 通过继承实现类的可泛化(代码复用,属性共享)、可拓展(属性拓展)
  • 同一方法可以有不同执行结果形成多态

所以对象本质上是我们用来描述一个事物的模型,在有了类的基础上,它更能实现多实例对象间的属性共享、方法复用。

new Number(1)和1有什么区别 @0718

首先new Number(1)使用了new关键字,所以它生成的一定是一个对象 — 引用类型,而1是一个原始类型。

那么Number是什么呢?我们来介绍一下:

JavaScript 标准内置对象 @0720

内置对象是每一种语言都会有的基本功能,内置对象定义了某个类型的对象既有的一些属性和方法,以及一些全局可使用的基本对象(比如错误对象等)。

📌 全部内置对象可参考 MDN >

这里重点介绍一下原始类型相关的内置对象 @0720

原始类型对应的内置对象我们称为包装对象,也不是所有原始类型都有包装对象的,目前除了null和undefined之外都有对应的包装对象。(需要注意的是Symbol和BigInt没有字面量创建的方式,所以他们本身就可以作为包装对象使用,不需要使用new关键字)

包装对象的作用就是为了实现我们前面提到的**“实现同类对象之前的属性共享、方法复用”,所以他们一定会有一个共同的基础对象模型,也可以看做就是我们所说的类。这个基础对象模型中定义了这一类模型都该拥有的属性和方法**,这样就不会需要每个实例对象去重复定义属性和方法。

原始类型和包装对象关系

原始类型(string/number/boolean) -> 包装对象:123 -> new Number(123)

包装对象 -> 原始类型(string/number/boolean):new Number(123) -> (new Number(123)).valueOf()

还记得我们上几个段落使用instanceof的时候,输出的那个现象么,这里再贴一下代码:

123 instanceof Number // false
new Number(123) instanceof Number // true
复制代码

当我们在创建包装对象的时候因为本质它生成的是一个对象,所以在使用typeof和instanceof检测的时候,都会和原始类型不同。作为对象进行比较的时候,也是遵循引用类型比较的原则,比较堆内存地址。放几个例子直观的感受一下:

typeof 123 // "number"
typeof new Number(123) // "object"
typeof Number(123) // "number"

123 instanceof Number // false
new Number(123) instanceof Number // true
Number(123) instanceof Number // false

123 === Number(123) // true
123 === new Number(123) // false
复制代码

如上例子在上面一连串的解释下来应该不难理解,其中有一点当我们不加new关键字调用包装对象创建对象时,我们发现它又不是个引用类型了。是这样的,当我们不使用new关键字调用包装对象创建时,会将传进来的value转换成包装对象对应的原始类型值,也就是可以当做是一个用来做类型转换的函数。

留一个特别的例子给大家🥊:

if(new Boolean(false)){
    alert('true!!');
}
复制代码

装箱/拆箱 @0720

上面说了通过JavaScript内置对象,让我们可以使用一些该类型对象公有的一些属性和方法。包括我们在使用字面量创建一些原始类型值后,会发现也可以使用该类型对应的内置对象上的方法和属性,这是怎么做到的呢?我们还原一下过程:

当我们在使用运算符计算时:

const a = new Number(1) // object
const b = a * 5
const c = a + 3
typeof b // "number"
typeof c // "number"
复制代码

如上例子,我们可以看到,在经过运算之后得到的变量值类型始终会是一个原始类型。这个现象我们称之为隐式转换,JS会检测运算符两侧变量值是否为原始类型,如果不是则会做一个引用类型(对象)自动转原始类型的操作,根据运算符和两侧变量值的不同类型,可能会有不同的转换场景(比如+运算有的场景转number有的场景转string)。

✅:这个隐式的将对象自动转为原始类型值的过程我们就称之为拆箱。

拆箱过程中会使用到的两个方法:

toString() :返回对象的string类型表示,在需要转字符串的场景优先使用。

valueOf():返回对象的原始类型(boolean/number/string)表示,在数值运算的场景优先使用。

null 和undefined既不是对象也没有对应包装对象,所以以上两个方法均没有。

一道有名的题
[] + [] // []
{} + {}
0 * []
[1, 2, 3] + [1, 2, 3]
复制代码

把变量当做对象访问属性或方法时:

const a = 123.342
a.toFixed(2) // "123.34"
const b = 'test'
b.chat(1) // "e"
复制代码

通过上个段落的类似分析,我们可以猜到,当我们将一个原始类型值作为对象访问其方法或属性时,会存在一个自动的隐式动作,而这个过程和拆箱不一样的是并不是直接将类型转换,而是以原始类型值对应的内置对象创建出一个对应的临时内置对象实例变量,使得临时变量作为原始类型值的替身,可以继承内置对象上的所有属性和方法。

// 以 a 举例,此时实际JS的内部操作是这样的
// 创造一个内置对象 Number 的实例对象
const a = new Number(123.342)
// num 和 Number的原型对象 就这样关联上了
a.__proto__ === Number.prototype
// 查看Number原型对象上有哪些属性和方法
Number.prototype
// 可以看到存在 toFixed 方法,即可以访问到 num.toFixed
num.toFixed(2)
复制代码

✅:这个隐式的自动将基本类型值和内置对象的原型对象做关联的过程就称之为装箱。

会发生隐式类型转换的场景,如何避免?

上个段落讲了运算和访问变量属性或方法时,会发生隐式类型转换的场景,这个段落再补充一下==的场景。

== 运算符用于比较两个变量是否相等,运算结果会返回一个布尔值,并且如果操作数不是同类型时,会发生强制的类型转换。下面列举一下双等号运算时的比较规则:

  • 如果两个操作数是同种基本类型

    • boolean:都为 true 或都为 false 时相等。
    • string:字符串排列顺序一致时相等。
    • number:数值相同时相等。
  • 如果两个操作数不同类型

    • 如果运算符两侧都是对象,则只有引用同一个对象才相等。
    • 如果操作数其中有一个为 null,则只有当另一个为 undefined 时才相等。
    • 会产生类型转换的以下情况
      • number 和 string:会将 string 强制转换为对应的 number 类型数值,再进行数值型的比较。
      • number 和 boolean:会将 boolean 变量转换为 number 类型数值(true = 1,false = 0),再进行数值型的比较。
      • number/string 和 object:对象类型会通过 valueOf 和 toString 转成对应基本类型后再进行比较(后续的比较依旧遵守上述的比较规则)。

✅:避免上述隐式类型转换我们一般是使用严格的全等符号进行比较 === or !==,全等比较会要求操作数类型和值都相等,不会有自动的类型转换。而其他场景也是一样的因地制宜,运算操作时尽量使用同类型且为适合运算的原始类型值(number/string/boolean)。

Symbol有应用场景么?

// todo

BigInt到底是解决了什么问题?@0718

// todo

Numeric Type Data 精度丢失相关

精度在哪里丢失了(整数/小数)

// todo

为什么浏览器要自动抹零

// todo

JavaScript 为什么要这么设计(即使这会造成精度丢失)

// todo

避免精度丢失的方法

// todo

💭:讲了数据类型,我上个段落的文字中总是提到 xxx型变量 这样的字眼,可能大多数人不会疑惑,但我在学习的时候因为下文会提到 原始类型值是不能被修改的 这一概念,我感到非常困惑 🤯

因为通常我们设置了一个数值型变量 var a = 1 ,然后可能通过一些逻辑噼里啪啦之后将其改为 10 之类的其他数值,这个操作是如此的常见,其他原始类型也是如此,我就非常不理解 原始类型值不能被修改 这句话。于是我挣扎了一会儿通透了🎉 ,才返过来补了这段内容以及理解的思路,可以直接跟我看下去。

变量类型是指变量的值的类型

JavaScript 是一种弱类型且动态的语言,弱类型是指不需要提前指定变量类型,js引擎在运算时会计算出变量是什么数据类型。动态是指同一个变量可以保存不同类型的数据。

有一个概念需要明确,当看到 var a = 1 时,通常会说 “我创建了一个数值型变量 a ” 。

var a = xxx 这个简单的赋值语句其实是三个步骤,

  1. 创建了一个值 xxx
  2. 创建了一个变量 a
  3. 让值和变量关联

其实更准确的说法是,我们创建了一个变量 a ,它的值是数值型。JavaScript 动态语言的特性,事实上并不存在变量类型的概念,我们在描述一个变量类型时,不是说这个变量就是xxx类型,而是这个变量在我们使用当下( js引擎在运算时会计算出值的数据类型 - 弱类型特性 )的值是xxx类型。

再看一个例子,这个例子清晰的说明了JavaScript动态性的特点。

var a = 1 // 数值1
console.log(a)
a = 10 // 数值10
console.log(a)
a = '一个装睡的人' // 字符串'一个装睡的人'
console.log(a)
复制代码

run code 输出如下结果:

1
10
一个装睡的人

[Done] exited with code=0 in 1.46 seconds
复制代码

那么当变量的值是原始类型和引用类型时,有什么不同的表现呢?

前面提到赋值语句的3个步骤,最后一个步骤 让值和变量关联 ,关联的方式有两种,存储值和存储引用,当代码运行后,每一个变量都会在栈内存中为它开辟一个内存空间,保存变量以及变量的值。变量就是一个用于标识的名称,而变量的值会根据不同值的数据类型有不同的存储表现。不同的表现我们从使用和存储两个角度分析,

不同的存储表现

原始类型的值本身就存储在栈内存中,以支持快速读取及回收清理内存释放。而引用类型的值是存储在堆内存中的,堆内存有更大的空间及保存周期更长,相较于栈内存它的读写速度也会慢一点,就很适合复杂类型的存储。

当遇到引用类型的值,值本身是存储在堆内存中的,堆内存会提供一个让外部访问到该值的内存地址( hashCode ),而我们在栈内存中,该变量的值上存储的就是这个堆内存给出的引用内存地址。

不同的使用表现

因为存储时的不同,在使用上自然也就有所不同了。

原始数据类型是按值访问的,因为变量的值是将数据的值直接存储在栈内存中的,我们在操作变量时,也就是直接在操作变量的值。

相对比,引用类型的变量的值因为根本没有存储在栈内存中,我们能直接访问的只是栈内存中存储的引用地址,而数据真正的值我们只能间接通过内存地址去堆内存中查询。所以我们在操作值为引用类型的变量时,本质只是在操作内存地址。

理解值类型和引用类型

  • 值类型和引用类型存储位置和方式不一样
  • 值类型和引用类型占用内存大小及周期不同
  • 值类型和引用类型在读写及赋值操作上会有不同的表现

讲到这里,我觉得我开头阐述的文字不太严谨,稍加修改 ✍🏻

变量在内存中如何存储(第2版)

JavaScript 内存空间分为两块 —— 栈内存和堆内存。

一般将值为原始数据类型的变量值直接存放在栈内存中(局部的、占用空间确定的数据、便于快速读写和内存释放),而对于值为引用数据类型的变量则是将值本身存放在堆内存中,对应会产生一个指向该值的堆内存地址(hashCode),栈内存中该变量的值则是存储了这个地址。当我们访问变量时,会先在栈内存中找到该变量存储的内存地址,再按照这个地址找到堆内存中对应的数据值。

📌 参考文章MDN >

那么内存是什么时候分配的

不管是什么程序语言,内存的生命周期基本是一致的:

  1. 分配所需要的内存(Allocate Memory)
  2. 使用分配到的内存进行读/写操作(Use Memory)
  3. 不需要时将分配到的内存释放/归还(Release Memory)

一般的低级语言(e.g. C 语言)内存分配是需要开发者去手动操作的。(ps:这句话抄的,我觉得都高级哈哈哈)而像 JavaScript 这种高级语言,内存分配的动作由 JS 底层完成。伴随着变量声明,底层会感知到此时需要分配内存,再根据变量的不同类型,会分配不同的内存大小。对于原始数据类型内存是静态的(一经创建不能被二次修改),大小一般是固定的,也相对较小,而对于引用类型内存则是动态的,随着变量的变化时实分配内存)。

📌 更多详细内容可以查阅:MDN 内存管理 >

这样有没有感觉稍微通透了一点点?再附一个小例子,加深理解

格局打开

变量的赋值操作在栈/堆内存中有不同表现

首先赋值分为两种形式:复制 和 引用

对于原始类型的值,赋值操作是在做值的复制,即复制一个值的副本出来。

对于引用类型的值,赋值操作是在做值的堆内存地址的引用,即将一个堆内存地址传递来传递去。

栈内存

var a = 10
var b = a // b是a的值的一个复本
b = 100 // 修改b的值

// a,b 属于原始数据类型,存放在栈内存中
console.log({
    a
}, {
    b
})
复制代码

运行结果如下:

栈内存

可以看到我们将 a 复制给了 b 后,当我们修改 b 的值,并没有对 a 造成影响。这是为什么呢?

正如我们上面所说的,原始数据类型是存放在栈内存中的,并且属性值就是原始值。**什么意思呢?**我们按行分析。

  • var a = 10

    声明了一个 number 变量 a ,JS 会给 a 分配内存(栈内存),并将 a 的原始值 10 存在分配的内存中。

    Variables(栈)Values
    a10
  • var b = a

    声明了一个变量 b , b = a 是一个引用赋值的操作,因为 a 是原始数据类型(number),对于原始数据类型变量,引用赋值操作是直接在内存中查找到变量值将其赋值给新声明变量的。最后就相当于 var b = 10 ,声明了一个 number 变量 b ,JS 会给 b 分配内存(栈内存),并将 b 的原始值 10 存在分配的内存中。

    Variables(栈)Values
    a10
    b10
  • b = 100

    这一步就是上述所说的内存生命周期中的第二步——对已分配的内存进行读写。JS 找到分配给 b 的内存并将数值 100 写入该内存中。

    Variables(栈)Values
    a10
    b10 100

✅:虽然是复制操作,但是对于原始数据类型相当于是写入一个值的操作,两个变量相互独立,互不影响。

堆内存

var personA = {
    name: 'Peter'
}
var personB = personA // personB是{ name:'Peter' }的一个引用
personB.name = 'Alice' // 修改personB的值

// personA, personB 属于引用数据类型,存放在堆内存中
console.log({
    personA
}, {
    personB
})
复制代码

运行结果如下:

堆内存

可以看到我们将 a 复制给了 b 后,当我们修改 b 时,对 a 也造成了影响。这是为什么呢?

正如我们上面所说的,引用类型的值是存放在堆内存中的,在栈中变量上的值存放的是该值对应的堆内存地址( hashCode )。什么意思呢?我们依然按行分析一下。

  • var personA = { name: 'Peter' }

    声明了一个变量 personA 值为 object 类型,JS 会先给对象 { name: 'Peter' } 分配内存(堆内存),给 personA 也分配内存(栈内存),将 { name: 'Peter' } 分配到的堆内存地址赋值给 personA 。

    这里只是为了表示栈内存堆内存大致对应关系,实际内存存储更为复杂(看了一些讲更底层的文章,我还没通透所以没能画出来🤦🏻‍♀️,想要了解细节的同学可以自行搜索)

    Variables(栈)Values
    personA0x167
    Variables(堆)Value
    0x167{ name: 'Peter' }
  • var personB = personA

    声明了一个变量 personB , personB = personA 因为 personA 是一个值为引用类型的变量,对于引用类型的值引用赋值操作是直接将被引用变量存储的堆内存地址复制传递给新声明的变量。(@0716 把”复制“改成了”传递“ 是不想和<原始类型值的复制>概念混淆)

    也就是将刚刚在 personA 存储的堆内存地址(假设为 0x167 )写入了给 personB 变量分配的栈内存中,即 var personB = 0x167 。

    Variables(栈)Values
    personA0x167
    personB0x167
    Variables(堆)Values
    0x167{ name: 'Peter' }
  • personB.name = 'Alice'

    查找 personB 存储的变量值,先在栈内存找到 personB 的值为堆内存地址 0x167 。而后从堆内存中找到 0x167 对应的数据值 {name: 'Peter'} ,修改其 name 属性为 Alice 。

    Variables(栈)Values
    personA0x167
    personB0x167
    Variables(堆)Values
    0x167{ name: 'Peter' 'Alice' }

    因为引用数据类型的变量间赋值操作相当于堆内存地址的传递,它们都指向同一个内存地址,所以任何一方对该地址代表的被引用值进行读写的话,其他引用方按照存储着的内存地址找到的值也都会是修改后的值。

✅:引用数据类型变量间的复制操作就是将内存地址传递给新声明的变量,变量间共享着同一个堆内存变量的引用,任何一方修改了该指向的变量,都会互相影响。

再啰嗦提一点,引用类型的变量持有的是被引用变量的堆内存地址,引用赋值的操作也只是修改变量所指向那个被引用对象,并不能直接修改被引用对象的值。

承接上面的例子:

var personA = {
    name: 'Peter'
}
var personB = personA // personB是{name:'Peter'}的一个引用
personB.name = 'Alice' // 修改personB的值

// personA,personB 属于引用数据类型,存放在堆内存中
console.log({
    personA
}, {
    personB
})

// 再进行一次引用赋值操作
personB = [1, 2, 3] // personB是[1,2,3]的一个引用

// 打印查看是否影响personA的值
console.log({
    personA
}, {
    personB
})
复制代码

可以看到,正如我们上面所说的,我们虽然修改了 personB 的指向,但是对 personA 不会有任何影响。

引用赋值无法修改被引用对象的值

✅:当我们进行复制操作时,根据不同的值的类型会有不同的情况。基本类型使用值复制,引用类型使用引用复制。

如何定义一个常量(无法被修改的变量ES5/ES6) @0718

// todo

const 不允许重复声明

const temp;
temp = 1;
// 以上代码会报错

// const 只支持一次声明并赋值
const temp = 1;
复制代码

发布第二天,早上刷手机,看到一道和变量存储相关的题,挂一下🤙🏻 @0716

var a = {
    n: 1
}
a.x = a = {
    n: 2
}
console.log(a.x)
console.log(a)
复制代码

// todo

写上面这道题的时候,我又突然想到一个类似的🤦🏻‍♀️, this 指向的问题,不知道有没有多大关系先挂一下 @0716

// todo

格局打开之后我们继续探索一下 🚀

为什么要将引用类型设计成存储在堆内存中呢?@0718

不知道大家好不好奇这个,我在学习的时候我是蛮好奇的所以特地去查了一下(杠精即视感🤳🏻)

为什么要设计堆内存和栈内存两个内存空间呢?

可以想象一下,如果只有一个栈内存,将所有数据存储在一个空间内,体积越来越大以后,对程序运行效率上必定会造成一些影响。

为什么将引用类型存储在堆内存中呢?

引用类型相对于原始类型来说是一种复杂类型,它可以随时随地被引用被修改,对于内存来说因为不知道它何时会被用到、会被改大改小,所以它们会占用比原始类型更多更长时间的内存,这样的占用对于需要提供运行效率的栈内存来说,是不划算的,所以将引用类型设计在了更大更远的堆内存中。

✅ 结论:

为了不影响运行效率,利用栈运算快速的优势,将较为简单且内存占用较小的原始数据类型存在了栈内存中,而较为复杂且内存占用较大的引用类型(随时可能会被修改或被使用、以及可以无限扩充)存放在了堆内存中,如果将复杂类型存放在了栈内存中,那频繁的操作对象则会影响栈运算的效率,得不偿失。📌 栈相关内容可参考这篇文章 >

说了半天的栈内存,那么引用类型在堆内存中到底是怎么存储的呢?@0718

本来这一段是写在了闭包的下面,但是在解释闭包的时候,发现理解中需要解释函数在内存中是如何存储的,函数是Object类型的实例,所以把这一段挪到这里解释了。

// todo

谈到堆内存又可以想到闭包,从内存的角度理解闭包 @0723

一问闭包是什么,大家都能说点 “闭包是持有对另一个函数内部作用域访问权限的函数” 这样类似的结论,我也一直这么记。但这次通过内存的角度又理解了一遍闭包,发现可能比直接从闭包产生的现象推出结论这种方式来的更好理解。

作用域 @0716

和闭包密不可分的一个概念就是作用域,我没记错的话,《你不知道的JavaScript》上对作用域的定义是 —— 作用域是一套根据名称查找变量的规则。用大白话解释就是当我们在使用变量时,是由作用域告诉我们是否有访问这个变量的权限。作用域又分为动态和静态两类,JavaScript 是静态作用域也就是我们常说的词法作用域,它是代码一书写就形成的(代码运行前)。不论代码噼里啪啦怎么个顺序执行,作用域始终是根据代码书写的顺序、书写的函数嵌套结构形成的,不会改变。

块级作用域

// todo

变量提升

// todo

执行栈和作用域链傻傻分不清楚 @0716

既然提到了词法作用域,咱们就再说下执行栈和作用域链的关系。执行栈是按照代码执行顺序,根据栈的特性,动态的将代码执行环境进行<入栈-执行-出栈>这样反复的动作。而作用域链的形成和执行顺序无关,是如上段文字说的,完全是按照词法形成的,在执行前编译时就已经确定了。在了解执行栈的概念之后(先进后出),可能很多人会误以为作用域链也是根据执行栈产生的,从栈顶往栈底查找(确实因为一些相对简单的代码结构,表现出了这种“作用域链和执行栈有关”的错觉)记住 并不是这样的!执行栈和作用域链的形成没有必然的联系,一个动态产生,一个静态产生。

再说回闭包,我们日常最最最最最最最常见的闭包出现形式就是把在一个函数内把另一个函数当做值返回。我都有底气不说🙊之一了!这绝对是出现频率最高的闭包形式了。

放个例子:

// 闭包 demo
function getData() {
    // 一个表示着不同逻辑的标志
    let data = null

    function customGetData() {
        if (data) {
            // A逻辑
            return Promise.resolve(data);
        }
        // B逻辑
        return fetch('xxx').then(res => data = res.json())
    }

    return customGetData
}

var fn = getData()

fn()
复制代码

我看了不少资料和文章,我一开始试图去归纳一下对于底层存储的解释,但是发现并不能以一种更容易理解的方式表达出来,其实这是在说明很多抽象的概念或底层设计原理时经常会碰到的感受,时常我们可能会有一种我其实明白了,但是写却写不明白。这也不要紧,这些能力会随着经验和锻炼一步步提升的。我不希望写出的文章是把一个本来复杂的事情解释的更复杂了,这样会增加阅读的人对这个知识的抗拒感,所以闭包内存模型的解释,我会以一种"意会"的形式去解释,可能不会讲到最底层,旨在让大家清楚闭包在使用时是如何被处理的(理解了这个也充分够用了

首先,我们分析下上述代码的含义,我们有一个getData函数,该函数是用来获取数据的。为了减少不必要的request发送,我们增加了一个逻辑判断,如果data已经获取过,那就不重复发送请求,于是就在函数内部定义了一个data变量,用来存储请求获取的数据。

好,这里有一个关键点是,这个data要能被保存住,这样我们第二次调用函数时,才能根据data判断。

那么data的声明位置可能会有两种情况,getData内部或getData外部。

分析一下这两种情况:

外部:data可能会被其他代码所修改

内部:每一次getData的执行完毕后getData执行占用的内存会被释放,那么getData中存储的变量也会相继被释放。下一次执行getData又是一个新的data

秉承保护data且避免定义全局变量的原则,我们选择data声明在getData内部。那么现在要解决一个问题,如何让一个变量在函数中保持内存占用不释放。

JavaScript就提供了我们一种方法叫做闭包,闭包就是让一个内部函数持有对外部函数的引用,并且将这个内部函数当做”值“传递给某个变量,这样该变量持有对这个内部函数的引用,这个内部函数持有对其词法环境外部函数的引用,这样连续的引用,会让被引用的环境在内存中多被持有一段时间(这句话有点太语义了)。本身引用的值就是因为不定大小、随时会被使用被修改所以设计在了堆内存中存储,并且也不像栈内存那样会立刻被释放。

进一步解释下上面这个说明的两个关键点:

1.让一个内部函数持有对外部函数的引用

2.将这个内部函数当做”值“传递给某个变量,让这个变量持有对内部函数的引用

第一步很好理解,就是两个函数嵌套,在内部的函数使用外部函数内定义的变量。

// 伪代码
function outerFunc() {
    // 外部函数
    let data = null
		function innerFunc() {
      // 内部函数
    }
}
复制代码

第二步,将这个内部函数当做”值“传递给某个变量,让这个变量持有对内部函数的引用。

// 伪代码
function outerFunc() {
    // 外部函数
    let data = 0
		function innerFunc() {
      // 内部函数
      data++
      console.log(data)
    }
  
  return innerFunc
}

// outerFunc 只会执行一次
const fn = outerFunc()
fn() // 1
fn() // 2
复制代码

这一步就是利用一个变量(fn)引用这个内部函数,实际上就是开拓了一个访问outerFunc内部变量的通道,而这个通道就是通过返回一个outerFunc的内部函数(innerFunc),并且这个内部函数引用着outerFunc的内部变量。

这样一来当我们使用该变量(fn)时,就会按照引用查找到堆内存中存储的内部函数(innerFunc),内部函数上又引用着其外部函数变量,会形成一个outerFunc的闭包clourse(outerFunc),闭包中会存储着被outerFunc内部函数引用着的outerFunc内部变量,这样就使得虽然outerFunc的执行上下文在执行完毕后会被释放,但是clourse(outerFunc)被存在了堆内存中,被innerFunc依赖着,当innerFunc执行时内部使用到data都可以顺着引用在clourse(outerFunc)找到。

函数是怎么经历执行的

每个函数被调用时,都会生成一个对应的函数执行上下文,上下文内会保存在该环境内创建的变量或传入的参数等等。而当我们在函数内部产生对外部变量的引用时,会根据词法环境的位置去找对应变量。这里就有个关键点,当在函数内部产生对外部的引用时, 我们还是去查找变量而使用的,并没有说将变量存储在当前函数中,这样才能保持外部对变量更新,内部的引用也能保持正确的变量值。当我们执行完某个函数时,它的执行环境上下文此时已经利用完毕,会被出栈回收,执行环境内存储的变量也都会被销毁。而闭包就是借由一个内部函数去持有对外部函数变量的引用,并将这个内部函数作为外部函数的返回值,这样可以使得将内部函数被其他变量引用。闭包生效的正确姿势一定是外部函数只会被调用一次(这一次的调用就是为了将内部变量和内部函数持有内部变量引用这层关系确立),并完成将内部函数传递给某个变量,后续的调用都是调用引用着内部函数的那个变量,这样我们借内部函数引用的内部变量才能被正确利用到。

说一说立即执行函数?(经常作为经典闭包出现在题目里)

// todo

JavaScript中如何实现一个私有属性的(闭包的应用场景1)

// todo

防抖和节流(闭包的应用场景2)

// todo

内存溢出和内存泄漏是一个概念么?(闭包使用过程中会遇到的问题)

// todo

我们总是会提到的垃圾回收是个啥东东

// todo

map写循环在里面改引用(突然想到CodeReview的时候经常会看到有小伙伴有这种写法)

// todo

JavaScript 对象的底层数据结构到底是什么

什么是数据结构

可参考文章:

📌 6 JavaScript data structures you must know > @Amanda Fawcett,以下图片均转载自该文章

📌 Data Structures in JavaScript > @Thon Ly

数据结构,是存储和组织数据的一种技术。数据结构决定了数据如何被收集,我们如何去访问它的函数和数据。数据结构贯穿近乎所有的计算机科学和编程领领域,我们组织数据的方式对性能和适用性有着很大的影响。

JavaScript有原始和非原始两类数据结构。原始数据结构和数据类型是编程语言所固有的,包括布尔值、数字、字符串、空值等。非原始数据结构不是编程语言所定义的而是编程人员定义的。

数据结构种类

  1. Array 数组

    Array
  2. Queues 队列

    img
  3. Linked List 链表

    img
  4. Tree 树

    img
  5. Graph 图表

    img
  6. Hash Tables(Map)

    img

    哈希表是一种复杂的数据结构,能够存储大量信息并有效地检索特定元素。这种数据结构依赖于键/值对的概念,其中“键”是搜索字符串,“值”是与该键配对的数据。

    ⭕️ 优点❌ 缺点
    键可以是任何形式,而数组的索引必须是整数。冲突:当两个键转换为相同的哈希码或两个哈希码指向相同的值时导致的错误。
    高效的搜索功能。这些错误可能很常见,通常需要对散列函数进行大修。
    每次搜索的操作次数恒定。
    插入或删除操作的恒定成本。

因为对象的结构是键值对之间的映射, key 可以是任意字符串, value 可以接受任意类型的值,使得对象自然而然的符合了哈希表的设计 —— 可以将键映射到值的数据结构。

// todo

const a = b = c @0722

幸福来得太突然,本文终于结束了 🎉

拿捏了.jpg

结束语

以上就是本文的全部内容,如果哪里不够通透,请务必评论我,加以修改!!另外有错误的/遗漏的也欢迎指出,一起探讨!!

感谢阅读 👏🏻 / 感谢阅读 🤙🏼 / 感谢阅读 👋🏽

https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/88bea55b0afb4347b9366e9c62696e58~tplv-k3u1fbpfcp-watermark.image.jpg
文章分类
前端
文章标签