JavaScript Hign-Level Day - May 9

73 阅读13分钟

1. 关于 JavaScript 的对象和函数

1.1 什么是对象?

我们知道 JavaScript 是支持面向对象的编程语言。在 JavaScript 中一切皆是对象。

面向对象的语言有一个标志,那就是它们都有类(class)的概念,通过类可以创建任意多个具有相同属性和方法的对象。而ECMAScript中没有类的概念,因此JavaScript的对象也跟其他基于类的语言中的对象有所不同。

ECMAScript把对象定义为:" 无序属性的集合 " 。这就相当于说对象是一组没有特定顺序的值。

我们从两个层次来试图理解对象到底是什么?

  1. 对象是单个实物的抽象。一本书、一个人、一辆汽车都可以是对象,一个数据库、一张网页、一个远程服务器连接也可以是一个对象。

  2. 对象是一个容器封装了属性和方法属性是对象的状态方法是对象的行为(完成某种行为)。

    // 创建自定于对象最简单的方式 let person = { name: 'ckn', age: 18, sayName: function() { alert(this.name) } }

1.2 对象的属性类型

ECMAScript中有两种属性: 数据属性访问器属性

1. 数据属性

[[configurable]]: 属性是否可配置
[[enumberable]]: 属性能否遍历枚举
[[writable]]: 能否修改属性的值
[[value]]: 这个属性保存属性的值

要修改属性默认的特性,必须使用ES5的 Object.defineProperty()方法。

var person = {}
Object.defineProperty(person, 'name', {
  writable: false,
  configurable: false,
  enumberable: false,
  value: 'ckn'
})

2. 访问器属性

[[configurable]]: 属性是否可配置
[[enumberable]]: 属性能否遍历枚举
[[get]]: 在读取的时候调用的函数
[[set]]: 在写入属性的时候调用的函数

同样,访问器属性不能直接定义,也必须使用 Object.defineProperty()方法。

var book = {
  _year: 2004,
  edition: 1
}
Object.defineProperty(book, 'year', {
  get: function() {
    return this._year
  },
  set(newVal) {
    if(newVal > this._year) {
      this._year = newVal
      this.edition += newVal - 2004
    }
  }
})
book.year = 2005;
console.log(book.edition); // 2

Object.definePropertys() 定义多个属性

Object.getOwnPropertyDescriptor() 读取属性的特性

1.3 创建对象

虽然可以通过Object构造函数或字面量创建单个对象,但如果想要创建多个对象的时候,会产生大量重复的代码。为了解决这个问题,人们开始使用工厂模式。

1.3.1 工厂模式

工厂模式是软件设计领域一种广为人知的设计模式。

function createPerson(name, age) {
  var o = new Object()
  o.name = name
  o.age = age
  o.sayName = function() { alert(this.name) }
  return o
}
var p1 = createPerson('ckn', 16)
var p2 = createPerson('lxh', 18)

以上解决了代码重复的问题,但是没有解决对象识别的问题(即怎样知道一个对象的类型)

var p3 = createPerson('旺财', 5)

上面3个新创建的对象,都属于 Object 类型,还是不能清晰分辨 人 和 旺财 的区别。

1.3.2 构造函数模式

JavaScript中构造函数可以用来创建特定类型的对象。

function Person(name, age) {
  this.name = name
  this.age = age
  this.sayName = function() { alert(this.name) }
}
// p1 和 p2 是构造函数生成的实例对象
var p1 = new Person('ckn', 18)
var p2 = new Person('lxh', 16)

通常构造函数的首字母大写,以此来区分普通函数。

因为构造函数本身也是函数,只不过可以用来创建对象而已。

创建自定义的构造函数意味着将来可以将它的实例标识为一种特定的类型(Perosn类型)。

1.3.3 构造函数的问题

我们看上面代码的 p1 和 p2 都有一个名为 sayName 的方法, 但那两个方法不是同一个Function的实例。因为函数也是对象,因此每定义一个函数,也就是实例化一个对象。

this.sayName = new Function('alert(this.name)')

从上面代码角度来看构造函数,更容易明白每个Person实例都包含一个不同的Function实例的本质。

明明是同样的功能,却生成多个不同的实例,这无疑消耗了性能和内存。

假如,我们把函数定义转移到构造函数之外呢?

function sayName() {
  alert(this.name)
}function Persoin(...) {
  ...
  this.sayName = sayName
}

此时,生成的实例确实共享了同一个sayName()函数,可是 sayName函数是在全局作用域下定义的,这让全局作用域有点名不副实,也污染了全局变量环境。

1.3.4  原型模式

我们创建的每一个函数都有 prototype(原型)属性,这个属性指向一个对象。而这个原型对象可以让所有实例共享它包含的属性和方法。

Person.prototype.sayName = function() {
  alert(this.name)
}

每一个实例都有一个 __proto__ 属性指向这个 prototype 原型对象。

Object.getProtottpeOf()获取对象的原型

hasOwnProperty()检查对象的属性是来自于实例自身还是原型

1.3.5 继承

原型是JavaScript对象相互继承特性的机制。

JavaScript 实现继承的主要依靠是原型链。所以说 JavaScript 是基于原型的语言。基于原型链一个对象可以继承另一个对象的属性和方法。

在传统的基于类Class的语言中如C++、Java。继承的本质是扩展一个已有的Class,并生成一个新的SubClass,这类语言严格区分类和实例,对于这类语言来说 继承 实际上是 类的扩展。

1.3.6 原型链

// 父类
function SupType(name, type) {
  this.name = name
  this.type = type
  this.colors = ['blue']
}
SupType.prototype.getSuperColors = function() {
  return this.colors 
}
// 子类
function SubType() {}
SubType.prototype = new SupType()

var instance1 = new SubType()
instance1.colors.push('red');
var instance2 = new SubTyoe()
alert(instance2.colors); // ['blue', red]

以上就是原型链模式继承的问题,主要问题来自包含引用类型值的原因(colors)。

因为引用类型值的原型属性会被所有实例共享,当然这是引用数据类型存储机制的原因。

所以这也正是为什么要在构造函数中,而不是在原型对象中定义属性的原因,最好是在自己的构造函数中定义自己实例所需要的属性。

原型链模式继承的第二个问题:在创建子类型的实例时,不能向超类型的构造函数传参。

1.3.7 借用构造函数

// 父类
function SupType(name) {
  this.name = name
  this.colors = ['blue']
}
SupType.prototype.sayHi = function() {
  console.log('hi')
}
// 子类
function SubType() {
  // 继承了SupType,同时还传递了参数
  SupType.call(this, 'Tom')
}
var instance1 = new SubType()
instance1.colors.push('red')
var instance2 = new SubType()
alert(instance2); // ['blue']
alert(instance2.name); // 'Tom'
instance1.sayHi();  // 报错

仅仅使用构造函数的问题:在超类型的原型中定义的方法,对于子类型而言是不可见的,函数复用也就无从谈起了

1.3.6 借用构造函数 和 原型模式 实现ES5的继承 (组合继承)

// 父类
function SupType(name) {
  this.name = name
  this.colors = ['blue']
}
SubType.prototype.sayHi = function() {
  console.log('hi my name is' + this.name)
}
// 子类
function SubType(name, age) {
  // 继承属性
  SupType.call(this, name)
  this.age = age
}
// 继承方法
SubType.prototype = new SupType();
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
  console.log(this.age)
}

var instance1 = new SubType('Tom', 18)
instance1.colors.push('red')
alert(instance1.colors) // ['blue', 'red']
alert(instance1.sayHi()) // 'hi my name is Tom'alert(instance1.sayAge()) // 18

var instance2 = new SubType('Grag', 6)
alert(instance2.colors) // ['blue']
alert(instance2.sayHi()) // 'hi my name is Grag'
alert(instance2.sayAge()) // 6

 组合继承避免了原型链和借用构造函数的缺陷,融合了它们的优点,称为JavaScript中最常用的继承模式。

2. 什么是函数?

2.1 理解函数

函数——可复用的代码块。

函数是一种可以反复调用的代码,通过函数可以封装任意多条语句,它允许你在一个代码块中存储一段用于处理任务(也可以说是流传)的代码,并且可以在任何地方、任何时候调用执行且可多次调用

在JavaScript看来函数与其他值没什么区别,无非就是可以调用执行,仅此而已。

2.2 函数与方法的区别

字符串的 replace 替换方法、数组的 join 拼接方法、或者生成一个随机数 Math.random 方法。它们既是函数也是方法。

JavaScript 有许多内置的函数,可以做很多有用的事情而无需自己编写所有代码。事实上,许多你调用浏览器内置函数时的代码并不是 JavaScript 来编写的 —— 大多数调用浏览器后台函数的代码是使用想C++这样的编程语言来编写的。

内置浏览器函数不是核心JavaScript语言的一部分,它只是被定义为浏览器API的一部分。到目前为止,我们所使用的内置代码同属于两种形式(函数和方法)

严格来说,内置浏览器函数不是函数 —— 它们是方法。

二者的区别在于,方法是对象内部定义的函数。

2.3 函数与对象的区别

我们知道JavaScript中,万物皆对象,所以函数本身也属于对象,它是一种特殊的对象,函数实际上是一个Function对象(函数是Function构造函数的实例对象)。

函数与其他对象的区别在于:函数可以调用,仅此而已。

还有函数可以封装任意多条语句,是一段处理任务的代码块,任何时候任何地方都可调用。

2.4 函数是第一等公民

JavaScript语言将函数看作一种值,与其他值(number、string、boolean等)地位相同。凡是可以使用值的地方,都可以使用函数。比如:

可以把函数赋值给变量或者作为对象的属性。
也可以当作参数传入其他函数或者作为其他函数的返回值返回。

由于与其他数据类型地位平等,所以在 JavaScript 语言中函数是第一等公民。

3. 函数式编程

3.1 定义

"函数式编程" 是一种 "编程范式",也就是如何编写程序的方法论。它将计算视为数学函数的评估,并避免使用程序状态以及易变对象(也就是JavaScript的引用数据类型的值)

函数式编程的目标是使用函数来抽象作用在数据之上的控制流与操作,从而在系统中消除副作用并减少对状态的改变

3.2 理解函数式编程的前置概念

为了充分理解函数式编程,首先看下这几个名词的基本概念:

  • 声明式编程
  • 纯函数
  • 引用透明
  • 不可变性

3.2.1 声明式编程

函数式编程属于声明式编程范式:这种范式会描述一系列的操作,但并不会暴露它们是如何实现的或是数据流如何穿过它们。

我们一定也听过命令式编程,那么我们看下两者的区别:

// 命令式编程
var array = [0,1,2,3,4,5]
for(let i = 0; i < array.length; i++) {
  array[i] = Math.pow(array[i], 2)
}
alert(array); // [0,1,4,9,16,25]

命令式编程会很具体的告诉计算机如何执行某个任务(上述就是通过数组循环将平方公式应用在每个数上)。这是编写代码的最常见的方式,我们在第一次实现该功能时很有可能就是这样写的。

而声明式编程是将程序的 描述求值分离开的。它关注于如何利用各种 表达式 来描述逻辑,而不一定要指明其控制流或状态的变化
你可以在SQL语句中找到声明性编程的例子。SQL语句是由一个个描述查询结果应该是什么的断言组成,对数据检索的内部机制进行了抽象。

如果使用函数式来解决相同的问题,只需要考虑每个数组元素上的行为,将循环交给系统的其他部分去控制。完全可以让Array.map()去做这种繁重的工作。

var array = [1,2,3,4,5]
var result = array.map(num => {
  return Math.pow(num, 2)
})
console.log(result); // [1, 4, 9, 16, 25]

与命令式相比,函数式的代码可以让开发者免于考虑如何妥善管理循环计数器 以及 数组索引访问的问题。(少写代码就可以少出bug)

我们为什么要去掉代码循环?循环是一种很重要的命令控制结构,但很难重用,并且很难插入其他操作中。此外,它意味着为响应新的迭代,代码会不断发生变化。
函数式编程旨在尽可能的提高代码的 无状态性不变性
无状态的代码不会改变或破坏全局的状态。
但要做到这一点,需要开发者使用那些没有副作用和状态变化的函数 —— 纯函数

3.2.2 纯函数

纯函数是指给定相同的输入,总是返回相同的输出,并且没有副作用的函数。这意味着它们不依赖于或修改外部状态。

编写不可变的程序起初会令人感到陌生。毕竟,我们所习惯的命令式程序设计的本质,就是声明一些从一个状态变为下一个状体的变量,这是我们做起来很自然的事情。

var counter = 0
function increment() {
  return ++counter
}

这个函数就是不纯的,因为它读取并修改了一个外部变量,即函数作用域外的counter。

引用透明是定义一个纯函数较为正确的方式。如果一个函数对于相同的输入始终产生相同的输出结果,那么就说它是引用透明的。比如下面这行代码:

var increment = (counter) => counter + 1;

开发一个管理学生数据的应用程序,通过学生id找到一个学生的信息并渲染在浏览器中(记住,是不是使用浏览器并不重要,你也可以写入控制台、数据库或文件)这个应用程序的场景,包含了很多与外部的本地对象存储结构(例如一个对象数组)和不同层次的IO交互而产生的副作用。

function showStudent(id) {
  var student = db.get(id);
  if (student !== null) {
    document.querySelector(`${elementId}`).innerHTML = 
      `${student.id},${student.name},${student.lastname}`
  } else {
    throw new Error('student not found!')
  }
}
showStudent(10220);

分析上述代码的会产生的副作用:

  • 该函数访问外部数据,与一个外部变量(db)进行了交互,因为该函数内部并没有声明该参数。在任何一个时间点,这个引用可能为null,或在调用间隔改变,从而导致完全不同的结果并破坏了程序的完整性
  • 全局变量 elementId 可能随时改变,难以控制
  • HTML元素被直接修改了。DOM本身是一个可变的、共享的全局资源
  • 如果没有找到学生,该函数可能会抛出一个异常,这将导致整个程序的栈回退并突然结束

使用函数式的思想来实现这个任务,根据上面的代码,有两点可以改进:

  1. 将长函数分离成多个具有单一职责的短函数

  2. 通过显示的将完成功能所需要的依赖都定义为函数参数来减少副作用的数量

    // find函数方法 需要对象存储的引用和id 来查找学生 var find = curry(function(db, id) { var obj = db.getObj(id) if (obj === null) { throw new Error('not found') } return obj })

    // csv函数方法 将学生对象转换为都好分割的字符串 var csv = (student) => ${student.id},${student.name},${student.lastname}

    // append函数方法 需要elementId以及学生信息显示到屏幕上 var append = curry(function(elementId, info) { document.getElementById(elementId).innerHTML = info })

暂时先不需要理解柯理化,但要看到重要的一点,那就是通过减少这些函数的长度,可以将showStudent编写为这些小函数的组合:

var showStudent = run(
  append('#stu-info'),
  csv,
  find(db)
)
showStudent(10020)

尽管只是做了些许的改进,但是已经展现出函数式编程的许多优势来:

  • 灵活,有三个可重用的方法
  • 这种细粒度函数的重用提高了开发效率,因为可以大大减少维护的代码量
  • 更重要的是,与HTML对象的交互被移动到一个单独和函数中,将纯函数从不纯的行为中分离出来

3.2.3 不可变性

函数式编程倡导使用不可变的数据结构。当需要修改数据时,应该创建一个新的数据结构,而不是直接修改现有的数据结构

3.2.4 高阶函数

高阶函数是接受函数作为参数或返回函数的函数。这是函数式编程的核心概念之一,它允许函数的抽象和复用

3.2.5 函数组合

将多个函数组合成一个新的函数,每个函数接收另一个函数的输出作为输入。这有助于创建复杂的操作,同时保持每个函数的简洁和独立

3.2.6 柯里化

一种将多参数函数转换为一系列单参数函数的技术。

3.2.7 闭包

闭包是那些能够访问自由变量(在函数外部定义的变量)的函数。它们在函数式编程中非常有用,因为它们可以捕获和记忆状态

3.2.8 递归

由于函数式编程避免使用循环结构,递归是实现循环逻辑的主要方式