Skip to content

你所不知道的JS

瑕不掩瑜的说,javascipt是一门充满bug的语言。这门语言千疮百孔,许多特性都基于历史遗留,需要反复去阅读尝试,才能理解所以然。读《你不知道的JS》后,我摘抄一些总结性的内容如下。其中包括了一些有用的知识点,一些有用的警告和避坑指南,另外还有一些没有什么卵用的语言特性或是bug。

类型

js中,任何一个变量都是没有类型的,只有才有。本质上,js作为一门弱类型语言,为实现了七种内置类型(注意,强调的是右,而非变量),其中包括5种基本类型,1个“对象”类型,和一个Symbol类型。五种基本类型的特性如下:

  • undefined类型。它的语义是“没有值”,undefined类型只有一个值,就是undefined

    测验有没有理解undefined的语义,请说出下面的js代码运行的结果:

    js
    let a;
    
    a;
    typeof a;
    b;
    typeof b;

    由于b没有被声明过,自然不存在于作用域,所以也就不可能持有值了,typeof b的结果自然是undefined

    重点在于,typeof b 没有报错,单纯的 b 引起了报错,因为它在作用域中没有被找到。所以如果要判断一个变量存不存在,就可以用前者,用后者是行不通的。

    js
    if (b) { ... } // 抛出error: b is not defined!
    if (typeof b !== "undefined") {...} // 能够判断b是否存在
    if (b !== "undefined") {...} // 该语句不能判断b是否存在,因为b可能存在但是没有值,即值为undefined;另外也可能不存在,不存在的时候会引发报错。

    作为对比,如果判断一个属性存不存在,却可以直接使用这个属性变量:

    js
    if(!myObject.b) {...}

    变量是否存在于作用域是引擎层面的课题,而判断属性是否存在于对象是代码层面的课题,前者比后者底层。

    由于全局对象的存在,这种判断方式也可以实现去判断一个变量有没有在全局作用域中声明过。但是ES6严格模式禁用了全局变量自动变为全局对象属性的行为,所以这种技巧理所应当被弃用掉。

    最后,想要得到一个 undefined 的值,可以使用配套的 void 运算符,它作用于任何值,

  • 空值null。它的语义是“有值,但值为空“。它本质上类似于 C++ 的空指针。

    js
    typeof null === "object"

    这是一个bug,原理是js内部检查一个值是不是对象的机制是判断它的二进制的前三位是不是0,而null的所有位都是0,所以很荒谬的被判为对象类型。

    正是由于这个bug的存在,我们可以利用它,判断一个值是否为null

    js
    (!a && typeof a === "object")

    只有为假值(因为显然的,真值必定不为空);并且利用上面的特性,null是唯一一个用typeof返回为object的假值。

  • 数字number类型在js中被实现为IEEE754标准,更准确来讲是double类型。(64位二进制。)

    这导致它出现了和c++同理的无法直接比较两个小数是否相等的繁琐问题。解决方法是设置一个误差范围值,ES6提供了一个机器精度值Number.EPSILON

    这个实现还让它拥有了两个0值,即+0-0,这是很诡异的东西,基本用不到,也对日常编程没有影响。(或许只是坑比较罕见)

    如果超过IEEE能够表示的最大范围,js提供了两个值Infinity-Infinity

    最后,js自带了一个NaN,它的语义是”无效数值“。相对应的,有一个全局函数isNaN(),但它的语义不是判断一个变量是否是NaN,而是判断一个变量是否不是合法数字

    js
    var a = NaN
    var b = "foo"
    isNaN(a) //true
    isNaN(b) //true
    Object.is(a, b) // false

    这种语义容易误导人,我们大多人一般都误以为isNaN是用来判断一个变量是否是NaN。所以,弃用这个函数是最佳选择

    取而代之的,ES6提供了Number.isNaN,它的语义就是单纯的判断一个变量是否为NaN

    js
    var a = NaN
    var b = "foo"
    Number.isNaN(a) //true
    Number.isNaN(b) //false!
    Object.is(a, b) // flase
  • 字符串string类型作为一个非常像类数组(也就是含有length属性)的东西,表现出很多和数组相似的行为。

    js字符串类型是不可变的,对它的修改操作函数总是返回一个新串。而且,js的字符串没有反转方法,一般的解决方案是把字符串转换为字符数组处理后再转回来:

    js
    var newString = oldString.split("").reverse().join("");

    js字符串由于类数组的特性,所以可以借用Array.proptotype.join等方法,因为它们的原理只要求对象含有length属性:

    js
    Array.proptotype.join.call("foo", "-") // "f-o-o"
  • boolean类型,值只有两个,trueflase.

作用域

js原生的作用域是函数作用域,这有些违背直觉,对学过其他编程语言的人来说难以理解。而let声明可以实现块级作用域,它等同于c++等语言中的作用域。我们不能说var关键字是不好的,它只是一种作用域的实现方式罢了.只不过,var相对于let而言会出现很多类似于变量提升等让人捉摸不透的行为,所以我更倾向于继承之前的语言风格,全部使用let而禁用var

记录一次于GPT的对话,可以说它已经非常厉害了,解释的非常详细。

q: 为什么有些人说,var声明在js引擎运行前被编译器处理?

A: 有些人之所以说“var声明在js引擎运行前被编译器处理”,是因为JavaScript引擎在执行代码之前,会进行一个预处理的阶段,称为“变量提升”(hoisting)。变量提升的过程是编译器处理的,这意味着在代码实际执行之前,JavaScript引擎会对所有的var声明进行预处理。

变量提升的过程包括以下两个步骤:

变量声明:编译器在作用域内查找所有var声明,并将它们添加到当前作用域的变量对象。 变量初始化:在代码执行阶段,引擎按照代码中的顺序初始化变量。在此阶段,变量值被设置为undefined,直到遇到实际的赋值语句。

q:那么这种运行前的“变量提升”无法处理eval()函数中的var声明,对吗?

A: eval()函数中的var声明不会在运行前经历普通的变量提升过程。这是因为eval()是在运行时动态执行代码的,它的内容在编译阶段是未知的。eval()函数的作用是将传入的字符串作为JavaScript代码进行解析和执行。这意味着,在eval()函数被调用之前,引擎无法预先知道将要执行的代码。因此,eval()函数内部的var声明不会在编译阶段被处理,而是在实际执行eval()函数时动态处理。

q:在执行eval函数的时候,js引擎也会对传入的字符串执行类似于运行前的变量提升的行为吗?

A:是的,在执行eval()函数时,JavaScript引擎会对传入的字符串执行类似于运行前的变量提升行为。实际上,eval()函数内部的代码将被当作一个新的代码块来处理,因此在执行它时会有一个独立的编译和执行阶段。 在eval()内部,变量提升的过程与普通的JavaScript代码类似。eval()函数接收到一个字符串作为参数时,它首先将字符串解析为JavaScript代码。然后,引擎会在这个代码块中查找所有的var声明,并将它们添加到当前作用域。接下来,引擎会逐行执行eval()中的代码,按照顺序初始化变量。 需要注意的是,虽然eval()函数内部的变量提升过程与普通的JavaScript代码相似,但它是在运行时处理的,而非运行前。这意味着在eval()执行之前,它内部的var声明不会影响到外部作用域。

q:说一说let关键字的原理?

A:当使用 let 声明一个变量时,JavaScript 引擎会在当前作用域中创建一个新的绑定,并将其初始化为 undefined。这个绑定只在当前作用域中可见,而不是像 var 一样在整个函数作用域中可见。 在 JavaScript 引擎内部,let 声明实际上是通过创建一个新的词法环境来实现的。词法环境是一个存储变量和函数的映射表,它与当前执行上下文相关联。当 JavaScript 引擎遇到一个 let 声明时,它会在当前词法环境中创建一个新的绑定,并将其初始化为 undefined。这个绑定只在当前词法环境中可见,而不是在外部词法环境中可见。 当代码执行到 let 声明所在的作用域结束时,JavaScript 引擎会销毁该作用域中的词法环境,从而销毁所有在该作用域中创建的绑定。这样可以避免变量泄漏和内存泄漏的问题。 总之,这个词法环境只在当前作用域中可见,从而实现了块级作用域。

对象

对象是JavaScript的基础。知识点非常多。

内置属性[[class]]

typeofObject.prototype.toString.call都可以检测类型。typeof作为一个内置运算符,返回字符串,其内部实现取决于使用的js引擎,所以typeof的结果只能是js内置的那7种类型。而且实现上有一些历史遗留问题(比如typeof null为对象)。而Object.prototype.toString.call的原理则是通过检测对象内置隐藏属性[[class]]来实现的,所以类型更加具体。

就应用来说,typeof 主要用于判断内置类型,而Object.prototype.toString.call一般用于判断对象子类型,即是否为数组函数具体类型。当然,用它来判断内置类型也是可行的,因为内置类型会被临时封装为内置对象。

引用

与基本类型不同,js的对象采用引用类型,而不是单纯的值。指向对象的变量实际上将持有,而非实际的。这在实际使用中基本上区分不开,因为js没有像C++一样,显式的为引用提供诸如&*之类的符号。

由于引用特性,所以深度拷贝需要借助一定的技巧和手段,同时要避免循环引用

js
// 安全深度拷贝模板,实现了忽略对象内部的循环引用
let deepCopy = (o) => {
    let cache = [];
    let str = JSON.stringify(o, function (key, value) {
        if (typeof value === 'object' && value !== null) {
            if (cache.indexOf(value) !== -1) {
                // 移除
                return;
            }
            // 收集所有的值
            cache.push(value);
        }
        return value;
    });
    cache = null; // 清空变量,便于垃圾回收机制回收
    return JSON.parse(str)
}

// 测试对象之间循环引用
let obj = { key: 2 }
let objTwins = { key: 5 }
obj.child = objTwins;
objTwins.child = obj;

console.log(deepCopy(obj))

内置对象

js从不是一门纯对象语言,但是一种广泛流传的错误说法就是,js中一切都是对象。这一切都是内置对象引起的,它允许我们可以像操作对象一样,使用number,string,boolean这类基本类型。:

js
"foo".length // 3, 需要注意'foo'原生字符串不是对象

这是因为js会尝试把基本类型转换为对应的内置对象。5种基本类型都拥有自己对应的内置对象。

但是 nullundefined 没有,尽管他们能够被 Object.prototype.toString.call 特判处理:

js
Object.prototype.toString.call( null ) // [Object Null]
Object.prototype.toString.call( undefined )

调用Object.prototype.toString.call( null )null 作为其参数,虽然无法获取其 [[class]] 属性,但是进行了特判发现它是 null 值,故直接返回了 [Object Null]undefined 同理。

虽然他们都有内置对象,但这些内置对象的原型,不一定有构造函数(constructor)。具体来说,nullundefined并没有对应的构造函数。注意,null 没有内置对象,不要误以为 null 的内置对象就是顶层空对象。

js
console.log(Object.prototype.toString.call(null)) // [Object NUll]
console.log(Object.prototype.toString.call(Object.create(null))) // [Object Object]

他们是不一样的。

全局对象

考虑下面的语句:

js
// ...上下文环境中未曾声明过num...
console.log(num) // error: num is not defined
console.log(num = 1) // 1

直接使用未声明的变量会报错,而直接对未声明的变量赋值却是合法的。

实际上,这两种行为,分别对应了两种作用域内查询方式。

对于赋值操作,首先,js引擎会尝试在作用域链上对num进行LHS查询,如果没有找到num,它就会会自动创建一个全局变量(非严格模式下),并将值赋给该变量。这个变量实际上是全局对象上的一个属性。

而对于直接取值的操作,js引擎进行 RHS 查询,如果没有找到num,则会抛出error

问了问 GPT 为什么要这样设计,它的回答如下:

JavaScript 之所以会这样设计,是因为在早期的 JavaScript 中,为了方便开发者,设计者并没有要求在使用变量之前必须先声明,这样开发者可以直接使用未声明的变量,从而减少了代码量和开发难度。但是这样的设计也带来了一些问题,比如可能会存在声明冲突和变量泄漏等问题。为了解决这些问题,ES5 引入了严格模式,强制要求在使用变量之前必须先声明,避免了可能出现的变量泄漏和命名冲突等问题。为了避免这些问题,我们应该始终使用 var、let 或 const 关键字声明变量,并在需要的作用域内进行赋值。

组成

对象的组成主要是可访问的普通属性,属性描述符,访问描述符,和无法访问的内置隐藏属性和方法。

  • 普通属性中值得一提的是函数和方法。实际上这两者没有本质的区别,它们就是一个东西:当一个函数被对象所用时,它被称为方法。
  • 属性描述符即writable,enumerbale,configurable
  • 访问描述符即gettersetter
  • 内置隐藏属性和方法不能通过访问对象的方式直接访问到,这些属性是 JavaScript 引擎内部使用的,而不是提供给开发者使用的标准属性。常见的内部属性是[[class]],内部方法是[[get]][[put]]

this

this的准确定义什么?

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文),这个记录会包括函数在哪里被调用(调用栈),函数的调用方式,传入的参数等信息。this就是这个记录的一个属性,会在函数执行的过程中用到。

这是我摘录的最贴切的解释。

在书中,作者认为this和箭头混用是一种不良的编程风格。因为他要求编程者对于js函数作用域this上下文概念非常清晰,才不至于混淆。此外,箭头函数自带的匿名特性也让出错的几率提升了。

对象原型

js天然的实现了原型设计模式

js中不存在类。但是js提供了语法糖和很多“类”库,让我们误以为js中存在类的概念。在js中,“类”被实现为一种设计模式,而非一种编程范式。所以,一些在类理论(面向对象范式)中强调的继承多态,并不能在js中使用,而且强行去使用它们反而会恰如其反,降低代码的健壮性和可维护性。

摘抄

  • 能使用=====尽量不要使用ES6新引入的Object.is(),因为它的效率很低。

  • 使用for..in遍历对象中的属性,不同的js引擎可能会得到不同的结果。这是因为JavaScript规范并没有规定对象属性的顺序,因此不同的引擎可以自由地选择它们认为最有效的顺序。

  • null表示一个空对象指针,而undefined表示一个未定义的值。

  • 开放封闭原则:对扩展开放,对修改封闭。尽可能的去把握住粒度,才能重构出优良的代码。

  • 设计模式强调的是特定上下文中的针对性解决方案,一般涉及具体的实现细节,依附于某一种范式(或者编程语言),而范式则是程序设计的通用指导思想,与具体语言和技术相对独立。

  • 虽然js引擎内部的许多函数和对象都是由C++实现的,但是实际上也有一些js对象和方法是由js本身编写的,并且它们被称为原生js对象和方法。js的原生对象包括全局对象、函数、数组、日期等,这些对象都是由js代码实现的,而不是由C++实现。例如,全局对象是一个包含许多内置函数、对象和值的对象,它的实现采用了js代码。

  • 简明胜于晦涩。