【读书笔记】你不知道的JS上卷

843 阅读32分钟

前言

  • 本文仅为笔者的读书笔记,加入笔者自己理解的大纲提炼,若有错误恳请指出。系统学习推荐阅读原书。
  • 推荐指数: 🌟🌟🌟🌟🌟

JavaScript非常特殊,只学习一小部分的话非常简单,但是想要完整的学习会很难(就算学到够用也不容易)。当开发者感到迷惑时,他们通常会责怪语言本身,而不是怪自己对语言缺乏了解。希望你能打心眼里欣赏这门语言。


思维导图

第一部分 | 作用域和闭包

第一章 | 作用域是什么

几乎所有编程语言的基本功能之一就是存储变量(写操作)和访问变量(读操作),而这一套规则被称为作用域

1.1 编译原理

尽管通常会将JavaScritp归类为“动态”或者”解释执行“语言,但实际上也是存在编译过程,但与传统编译语言不同,它不是提前编译,编译结果也不能在分布式系统中进行移植

  • 分词/词法分析(Tokening/Lexing)

    var = 2;会被分解成以下词法单元:var、a、=、2、;。分词/词法分析之间区别:有无状态(疑问)

  • 解析/语法分析(Parsing)

    这个过程将词法单元流(数组)转换成一个元素逐级嵌套所组成的代表了程序语法结构的树,也就是抽象语法树(AST)

  • 代码生成

    将AST转换为可执行代码(机器指令,v8通过JIT编译为字节码)的过程。

以上为宏观简单的介绍。JavaScript引擎没有大量时间做优化,大部分发生在代码执行前几微秒。

1.2 理解作用域
  • 定义:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套严格的规则,确定当前执行代码的访问权限。(我的理解:有访问权限的嵌套式变量集合)。

  • 引擎分为编译执行两个部分

    • 编译过程:创建作用域。如处理var a = 2这段程序的编译阶段,会首先作用域集合中查找是否存在同名变量,有则忽略,没有则在当前作用域添加。
    • 执行过程:遇到变量时会查找作用域。分为LHS(左侧查找)和RHS(右侧查找)。LHS是准备对变量进行写操作的查找。RHS是准备对变量进行读操作的查找。在处理var a = 2执行阶段,会进行左侧查找,并赋值。
  • 作用域嵌套

    向上查找过程(LHS和RHS都会向上查找)

    • LHS会在未找到变量时(非严格模式下)隐式的创建一个全局变量(全局变量泄漏)
    • RHS在未找到变量时抛出错误ReferenceError

第二章 | 词法作用域

我们将作用域定义为一套规则,管理引擎如何在执行栈中根据标识符进行变量查找。

作用域主要有两种工作模式词法作用域(被大多数编程语言采用,包括JavaScript)和动态作用域(如Bash脚本,这里不展开)

2.1 词法阶段(编译第一阶段)

大部分标准语言编译器的第一个工作叫做词法化。那么在这个阶段中创建的作用域就叫词法作用域,也就是JavaScript的作用域。

  • 词法作用域只会查找一级标识符,如代码中引用了foo.bar.baz,词法作用域只会试图查找foo标识符,在找到这个变量后,对象属性访问规则会分别接管对bar和baz属性的访问
2.2 欺骗词法
  • eval()在非严格模式下解析的代码会影响当前作用域(严格模式下有自己的独立词法作用域)

    function foo(str, a) {
      eval(str);
      console.log(a, b)
    }
    var b = 2
    foo("var b = 3", 1); // 1, 3
    
  • with()

  • 性能: JavaScript引擎在编译阶段会进行数项的性能优化,部分优化依赖于词法的静态分析,预先确定所有变量和函数的定义位置,才能在执行中快速找到标识符。欺骗词法使词法作用域动态化,造成优化失效,运行变慢

2.3 小结
  • 编译的词法阶段基本能够知道全部标识符在哪儿以及如何声明, 从而优化在执行过程中的查找过程。而欺骗词法会扰乱该过程

第三章 | 函数作用域和块作用域

3.1 函数作用域定义
  • 属于这个函数的全部变量(词法作用域,参数,this)的集合
3.2 隐藏一段代码
  • 当我们想要执行一段代码又不想它污染全局作用域(这会有以下好处)
    • 最小暴露原则
    • 规避冲突(命名空间 模块管理)
3.3 如何去实现隐藏
  • 声明函数执行方式

    function foo() {
      var a = 2
      console.log(a)
    }
    foo()
    

    缺陷:1. foo会污染全局变量 2.需要显式调用

  • 立即执行函数表达式 IIEF (Immediately Invoked Function Expression)

    var a = 2
    (function IIFE(){
      var a = 3
      console.log(a)// 3
    })()
    console.log(a) // 
    
3.4 块作用域

JavaScript中的块作用域不如其他语言那么常规化(try/catch with()),直到ES6引入了let ,let关键字会将其声明的变量绑定到当前所在块作用域中(一个新的作用域,不是当前的函数作用域也不是全局作用域)。

  • 垃圾收集

  • let循环(每次迭代都是重新绑定)

  • const也能创建块作用域变量


第四章 | 提升

  • 变量声明提升 赋值按代码顺序执行

    a = 2 // 不会报错 ReferenceError: a is not difined
    console.log(a) // 2
    var a
    
  • 函数声明会被提升

  • 函数表达式的标识符会被提升

    foo()
    var foo = function	() {
      console.log(1)
    }
    // TypeError 而不是 ReferenceError
    

第五章 | 闭包

5.1 定义与作用
  • 定义:闭包,也称词法闭包或函数闭包,定义大概分为两种

    • 函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。 (MDN)

    • 闭包是在其词法上下文中引用了自由变量(函数作用域以外的变量)的函数。(WIKI)

  • 作用(目的)

    • 打包函数作用域
    • 延迟执行
  • 代码中理解

    function foo () {
      var a = 2
      function bar () {
        console.log(a)
    	}
      bar()
    }
    foo()
    

    上面这段代码是闭包吗?技术的角度上来说是。但是我们无法直接观察,也没有达到我们使用闭包的目的,再看下面一段

    function foo () {
      var a = 2
      function bar () {
        console.log(a)
      }
      return bar
    }
    var baz = foo()
    baz()  // 2
    

    这段代码就很好的实现了我们的目的(打包上下文,延迟执行)

5.2 实现闭包

内部定义函数传递到词法作用域以外,都会保持对原始定义作用域的引用,无论何处执行该函数都会使用闭包,比如在以下代码段中将baz函数作参数传递到词法作用域以外

function foo () {
  var a = 2
  function baz () {
    console.log(a)
	}
  bar(baz)
}
function bar(fn) {
  fn() // 2
}
foo()
5.3 应用

明白其作用和使用方式后,回到你写过的代码中其实到处都是闭包的身影

function wait (message) {
  setTimeout( function timer () {
    console.log(message)
  }, 1000)
}
wait("hello, closure")
5.4 闭包与循环

常见的例子

for (var i = 0; i < 5; i++) {
  setTimeout(function timer(){
    console.log(i)
  }, 0)
}
// 输出5个 5

表示在下一个事件循环中访问外部作用域(闭包)i的值,此时循环已经完成,所以i的值为5

for (let i = 0; i < 5; i++) {
  setTimeout(function timer(){
    console.log(i)
  }, 0)
}
// 0 1 2 3 4

let关键字在每一次迭代中会重新声明一次(let在for头部中,会创建一个隐藏作用域),创建一个新的作用域 劫持对i的访问,所以每一个timer访问的作用域是不同的

如何理解let的块作用域?

词法分析阶段var 声明的变量会做声明提前,let, const 声明的变量会被放入暂时死区TDZ(Temporal Dead Zone),不可访问(提前访问),在执行后声明后才可被访问。正常来讲对变量的访问会基于词法作用域规则,let关键字声明的变量会劫持块作用域中对该变量的访问

// TDZ演示
{
  console.log(a) // ReferenceError
  let a
}
// 劫持块作用域演示
var a = 3
{
  console.log(a) // ReferenceError
  let a
}
5.5 模块

闭包最强大的应用之一

// 经典模块实现
function CoolModule () {
  var something = "cool"
  var anothor = [1, 2, 3]
  
  function doSomething(){
    console.log(something)
  }
  
  function doAnothor() {
    console.log(anothor.join("!"))
  }
  return {
    doSomething: doSomething,
    doAnothor: doAnothor
  }
}
var foo = CoolModule()
foo.doSomething()
foo.doAnothor()
  • 这种模式在Javascript中被称为模块。我们保持了内部变量的私有状态,并暴露出了公共API(实际上CommonJS就是这么做的,对于每个js文件做函数包裹)。

  • 当然模块中返回对象不是必须的,也可以返回一个内部函数(函数也是对象)。实际上大多数第三方包都是返回的函数,比如lodash,JQuery。

  • 那么我们可以总结出模块模式需要的两个必要条件:

    • 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
    • 封闭函数必须返回至少一个内部函数。

当只需要一个实例的时候,可以简单改进为单例模式(保证自定义属性的修改源唯一)

var foo = (function CoolModule() {
    var something = "cool"
    var anothor = [1, 2, 3]

    function doSomething() {
        console.log(something)
    }

    function doAnothor() {
        console.log(anothor.join("!"))
    }
    return {
        doSomething: doSomething,
        doAnothor: doAnothor
    }
})()
foo.doSomething()
foo.doAnothor()

我们将模块函数转换成IIFE,并将返回值赋值给foo

模式模块另一个简单强大的用法是命名将要作为公共API返回的对象,并可以在运行时修改API

var foo = (function CoolModule (id) {
  function change () {
    // 修改公共API
    publicAPI.identify = identify2
  }
  function identify1 () {
    console.log(id)
  }
  function identify2 () {
    console.log(id.toUpperCase())
  }
  var publicAPI = {
    change: change,
    identify: identify1
  }
})('foo module')

foo.indentify() // foo module
foo.change()
foo.indentify() // FOO MODULE

现有模块机制(基于函数包裹)

npm包管理简单实现

var myNpm = (function manage () {
    var modules = {}
    function define (name, deps, impl) {
        for(let i = 0; i < deps.length; i++) {
            deps[i] = modules[deps[i]]
        }
        modules[name] = impl(...deps)

    }
    function get (name) {
        return modules[name]
    }
    return {
        modules,
        define,
        get
    }
})()

var nameModule = function () {
    function getName () {
        console.log('nameModule: getName function id called')
        return 'whs'
    }
    return {
        getName
    }
}

var helloModule = function (nameModule) {
    var name = nameModule.getName()
    function sayHello () {
        console.log('helloModule: sayHello function is called')
        console.log(`hello, ${name}`)
    }
    return {
        sayHello
    }
}

myNpm.define('nameModule', [], nameModule)
myNpm.define('helloModule', ['nameModule'], helloModule)

var helloGet = myNpm.get('helloModule')
helloGet.sayHello()

ES6模块机制

  • ES6的模块是一种全新的机制,必须被定义在独立的文件,宿主环境提供一个“模块加载器”(这里不深入讨论)。可以在编译阶段去静态检测导入模块API成员的引用是否存在(API确定,无法动态改变)

第二部分 | this和对象原型

第一章 | 关于this

this不是执行上下文!!!

1.1 为什么需要this
function foo () {
  console.log(this.name)
}
var boy = {
  name: "Neil"
}
foo.call(boy) // Neil

如果没有this该如何实现

function foo (context) {
  console.log(context.name)
}
var boy = {
  name: "Neil"
}
foo(boy)

需要显式的去传递。随着应用复杂,代码会变得越来越混乱

1.2 this到底指向哪儿

观察下面这个断点

可以看到这个断点下的执行上下文(execution contexts)分为Local和Global,Local下的this对象就是boy对象。

没错,this就是函数被调用时绑定的一个可访问(读写能力)的对象

这里我想简单讨论下执行上下文(参考ES6规范)

An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation. At any point in time, there is at most one execution context that is actually executing code. This is known as the running execution context. A stack is used to track execution contexts. The running execution context is always the top element of this stack. A new execution context is created whenever control is transferred from the executable code associated with the currently running execution context to executable code that is not associated with that execution context. The newly created execution context is pushed onto the stack and becomes the running execution context.

JavaScript的执行基于执行栈,执行上下文是一种追踪执行时代码(track the runtime evaluation of code)的实现。执行上下文的追踪过程是基于栈结构。

那么执行上下文的组成包括通用部分(其他基于调用栈的语言也有)

  • code evaluation state
  • Function
  • Realm

以及ES特有的部分

初始化时词法环境(LexicalEnvirontment)和变量环境(VariableEnvirontment)是一样的,执行过程中,this绑定会被添加到变量环境。(在ES5规范中thisBinding是被单独提出来的一部分)。有兴趣的同学可以去看下规范。

第二章 | this全面解析

上文我们明白了this的绑定取决于函数的调用栈(也就是函数的调用方式)

2.1 先理解调用栈

调用栈是为了到达当前执行位置所调用的所有函数

可以看到是函数调用的层层堆叠,foo执行调用bar,bar执行调用baz(理解js执行是基于调用栈的)

2.2 绑定规则(四种)
默认绑定
function foo () {
  console.log(this.a)
}
var a = 2
foo() // 非严格模式下this指向全局对象,输出2 严格模式下this为undifined,报错TypeError
隐式绑定

通过对象属性访问调用,与函数在何处定义无关

function foo () {
  console.log(this.a)
}
var obj = {
  a: 2,
  foo: foo
}
obj.foo() // 2

可以看到隐式绑定在this中加入了a变量

那么obj.obj.func的函数调用呢?

function foo () {
  console.log(this.a)
}
var obj2 = {
  a: 5,
  foo: foo
}
var obj1 = {
  a: 1,
  obj2: obj2
}
obj1.obj2.foo()  // 5

可以看到输出的是obj2的a的属性值,就近原则

隐式绑定丢失问题

  • 引用赋值this丢失
function foo () {
  console.log(this.a)
}
var obj = {
  a:2,
  foo: foo
}
var bar = obj.foo
var a = "opps, global"
bar() // 浏览器环境下"opps, global" node环境下undifined

仔细品一下,bar其实是obj.foo的一个引用,调用bar等于直接调用foo。(bar其实和foo一样都是保存的函数在堆内存中的地址,只是标识符不同)

  • 异步回调中this丢失
function foo () {
  console.log(this.a)
}
var obj = {
  a: 2,
  foo: foo
}
var a = "oops, global"

setTimeout(obj.foo, 100) // 浏览器环境下"oops, global" node环境下undifined

当异步任务完成,从任务队列中取出压入执行栈的时候,当时的执行上下文已经销毁,只剩全局作用域(静态)。总之就是丢失了!

这里我还想讨论一下什么是回调函数

引用stack overflow大神定义

A "callback" is any function that is called by another function which takes the first function as a parameter. (在一个函数中把另外一个函数作为参数调用)

function greeting (name, callback) {
  callback(name)
  console.log("greeting called")
}

function callback (name) {
  console.log(`hello ${name}`)
}
greeting("Neil", callback)
// greeting called
// hello Neil

// 通常我们更习惯使用行内匿名函数
greeting("Neil", function(name) {
  console.log(`hello ${name}`)
})
// greeting called
// hello Neil

你会注意到greeting called先被打印,因为在某些地方会被回调描述为父函数(greeting)执行完成后再执行子函数(callback)。但其实回调只是如大神的描述一般,不存在执行顺序的改变,只是一种将函数作为参数的使用方法。

你可能也想到了,上面代码还有一种更加常规的实现方式,结果没有任何区别:直接调用

function greeting(name) {
  console.log("greeting called")
  callback(name)
}

function callback(name) {
  console.log(`hello ${name}`)
}

greeting("Neil")
// greeting called
// hello Neil

但是同步回调可以实现了对既有函数的自定义扩展如Array.prototype.forEach(func)

异步回调才是经典用法(个人感觉异步设计是js迅速发展壮大的重要原因)

function foo () {
  setTimeout(function(){
    console.log("异步回调函数 is called")
  },100)
  console.log("foo is called")
}
foo()
// foo is called
// 异步回调函数 is called

非阻塞模式,非常棒!

显式绑定

call() apply()这两个定义在函数原型上的方法让函数调用非常灵活,可以在执行时动态修改this对象,而且显式绑定让代码逻辑更加清晰。(具体定义和用法请查看MDN)

显式绑定可以解决第一种赋值引用丢失的情况

function foo () {
  console.log(this.a)
}
var obj = {
  a:2,
  foo: foo
}
var bar = obj.foo
var a = "opps, global"
bar.call(obj) // 2

却无法解决异步回调中的this丢失。因为异步回调需要传入一个函数,call和apply都是立即执行的绑定

硬绑定(显式变种)

从显式绑定思考如何能将一个绑定了this函数延迟执行

function foo () {
  console.log(this.a)
}
var obj1 = {
  a: 1
}

var obj2 = {
  a: 2
}
// 包裹起来
var bar = function() {
  foo.call(obj1)
}

bar() // 1

setTimeout(bar, 100)

bar.call(obj2) // 1 硬绑定后的函数无法改变this

没错,用函数包裹起来,无法再改变this的值我们称为硬绑定

完整对一个函数执行硬绑定包装

function foo (something) {
  console.log(this.a, something)
  return this.a + something
}

var obj = {
  a: 2
}

function bar () {
  return foo.apply(obj, arguments)
}

var result = bar(5) // 2 5
console.log(result) // 7

包装过程函数化

function foo (something) {
  console.log(this.a, something)
  return this.a + something
}

function bind (func, obj) {
  return function () {
    return func.apply(obj, arguments)
  }
}

var obj = {
  a: 2
}

var fooBind = bind(foo, obj)

var result = fooBind(5) // 2 5

console.log(result) // 7

ok,我们实现了简单版的bind()函数

  • 第四条绑定规则 | new绑定

    讲解之前我们需要澄清一个非常常见的关于JavaScript中关于函数和对象的误解

    在传统面向类的语言中,“构造函数”是类的一种特殊方法,使用new初始化类的时候会调用类的构造函数。通常形式是这样

    something = new MyClass(...);
    

    JavaScript也有一个new操作符,使用方法看起来也和那些面向类的语言一样,但是机制却大相径庭。

    首先我们重新定义一下“构造函数”,在JavaScript中,构造函数只是一类使用new操作符时被调用的函数。它们不属于某个类(js中也没有类),也不会实例化一个类,甚至都不是一种特殊的函数,只是被new调用的普通函数,所以其实并不存在所谓的“构造函数”,只有对于函数的“构造调用”

    当我们使用new的时候会执行以下操作

    1. 构造以恶搞全新的对象
    2. 对象会被执行[[prototype]]连接
    3. 新对象会绑定到函数调用的this
    4. 如果函数没有返回其他对象,那么news表达式中的函数调用会自动返回这个新对象
2.3 优先级

显式 > 隐式

function foo () {
  console.log(this.a)
}

var obj1 = {
  a: 2,
  foo: foo
}

var obj2 = {
  a: 3,
  foo: foo
}

obj1.foo() // 2
obj2.foo() // 3
obj1.foo.call(obj2) // 3
obj2.foo.call(obj1) //2

new > 硬绑定(bind)

function foo (num) {
  this.a = num
}

var obj = {}

var bar = foo.bind(obj)

bar(2)
console.log(obj.a) // 2

var baz = new foo(3)
console.log(obj.a) // 2
console.log(baz.a) // 3

按照理论硬绑定函数的this不会改变,但是new确实修改了this绑定。

实际上在es5内置的Function.prototype.bind()更加复杂,会有一段代码去判断硬绑定函数是否被new调用,如果是的话就会使用新创建的this替换硬绑定的this。

之所以要在new中使用硬绑定函数,主要是为了能实现预先设置函数的一些参数,再执行new操作,实现参数复用(bind函数内实现了柯里化)。

function add (num1, num2) {
return num1 + num2
}
var bar = add.bind(null, 3)
var baz = bar(2)
console.log(baz)
柯里化

这里我想对柯里化做一下探究

  • 定义

    currying is the technique of translating the evaluation of a function that takes multiple arguments into evaluating a sequence of functions, each with a single argument. For example, a function that takes two arguments, one from X and one from Y, and produces outputs in Z, by currying is translated into a function that takes a single argument from X and produces as outputs functions from Y to Z. (wiki)

柯里化是一种技术,将一个接受多参数的函数求值过程转化为一系列只接受一个参数的函数的求值过程。

以做了无数次示例的add函数为开始

// 普通函数
function add (num1, num2) {
  return num1 + num2
}
// 柯里化
function curringAdd (num1) {
  return function(num2) {
    return num1 + num2
  }
}
// 箭头函数柯里化(视觉上更加清晰)
const curringAddArrow = x => y => x + y;

add(2, 5) // 7
curringAdd(2)(5) //7
curringAddArrow(2)(5) // 7

那么柯里化有什么作用呢

  1. 参数复用

  2. 延迟执行(类比bind()函数)

函数柯里化实现

  • 方案一(实质上只是偏函数)
function currying (fn) {
    var preset = [...arguments].slice(1)
    return function () {
        var laterArgs = [...arguments]
        return fn.apply(null, preset.concat(laterArgs))
    }
}

function add (x, y, z) {
    return x + y + z
}

var curryingAdd1 = currying(add, 1)
console.log(curryingAdd1(2, 3)) // 6

var curryingAdd2 = currying(add, 1, 2)
console.log(curryingAdd2(3)) // 6

对于不同的前置参数都需要调用一次currying函数

// 不考虑this
trueCurrying (fn, ...args) {
    if (args.length >= fn.length) {
        return fn(...args)
    }
    return function (...args2) {
        trueCurrying(fn, ...args, ...args2)
    }
}

function add(x, y, z) {
    console.log([x, y, z])
}

var add2 = trueCurrying(add)
add2(1, 2)(3)
2.4 被忽略的this

下面一段代码当this指向null的时候,会使用默认绑定规则

function foo () {
    console.log(this.a)
}

var a = 2

foo.call(null)  // 2 (浏览器环境)

当我们对第三方库的函数中的函数使用指向null方法时,可能会使用默认规则把this绑定到全局,将会导致不可预计的后果(比如修改全局对象)

安全的做法

var NULL_OBJ = Object.create(null)

foo.bind(NULL_OBJ, args)

软绑定

var host
if (typeof window === 'object') {
    host = window 
} else {
    host = global
}
Function.prototype.myBind = function(obj, ...args) {
    const fn = this
    var bound = function(...args2) {
        return fn.apply(
            (!this || this === host)?
                obj : this,
            args.concat(args2)
        )
    }
    bound.prototype = Object.create(fn.prototype)
    return bound
}

function name () {
    console.log(this.name)
}

obj = {
    name: 'neil'
}
obj2 = {
    name: 'whs'
}

var soft = name.myBind(obj)

soft() // neil
soft.call(obj2) // whs
2.5 this词法

箭头函数不使用this的四种规则,而是取决于定义是外层作用域

function foo () {
    return () => {
        console.log(this.a)
    }
}

var obj1 = {
    a: 2
}

var obj2 = {
    a: 5
}

var bar = foo.call(obj1)

bar.call(obj2) // 2

foo()内部常见的箭头函数会捕获调用时foo()this,之后无法被修改(new也不行)

常用于异步回调函数

编码风格

  1. 只使用词法作用域并完全抛弃错误的this风格的代码;
  2. 完全采用this风格,在必要时使用bind(),尽量避免使用self=this箭头函数

第三章 | 对象

3.1 语法

可以通过两种形式定义:字面量和构造形式

3.2 类型

对象是JavaScript的基础。在JavaScript中一共有7种主要类型

  • string
  • number
  • symbol
  • boolean
  • undifined
  • null
  • object

null本身是基本类型,typeof(null)返回object是一个语言bug

有一种常见的错误说法“JavaScript中万物皆是对象”,显然是错误的

实际上JavaScript中有很多特殊的对象子类型

  • 比如函数(从技术角度上来说是“可调用的对象”)
  • 数组也是一种具备额外行为的对象,他的内容组织方式比一般对象要复杂一点

JavaScript中还有其他一些内置对象

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

他们很像其他语言中的类型(type)或者类(class),但是在JavaScript中,他们实际上只是一些内置函数。他们可以被当作构造函数(前面讲过构造函数只是使用new进行函数的构造调用

社区中大多数认为能使用字面量创建内置对象就不要用构造形式

当我们对string和number使用方法的时候实际上是引擎把字面量转换成了对象

var str = "It's so good"
console.log(str.length) // 12
console.log(str.charAt(3))  // s

null和undefined的只有字面量形式,Date只有构造形式

3.3 内容

对象的内容是由一些存储在特定命名位置的值组成的,我们称之为属性。

虽然这些值像是被存储在对象内部,但这只是表现形式。在引擎内部(内存上)这些属性名保存的是值的地址,更像是一种链表结构(我猜的)

var obj = {
  a: 2
}
obj.a // 2
obj['a'] //2

当我们想要访问obj中a位置上的值,我们可以使用两种方式

  • 属性访问.操作符

  • 键访问(类比数组)

属性访问需要属性名满足标识符命名规范,而键访问可以接受任意UTF-8/Unicode字符串作为属性名

3.3.1 可计算属性名

ES6新增特性

var prefix = 'foo'
var obj = {
    [prefix + 'bar']: 2,
    [prefix + 'baz']: 5
}

console.log(obj['foobar']) // 2
console.log(obj.foobar)  // 2
console.log(obj[prefix + 'baz']) //5
3.3.2 属性与方法

从技术的角度来说,一个函数永远不会“属于”一个对象。

当函数使用this,且this隐式绑定的时候,也最多描述为简介关系,因为this是运行时绑定的(箭头函数除外)

function foo () {
    console.log('foo')
}

var someFoo = foo

var obj = {
    someFoo: foo
}

console.log(foo === someFoo)  // true
console.log(foo === obj.someFoo) // true
console.log(someFoo === obj.someFoo) // true

他们只是不同标识符指向堆内存中同一个地址,唯一区别就是obj.someFoo调用时会执行隐式绑定

3.3.3 数组

数组支持[]访问,也支持.访问,但是需要注意的是,给数组添加属性的时候

var arr = ['foo', 'bar', 'baz']

// 同arr.name = 'Neil
arr['name'] = 'Neil'

console.log(arr.length)  // 3

arr['5'] = 'Haidee'

console.log(arr.length)  // 6

一般字符串属性名不会被计入length,但字符串可以被隐式转化为number时,会执行数组index的操作。

3.3.4 复制对象

按照常理来说JavaScript应该内置一个对象的copy()方法,但是这比想象的更加复杂。思考下面一段代码

function foo () {}

var obj = {
    name: 'Neil'
}

var arr = []

var anotherObj = {
    a: 2,
    b: obj,
    c: arr,
    d: foo
}

arr.push(obj, anotherObj)

我们要如何准确的表达anotherObj的复制呢?

如果是浅拷贝,b, c, d只是引用。但是对于深拷贝来讲存在相互引用的死循环。

JSON安全的对象可以使用copyObj = JSON.parse(JSON.stringify(obj))

JSON安全对象包括基本类型string number boolean array null

3.3.5 属性描述符

属性分为数据属性访问器属性

  • 数据属性(value, writable, enumerable, configurable)

    var obj1 = {
        a: 2
    }
    console.log(Object.getOwnPropertyDescriptor(obj1, 'a'))
    // Object {value: 2, writable: true, enumerable: true, configurable: true}
    
  • 访问器属性(get(), set(), enumerable, configurable)

    var obj2 = Object.defineProperty({}, 'b', {
        get() {
            return this._b
        },
        set(value) {
            this._b = value
        },
        enumerable: true,
        configurable: true
    })
    
    console.log(obj2.b) // undefined
    obj2.b = 3
    console.log(obj2.b) // 3
    
  • value, writable与get(), set()互斥

  • configurable设为false的时候writable依旧可以从true改为false,但是不能从false改为true

3.3.6 不变性

所有的方法创建的都是浅不变性,只会影响目标的直接属性,如果目标对象引用了其他对象,其他对象的内容不受影响。

根据通用的设计模式,如果你发现需要密封或者冻结所有的对象,那么你或许应该退一步,重新思考一下程序的设计。

  1. 对象常量

    结合writable:false; configurable: false,可以创建一个常量属性(浅不变)

    var obj2 = Object.defineProperty({}, 'b', {
        value: 2,
        writable: false,
        enumerable: true,
        configurable: false
    })
    
    obj2.b = 3
    console.log(obj2.b)
    

    如果value为一个对象的引用,引用内容可变

    var obj1 = {
        a: 5
    }
    var obj2 = Object.defineProperty({}, 'b', {
        value: obj1,
        writable: false,
        enumerable: true,
        configurable: false
    })
    
    obj2.b.a = 3
    console.log(obj2.b.a)  // 3
    
  2. 禁止扩展, 密封,冻结

  • 禁止扩展:Object.preventEctensions()保留已有属性,禁止向对象添加新属性

  • 密封:Object.seal()会在禁止扩展基础上将对象的现有属性标记为configurable: false(可以修改)

  • 冻结:Object.freeze()会在密封基础上对将所有“数据属性”标记为writable: false

3.3.7 [[Get]] 访问算法
var obj = {
  a: 2
}
obj.a // 2

obj.a是一次属性访问,看起来是在obj上查找名为a的属性,其实背后有更复杂的机制。

在语言规范中,obj.aobj上实际上是实现了[[Get]]操作,会先查找obj,如果没找到a,会执行原型链查找。如果最终没找到会返回undifined

但是如果a的值为undifined就有意思了,你暂时无法判断到底存不存在这个属性(后面会讲存在性)。

3.3.8 [[Put]] 赋值算法

会查找当前对象是否存在这个属性,如果存在[[Put]]算法会检查下面内容

  1. 属性是否存在setter,存在就调用
  2. 属性writable是否为false,是就在严格模式下报错,非严格模式下静默失败
  3. 如果都不是就将该值设置为属性的值

如果当前对象不存在这个属性,就会沿着原型链向上查找,如果也不存在就在当前对象添加该属性

如果原型链上存在这个属性:

  1. writable: true,会在当前对象上添加该属性,它是屏蔽属性
  2. writable: false,严格模式下报错,非严格模式下静默失败。
  3. 如果该属性设置了setter,会直接调用setter。该属性不会被添加到当前对象,也不会重新定义setter

第二种情况是为了模拟类属性的继承,父类的属性只读,那么子类如果继承也是只读的(实际上没有发生继承),这个限制只存在在=操作符赋值中,Object.defineProperty()不受影响

3.3.9 Getter 和 Setter

只能应用在对象的单个属性上。

两种定义方式

var obj = {
    get a () {
        return 2
    }
}

Object.defineProperty(obj, 'b', {
    get() {
        return this.a * 2
    }
})

console.log(obj.a) // 2
console.log(obj.b) // 4

需要注意的是Object.defineProperty()定义的属性未设置的属性描述符默认为false

3.3.10 存在性

如前面提到的如果属性值为undefined,该如何去检查属性

  • Object.prototype.hasOwnProperty()会检查当前对象是否存在该属性(每个对象基于原型链可以自身调用)

  • in操作符会检查对象本身和其原型链上是否存在

    数组上调用in会检查该键值是否存在而不是数组中是否存在该值

var arr = [2, 4, 6]
console.log(4 in arr) // false

in可以检查属性的存在,for in却不会遍历这个属性

隐式屏蔽问题

var obj = {
    a: 2
}
var myObj = Object.create(obj)
myObj.a++
console.log(obj.a) // 2
console.log(myObj.a); // 3

所以class关键字禁止直接定义属性,希望能避免这种错误。

3.4 遍历
  • 枚举enumerable:属性描述符会控制该属性是否会被for in循环遍历

  • for in遍历对象的可枚举属性(包括[[prototype]]链)

    • 当在数组上使用for in循环时需要特别小心,不仅会遍历数组,还会遍历数组的属性(数组也是对象),所以最好使用for of(ES6)或者传统遍历方式for (var i = 0; i < arr.length ; i++)
  • ES5中遍历数组

    var arr = [1, 2, 3]
    for (var i = 0; i < arr.length; i++) {
        console.log(arr[i])
    }
    
    //1 2 3
    

    我们其实是遍历的数组下标

    ES5中定义了多个数组的辅助迭代器,如forEach() every() some(),它们接受一个回调函数并把它应用到数组 的每一个元素上,唯一区别就是它们对于回调函数的返回值处理方式不同。

  • for of:ES6中新定义的一种数组遍历方式(如果对象定义了迭代器也可以被遍历)

    var arr = [1, 2, 3]
    for (value of arr) {
      console.log(value)
    }
    //1 2 3
    

    for of循环会先向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的next()方法来遍历所有的返回值。

    数组内置了@@iterator,试一下手动遍历。

    var arr = [1, 2, 4]
    var ite = arr[Symbol.iterator]()
    
    console.log(ite.next()) // {value: 1, done: false}
    console.log(ite.next()) // {value: 2, done: false}
    console.log(ite.next()) // {value: 4, done: false}
    console.log(ite.next()) // {done: true}
    

    ES6中访问对象的Symbol属性需要使用键访问,返回的@@iterator是一个函数,调用该函数才会返回迭代器对象(我也不知道为啥这么设计)

    给对象添加遍历器属性@@iterator

    var obj = {
        a: 2,
        b: 3
    }
    
    Object.defineProperty(obj, Symbol.iterator, {
        enumerable: false,
        writable: false,
        configurable: true,
        value: function() {
            var _this = this
            var index = 0
            var keys = Object.keys(_this)
            return {
                next: function() {
                    return {
                        value: _this[keys[index++]],
                        done: index > keys.length
                    }
                }
            }
        }
    })
    
    var ite = obj[Symbol.iterator]()
    console.log(ite.next()) // {value: 2, done: false}
    console.log(ite.next()) // {value: 2, done: false}
    console.log(ite.next()) // {value: undefined, done: true}
    
    for ( var value of obj ) {
        console.log(value)
    }
    // 2 3
    debugger
    // debugger模式下才能显示第三次手动遍历结果
    
    

    更能清楚的看到Symbol.iterator指向一个函数

对于用户定义的对象来说,结合for of循环和自定义迭代器可以组成非常强大的对象操作工具(我还没实现)

第四章 | 混合对象"类"

本章会首先介绍面向对象编程理论,比如类的设计模式:实例化(instantiation)、继承(inheritance)和多态(polymorphism),再去对应到JavaScript代码上

4.1 类理论

类/继承描述了一种代码的组织结构形式——一种在软件中对真实世界中问题领域的建模方法

面向对象编程是一种编程范式(Programming Paradigms),有兴趣的可以了解一下Programming Paradigms

A programming paradigm is a style, or “way,” of programming.

Some languages make it easy to write in some paradigms but not others.

面向对象编程强调的是数据和操作数据的行为本质上相互关联,因此好的设计就是把数据以及它的相关行为打包。

如何理解类,继承,实例化及多态请看原书举例,这里不重复了。(都是类概念性的东西)

4.1.1 “类”设计模式

首先我们应该明白类也是一种设计模式,而我们讨论的工厂模式,单例模式,迭代器模式等设计模式都是基于类(低级)设计模式上基础上实现的。类不是编程的基础,而是一种可选的代码抽象。

有的语言(比如Java)不会给你选择的机会,万物皆是类。其他语言(C/C++)会提供过程化和面向类这两种语法,开发者可以选着其中一种或者两种风格混用。

4.1.2 JavaScript中的“类”

JavaScript中并没有真正的类,只是提供了一些能够模拟类的语言特性。但它同时也提供了easy to write in FP的功能( first-class functions),具体编程风格取决于程序员。

4.2 类的机制
  • 类和实例的关系来源于蓝图和建筑实体。

  • 构造函数:类实例是由一个特殊的类方法构造的,这个方法通常和类同名,被称为构造函数

4.3 类的继承
  • 在面向类的语言中,你可以先定义一个类,然后定义一个继承前者的类,后者被称为子类,前者被称为父类。类的继承就是复制

  • 多态:子类重写父类方法,并且调用了该继承方法。

4.4 JavaScript中的类机制

第五章 | 原型

5.1 [[prototype]]

JavaScript中的几乎所有对象(Object.create(null)没有)都有一个特殊的[[prototype]]内置属性,其实就是对于其他对象的引用。

他有什么用呢?当你取值访问的对象属性的时候会出发[[get]]算法,如果当前对象没有该属性,就会继续访问对象的[[prototype]]链:

var obj = {
  a: 2
}
var anotherObj = Object.create(obj)
console.log(anotherObj.a) // 2

我们把obj对象的[[prototype]]属性关联到了obj。当我们访问a的时候,显然anotherObj没有这个属性,所以会沿着[[prototype]]向上查找。

需要注意的是查找的是可枚举属性,同for in一样,in查找的是[[prototype]]链上所有属性(包括不可枚举)。

5.1.1 [[prototype]]的尽头

那么当我们沿着[[prototype]]链查找的时候,“尽头”是哪里呢?所有的普通的[[prototype]]链都会指向内置的Object.prototype

5.2 “类”

那么我们为什么要把一个对象关联到另一个对象?因为JavaScript中没有lei来作为对象的抽象模型,无法通过类来定义自己的行为(方法)。

5.2.1 “类函数“

所有的函数都被默认拥有一个prototype的共有且不可枚举的属性,他会指向一个对象,这个对象被称为Foo的原型:

function foo () {}
Foo.prototype; // {}

这个对象到底是什么?通过调用new创建的每个对象将被[[prototype]]链接到Foo.prototype这个对象上。

从视觉的角度,[[prototype]]的机制是从下往上的委托查找,而类继承是从上往下的复制

所以当我们使用原型继承这个名字的时候,会对继承这个词产生强烈的心理预期从而让我们曲解了[[prototype]]机制的原理。

5.2.2 “构造函数”
function Foo () {}
var a = new Foo()

ES6以前我们把构造函数当作类,用各种方式去探究“类”的继承。但是我们为什么会认为Foo是一个“类”呢?其中一个原因是我们看到了new,在其他面向类的语言中构造类实例会用到的关键字。另一个原因是看起来我们执行了类的构造函数。

还有一个原因是constructor属性。Foo.prototype默认有一个公开且不可枚举的属性constructor,这个属性引用的是的函数本身(Foo)。是为了模拟类的机制,体现这个对象是被构造函数创建的。

function Foo () {}
Foo.prototype.constructor === Foo // true
var a = new Foo()
a.constructor === Foo // true

但是和同样令人感到迷惑

function Foo () {
    console.log(222)
}
Foo.prototype.constructor() // 222

当你用debugger查看Foo.prototype.constructor属性的时候会得到一个无限循环。

而且它本身不被信任的,有时候会出错。

function Foo () {}
Foo.prototype = {}

var a = new Foo()
console.log(a.constructor === Foo); // false
console.log(a.constructor === Object); // true

虽然是通过Foo创建的a。

5.3 (原型)继承

类的继承在JavaScript中如何体现。面试题中提到各种原型继承,构造继承,组合继承,寄生式继承,都是对这个问题的探索。ES6扩展出了class的extends去实现。

但是本书更愿意以委托的思想来看待[[prototype]]机制(后面章节解释)

原型风格代码(不应该以name为实例参数,在断点下查看函数容易造成误解,函数本身有默认name属性)

function Foo (name) {
    this.name = name
}
Foo.prototype.sayName = function () {
    console.log(this.name)
}
function Bar (name, age) {
    Foo.call(this, name)
    this.age = age
}
Bar.prototype = Object.create(Foo.prototype)
// Object.setPrototypeOf(Bar.prototype, Foo.prototype)同样可以实现,思考一下两者区别
Bar.prototype.sayAge = function () {
    console.log(this.age)
}
// 修复constructor需要用Object.defineProperty创建一个{ enumerable: false }的属性,这里不需要修复
var obj = new Bar('Neil', 28)

obj.sayName() // Neil
obj.sayAge() // 28

大家还可以思考这个断点下Bar中的两个[[prototype]](_proto_),以及后面注释的问题

function Foo (name) {
    this.name = name
}
Foo.prototype.sayName = function () {
    console.log(this.name)
}
function Bar (name, age) {
    Foo.call(this, name)
    this.age = age
}
Bar.prototype = Object.create(Foo.prototype)
console.log(Bar)
debugger
// Object.getPrototypeOf(Bar.prototype) === Object.create(Foo.prototype)
// Object.getPrototypeOf(Bar.prototype) === Foo.prototype
// console.log(Object.getPrototypeOf(Bar) === ?)

5.3.1 检查“类”关系

在传统面向类环境中,检查一个实例(JavaScript中的对象)的继承祖先(JavaScript中的委托关系)通常被称为内省(或者反射)

思考下面代码

function foo () {};
Foo.prototype.blah = ...;
var obj = new Foo()

如何通过内省找出obj的“祖先”(委托关系)?第一种方式是站在“类”的角度来判断:

obj instanceof Foo // true

instance操作符可以回答:在obj的整条[[prototype]]链中是否有Foo.prototype指向的对象。但是它只能判断对象和函数之间的委托关系。

那么如何判断两个对象直接的委托关系呢?下面这段代码试图站在类的角度回答

function isRelateTo (obj1, obj2) {
    function Foo() {}
    Foo.prototype = obj2 
    return obj1 instanceof Foo
}
var obj2 = {}
var obj1 = Object.create(obj2)

console.log(isRelateTo(obj1, obj2)) // true

虽然可以回答,但是obj1并不是Foo的实例(不是由new Foo创建),逻辑上有点不通

第二种判断[[prototype]]反射的方法,更加简洁

Foo.prototype.isPrototypeOf(obj); // true

isPrototypeOf回答的是在obj的整条[[prototype]]链中是否出现过Foo.prototype

对于两个对象的判断就非常简单了

b.isPrototypeOf(a) // a.

Object.getPrototypeOf(obj)可以直接获取对象原型,同非标准方法obj.__proto__,只能获取上层。

var obj1 = {}
var obj2 = Object.create(obj1)
var obj3 = Object.create(obj2)

console.log(obj1.isPrototypeOf(obj3)) //true
console.log(Object.getPrototypeOf(obj3) === obj2) // true
console.log(Object.getPrototypeOf(obj3) === obj1) // false
console.log(obj3.__proto__ === obj2) // true

__proto__看起来像是属性,实际上更像一个getter/setter

Object.defineProperty(Object.prototype, "__proto__", {
    get: function () {
        return Object.getPrototypeOf(this);
    },
    set: function (o) {
        Object.setPrototypeOf(this, o);
        return o; // 为啥要return,难道赋值还能返回值
    }
})
5.4 对象关联

现在我们知道了[[prototype]]机制就是存在于对象中的一个内部链接,它会引用其他对象。

通常来说这个链接的作用是:入股在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[prototype]]关联的对象上继续查找,如果后者也没有找到需要的引用就会继续查找后者的[[prototype]],以此类推。

5.4.1 创建关联

Object.create()非常强大实用的方法。(接受第二个参数,详细看MDN)

var foo = {
    something: function() {
        console.log('something new')
    }
}
var bar = Object.create(foo)

bar.something() // something new

在没有使用函数的情况下,避免了麻烦的.prototype.construtor,同时也利用了[[prototype]]机制

“字典”: var dict = Object.create(null),不受原型链干扰,适合数据存储。

第六章 | 行为委托

JavaScript中[[prototype]]机制的本质就是对象之间的关系

6.1 面向委托设计

我们必须意识到[[prototype]]是一种不同于类的设计模式。

类设计模式鼓励你在继承的时候使用方法重写(和多态)。父类和子类在脑海中抽象为垂直关系。

在委托行为中我们尽量避免在[[prototype]]链的不同级别中使用相同的命名。抽象为任意方向的委托关系。

代码风格对比

原型继承风格

function Foo (who) {
    this.me = who
}
Foo.prototype.identify = function () {
    return this.me
}
function Bar (who) {
    Foo.call(this, who)
}
Bar.prototype = Object.create(Foo.prototype)
Bar.prototype.speak = function () {
    console.log("hello, ", this.identify())
}

var b1 = new Bar('b1')
var b2 = new Bar('b2')
b1.speak() // hello, b1
b2.speak() // hello, b2

对象关联风格

var Foo = {
    init: function (who) {
        this.me = who
    },
    identify: function () {
        return this.me
    }
}
var Bar = Object.create(Foo)
Bar.speak = function () {
    console.log("hello, ", this.identify())
}

var b1 = Object.create(Bar)
b1.init('b1')
var b2 = Object.create(Bar)
b2.init('b2')

b1.speak() // hello, b1
b2.speak() // hello, b2

避免了构造函数,原型以及new。注意Bar的方法声明方式,不同于Foo。

6.2 场景对比
  • UI控件场景(具体代码见书)
  • 控制器对象(具体代码见书)
6.3 简洁方法声明

ES6语法,看起来更像方法而不是函数

class Foo {
    bar() {/*  */}
}

对象中也可以使用,唯一区别是对象中需要逗号分隔

通常来讲匿名函数会导致

  1. 调用栈难以追踪
  2. 自我引用
  3. 代码难以理解

简洁方法只有第二个缺点

6.5 内省(反射)

内省就是检查实例的类型,判断对象的结构和功能。

实例与构造函数关系 obj instanceof Foo

”父类“和”子类“需要 child.prototype instanceof Father

附录A class

ES6语法糖,没有引入新的机制,但是让面向类编程更加easy

解决的问题:

  1. 基本上不再引用.prototype
  2. extends关键字让继承更加友好,不用Object.create()语法修改.prototype,可以更好的扩展内置子类型。
  3. class字面语法不能声明属性很好的避免了错误(对象类型被实例共享)
  4. super()实现相对多态,不需要丑陋的显示绑定语法

陷阱:

  1. 实例间状态共享变得困难,需要在.prototype上实现(当然也不推荐使用)。违背class语法本意:暴露了.prototype

  2. 也存在属性和方法相互屏蔽

    class C {
      constructor(id) {
        this.id = id
      }
      id() {
        console.log('id: ' + id)
      }
    }
    var c1 = new C('c1')
    c1.id() // TypeError
    
  3. super()静态绑定

    class P {
        foo() {
            console.log("P.foo")
        }
    }
    class C extends P {
        foo() {
            super.foo()
        }
    }
    var c1 = new C()
    c1.foo() // P.foo
    
    var D = {
        foo: function() {
            console.log("D.foo")
        }
    }
    var E = {
        foo: C.prototype.foo
    }
    Object.setPrototypeOf(E, D)
    
    E.foo() // P.foo
    

    可以看到E的[[prototype]]被指向D后,我们期望super动态的指向上层的D,实际上是指向的P(静态绑定)


结语

  • 耗时三周终于写完了。我采用的是第一遍阅读第二遍笔记的方式最后写完再校对一遍。所以速度比较慢。

  • 还是推荐大家阅读原书,我的笔记只是针对我的知识体系的,而原书是全面的。

Reference