前端架构设计第九课 前端设计模式与设计思想

1194 篇文章 56 订阅

19 如何理解软件开发灵活性和高定制性?

前两讲我们介绍了前端开发领域常见的开发模式和封装思想,这一讲,我们将该主题升华,聊一聊软件开发灵活性和高定制性这个话题。

业务需求是烦琐多变的,因此开发灵活性至关重要,这直接决定了开发效率,而与灵活性相伴相生的话题就是定制性。本讲主要从设计模式和函数式思想入手,从实际代码出发,来阐释灵活性和高定制性。

设计模式

设计模式——我认为这是一个“一言难尽”的概念。维基百科对设计模式的定义为:

在软件工程中,设计模式(Design Pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希·伽玛(Erich Gamma)等人在 1990 年代从建筑设计领域引入到计算机科学的。设计模式并不是直接用来完成代码的编写,而是描述在各种不同情况下,要怎么解决问题的一种方案。

设计模式一般认为有 23 种,这 23 种设计模式的本质是面向对象设计原则的实际运用,是对类的封装性、继承性和多态性,以及类的关联关系和组合关系的总结应用。

事实上,设计模式是一种经验总结,它就是一套“兵法”,最终是为了更好的代码重用性、可读性、可靠性、可维护性。我认为设计模式不能只停留在理论上,而是应该结合到实际代码当中。在平常开发中,“也许你不知道,但是已经在使用设计模式了”。

下面我们将从前端中最常见的两种设计模式展开讲解。

代理模式

代理模式大家应该都不陌生,ES.next 提供的 Proxy 特性让我们实现代理模式变得更加容易。关于 Proxy 特性的使用这些基础内容这里不过多赘述,我们直接来看一些代理模式的应用场景。

一个常见的代理模式应用场景是针对计算成本比较高的函数,我们可以通过对函数进行代理,来缓存函数对应参数的计算返回结果。在函数执行时,优先使用缓存值,否则返回执行计算值,如下代码:

const getCacheProxy = (fn, cache = new Map()) => 
  // 代理函数 fn
  new Proxy(fn, {
  	// 代理 fn 的调用方法
    apply(target, context, args) {
    	// 将调用参数字符串化,方便作为存储 key
      const argsString = args.join(' ')
      // 判断是否存在缓存,如果存在直接返回缓存值
      if (cache.has(argsString)) {
        return cache.get(argsString)
      }
      // 执行 fn 方法,得到计算结果
      const result = fn(...args)
      // 存储相关计算结果
      cache.set(argsString, result)
      return result
    }
  })

利用上述实现思想,我们还可以很轻松地实现一个根据调用频率来进行截流的函数代理,如下代码实现:

const createThrottleProxy = (fn, timer) => {
  // 计算时间差
  let last = Date.now() - timer
  // 代理函数 fn
  return new Proxy(fn, {
  	 // 代理函数调用
    apply(target, context, args) {
    	// 计算距离上次调用的时间差,如果大于 rate 则直接调用
      if (Date.now() - last >= rate) {
        fn(args)
        // 记录此次调用时间
        last = Date.now()
      }
    }
  })
}

我们再看一个 jQuery 中的一个例子,jQuery 中$.proxy()方法接受一个已有的函数,并返回一个带有特定上下文的新函数。比如对于向一个特定对象的元素添加事件回调,如下代码:

$( "button" ).on( "click", function () {
  setTimeout(function () {
    $(this).addClass( "active" );
  });
});

上述代码中的$(this)因为是在setTimeout中执行,不再是预期之中的“当前触发事件的元素”,我们可以存储 this 指向来完成:

$( "button" ).on( "click", function () {
  var that = $(this)
  setTimeout(function () {
    that.addClass( "active" );
  });
});

也可以使用 jQuey 中的代理方法。如下代码:

$( "button" ).on( "click", function () {
    setTimeout($.proxy( unction () {
        // 这里的 this 指向正确
        $(this).addClass( "active" );
    }, this), 500);
});

其实,jQuery 源码中$.proxy的实现也并不困难:

proxy: function( fn, context ) {
  // ...
  // 模拟 bind 方法
  var args = slice.call(arguments, 2),
    proxy = function() {
      return fn.apply( context, args.concat( slice.call( arguments ) ) );
    };
  // 这里的做法主要为了使得 proxy 全局唯一,以便后续删除
  proxy.guid = fn.guid = fn.guid || proxy.guid || jQuery.guid++;
  return proxy;
}

上述代码中我们模拟了bind方法,以保证 this 上下文的准确。

事实上,代理模式在前端中的使用场景非常多。我们熟悉的 Vue 框架,为了完成对数据的拦截和代理,以便结合观察者模式,对数据变化进行响应,在最新版本中,也使用了 Proxy 特性,这些都是代理模式的典型应用。

装饰者模式

简单来说,装饰者模式就是在不改变原对象的基础上,对其对象进行包装和拓展,使原对象能够应对更加复杂的需求。这有点像高阶函数,因此在前端开发中很常见,如下面代码:

import React, { Component } from 'react'
import {connect} from 'react-redux'
class App extends Component {
 render() {
  //...
 }
}
export default connect(mapStateToProps,actionCreators)(App);

react-redux 类库中的connect方法,对相关 React 组件进行包装,以拓展新的 Props。另外,这种方法在 ant-design 中也有非常典型的应用,如下面代码:

class CustomizedForm extends React.Component {}
CustomizedForm = Form.create({})(CustomizedForm)

如上代码,我们将一个 React 组件进行“装饰”,使其获得了表单组件的一些特性。

事实上,我们将上述介绍的两种模式相结合,很容易衍生出 AOP 面向切面编程的概念。如下代码:

Function.prototype.before = function(fn) {
  // 函数本身
  const self = this
  return function() {
  	 // 执行 self 函数前,需要执行的函数 fn
    fn.apply(new(self), arguments)
    return self.apply(new(self), arguments)
  }
}
Function.prototype.after = function(fn) {
  const self = this
  return function() {
  	 // 先执行 self 函数
    self.apply(new(self), arguments)
    // 执行 self 函数后,需要执行的函数 fn
    return fn.apply(new(self), arguments)
  }
}

如上代码,我们对函数原型进行了扩展,在函数调用前后分别调用了相关切面方法。一个典型的场景就是对表单提交值进行验证。如下代码:

const validate = function(){
  // 表单验证逻辑
}
const formSubmit = function() {
  // 表单提交逻辑
  ajax( 'http:// xxx.com/login', param )
}
submitBtn.onclick = function() {
  formSubmit.before( validate )
}

至此,我们对前端中常见的两种设计模式进行了分析,实际上,在前端中还处处可见观察者模式等经典设计模式的应用,我们将在下一讲中,进行更多说明。

函数式思想应用

前面我们介绍了设计模式相关内容,事实上,设计模式和面向对象话题相伴相生,而面向对象和函数式思想“相互对立”,互为补充。函数式思想在前端领域同样应用颇多,这里我们简单对函数式思想的基础应用进行说明。

函数组合的简单应用

纯函数是指:

一个函数如果输入参数确定,输出结果是唯一确定的,那么它就是纯函数。

同时,需要强调的是纯函数不能修改外部变量,不能调用 Math.radom() 方法以及发送异步请求等,因为这些操作都不具有确定性,可能会产生副作用。

纯函数是函数式编程中最基本的概念。另一个基本概念是——高阶函数:

高阶函数体现了“函数是第一等公民”,它是指这样的一类函数:该函数接受一个函数作为参数,返回另外一个函数。

我们来看一个例子:filterLowerThan10这个函数接受一个数组作为参数,它会挑选出数组中数值小于 10 的项目,所有符合条件的值都会构成新数组被返回:

const filterLowerThan10 = array => {
    let result = []
    for (let i = 0, length = array.length; i < length; i++) {
        let currentValue = array[i]
        if (currentValue < 10) result.push(currentValue)
    }
    return result
}

另外一个需求,挑选出数组中非数值项目,所有符合条件的值都会构成新数组被返回,如下filterNaN函数:

const filterNaN = array => {
    let result = []
    for (let i = 0, length = array.length; i < length; i++) {
        let currentValue = array[i]
        if (isNaN(currentValue)) result.push(currentValue)
    }
    return result
}

上面两个函数都是比较典型的纯函数,不够优雅的一点是 filterLowerThan10 和 filterNaN都有遍历的逻辑,都存在了重复的 for 循环。它们本质上都是遍历一个列表,并用给定的条件过滤列表。那么我们能否用函数式的思想,将遍历和筛选解耦呢?

好在 JavaScript 对函数式较为友好,我们使用 Filter 函数来完成,并进行一定程度的改造,如下代码:

const lowerThan10 = value => value < 10
[12, 3, 4, 89].filter(lowerThan10)

继续延伸我们的场景,如果输入比较复杂,想先过滤出小于 10 的项目,需要先保证数组中每一项都是 Number 类型,那么可以使用下面的代码:

[12, 'sd', null, undefined, {}, 23, 45, 3, 6].filter(value=> !isNaN(value) && value !== null).filter(lowerThan10)

我们通过组合,实现了更多的场景。

curry 和 uncurry

继续思考上面的例子,filterLowerThan10 还是硬编码写死了 10 这个阈值,我们用 curry 化的思想将其改造,如下代码:

const filterLowerNumber = number => {
    return array => {
        let result = []
        for (let i = 0, length = array.length; i < length; i++) {
            let currentValue = array[i]
            if (currentValue < number) result.push(currentValue)
        }
        return result
    }
}
const filterLowerThan10 = filterLowerNumber(10)

上面代码中我们提到了 curry 化这个概念,简单说明:

curry 化,柯里化(currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。这个技术由克里斯托弗·斯特雷奇以逻辑学家哈斯凯尔·加里命名的。

curry 化的优势非常明显:

  • 提高复用性

  • 减少重复传递不必要的参数

  • 动态根据上下文创建函数

其中动态根据上下文创建函数,也是一种惰性求值的体现。比如这段代码:

const addEvent = (function() {
    if (window.addEventListener) {
        return function (type, element, handler, capture) {
            element.addEventListener(type, handler, capture)
        }
    }
    else if (window.attachEvent){
        return function (type, element, fn) {
            element.attachEvent('on' + type, fn)
        }
    }
})()

这是一个典型兼容 IE9 浏览器事件 API 的例子,根据兼容性的嗅探,充分利用 curry 化思想,完成了需求。

那么我们如何编写一个通用化的 curry 函数呢?下面我给出一种方案:

const curry = (fn, length) => {
	 // 记录函数的行参个数
    length = length || fn.length
    return function (...args) {
    	  // 当参数未满时,递归调用
        if (args.length < length) {
            return curry(fn.bind(this, ...args), length - args.length)
        }
        // 参数已满,执行 fn 函数
        else {
            return fn.call(this, ...args)
        }
    }
}

如果不想使用 bind,另一种常规思路是对每次调用时产生的参数进行存储

const curry = fn =>
    judge = (...arg1) =>
    	 // 判断参数是否已满
        arg1.length >= fn.length
            ? fn(...arg1) // 执行函数
            : (...arg2) => judge(...arg1, ...arg2) // 将参数合并,继续递归调用

对应 curry 化,还有一种反 curry 化的概念:反 curry 化在于扩大函数的适用性,使本来作为特定对象所拥有的功能函数可以被任意对象使用

有一个 UI 组件 Toast,如下代码简化为:

function Toast (options) {
    this.message = ''
}
Toast.prototype = {
    showMessage: function () {
        console.log(this.message)
    }
}

这样的代码,使得 Toast 实例均可使用 ShowMessage 方法,使用方式如下:

new Toast({message: 'show me'}).showMessage()

如果脱离组件场景,我们不想实现 Toast 实例,而使用Toast.prototype.showMessage方法,预期通过反 curry 化实现,如下代码:

// 反 curry 化通用函数
// 核心实现思想是:先取出要执行 fn 方法的对象,标记为 obj1,同时从 arguments 中删除,在调用 fn 时,将 fn 执行上下文环境改为 obj1
const unCurry = fn => (...args) => fn.call(...args)
const obj = {
    message: 'uncurry test'
}
const unCurryShowMessaage = unCurry(Toast.prototype.showMessage)
unCurryShowMessaage(obj)

以上是正常函数实现 uncurry 的实现。我们也可以将 uncurry 挂载在函数原型上,如下代码:

// 反 curry 化通用函数挂载在函数原型上
Function.prototype.unCurry = !Function.prototype.unCurry || function () {
    const self = this
    return function () {
        return Function.prototype.call.apply(self, arguments)
    }
}

当然,我们可以借助 bind 实现:

Function.prototype.unCurry = function() {
  return this.call.bind(this)
}

我们通过下面这个例子来理解:

// 将 Array.prototype.push 反 curry 化,实现一个适用于对象的 push 方法
const push = Array.prototype.push.unCurry()
const test = { foo: 'lucas' }
push(test, 'messi', 'ronaldo', 'neymar')
console.log(test) 
// {0: "messi", 1: "ronaldo", 2: "neymar", foo: "lucas", length: 3}

反 curry 化的核心思想就在于:利用第三方对象和上下文环境,“强行改命,为我所用”

最后我们再看一个例子,我们将对象原型上的toString方法“为我所用”,实现了一个更普遍适用的类型检测函数。如下代码:

// 利用反 curry 化,创建一个检测数据类型的函数 checkType
let checkType = uncurring(Object.prototype.toString)
checkType('lucas'); // [object String]

总结

这一讲我们从设计模式和函数式两大编程思想流派入手,分析了如何在编程中做到灵活性和高定制性,并通过大量的实例来强化思想,巩固认识。

本讲主要内容如下:

前端基建 金句.png

事实上,前端领域中的灵活性和高定制性编码方案和其他领域相关思想是完全一致的,设计模式和函数式具有“普世意义”,我们将会在下一讲中继续延伸这类话题。

这里我也给大家留一个思考题:你还用过哪些设计模式的使用场景呢?欢迎在留言区和我分享你的经历。下一讲,我们将深入对象和原型,理解 JavaScript 在这个方向上的能力。请注意,下一讲我们不再过多赘述基础,而是面向进阶,需要你具有一定的知识准备。我们下一讲再见。

20 如何理解前端中面向对象的思想?

“对象”——这个概念在编程中非常重要,任何语言和领域的开发者都应该具有面向对象思维,才能够有效运用对象。良好的面向对象系统设计将是应用强健性、可维护性和可扩展性的关键;反之,如果面向对象环节有失误,将成为项目的灾难。

说到 JavaScript 面向对象,它实质是基于原型的对象系统,而不是基于类的。这是设计之初,由语言设计所决定的。随着 ES Next 标准的进化和新特性的添加,使得 JavaScript 面向对象更加贴近其他传统面向对象型语言。有幸目睹语言的发展和变迁,伴随着某种语言的成长,我认为是开发者之幸。

这一讲就让我们深入对象和原型,理解 JavaScript 在这个方向上的能力。请注意,今天的内容我们不再过多赘述基础,而是面向进阶,需要你具有一定的知识准备。

实现 new 没有那么容易

说起 JavaScript 当中的 new 关键字,有一段很有趣的历史。其实 JavaScript 创造者 Brendan Eich 实现 new 是为了获得更高的流行度,它是强行学习 Java 的一个残留产出,创造者想让 JavaScript 成为 Java 的小弟。当然,也有很多人认为这个设计掩盖了 JavaScript 中真正的原型继承,只是表面上看,更像是基于类的继承。

这样的误会使得很多传统 Java 开发者并不能很好理解 JavaScript。实际上,我们前端工程师应该明白,new 关键字到底做了什么事情。

step1:创建一个空对象,这个对象将会作为执行 new 构造函数() 之后,返回的对象实例。

step2:将上面创建的空对象的原型(proto),指向构造函数的 prototype 属性。

step3:将这个空对象赋值给构造函数内部的 this,并执行构造函数逻辑。

step4:根据构造函数执行逻辑,返回第一步创建的对象或者构造函数的显式返回值。

因为 new 是 JavaScript 的关键字,我们不能直接覆盖,实现一个 newFunc 来进行模拟,预计使用方式:

function Person(name) {
  this.name = name
}
const person = new newFunc(Person, 'lucas')
console.log(person)
// {name: "lucas"}

实现为:

function newFunc(...args) {
  // 取出 args 数组第一个参数,即目标构造函数
  const constructor = args.shift()
  // 创建一个空对象,且这个空对象继承构造函数的 prototype 属性
  // 即实现 obj.__proto__ === constructor.prototype
  const obj = Object.create(constructor.prototype)
  // 执行构造函数,得到构造函数返回结果
  // 注意这里我们使用 apply,将构造函数内的 this 指向为 obj
  const result = constructor.apply(obj, args)
  // 如果构造函数执行后,返回结果是对象类型,就直接返回,否则返回 obj 对象
  return (typeof result === 'object' && result != null) ? result : obj
}

上述代码并不复杂,几个关键点需要注意:

  • 使用 Object.create 将 obj 的 proto 指向为构造函数的原型;

  • 使用 apply 方法,将构造函数内的 this 指向为 obj;

  • 在 newFunc 返回时,使用三目运算符决定返回结果。

我们知道,构造函数如果有显式返回值,且返回值为对象类型,那么构造函数返回结果不再是目标实例。

如下代码:

function Person(name) {
  this.name = name
  return {1: 1}
}
const person = new Person(Person, 'lucas')
console.log(person)
// {1: 1}

了解这些注意点,对于理解 newFunc 的实现就不再困难了。

如何优雅地实现继承

实现继承式是面向对象的一个重点概念。我们前面提到过 JavaScript 的面向对象系统是基于原型的,它的继承不同于其他大多数语言。

社区上对于 JavaScript 继承讲解的资料不在少数,这里我不再赘述每一种继承方式的实现过程,还需要你提前了解。

ES5 相对可用的继承方案

我们仅总结以下 JavaScript 中实现继承的关键点。

如果想使 Child 继承 Parent,那么采用原型链实现继承最关键的要点是:

Child.prototype = new Parent()

这样的实现,不同的 Child 实例的 proto 会引用同一 Parent 的实例。

构造函数实现继承的要点是:

function Child (args) {
    // ...
    Parent.call(this, args)
}

这样的实现,问题也比较大,其实只是实现了实例属性继承,Parent 原型的方法在 Child 实例中并不可用

组合继承的实现才基本可用,其要点是:

function Child (args1, args2) {
    // ...
    this.args2 = args2
    Parent.call(this, args1)
}
Child.prototype = new Parent()
Child.prototype.constrcutor = Child

它的问题在于,Child 实例会存在 Parent 的实例属性。因为我们在 Child 构造函数中执行了 Parent 构造函数。同时,Child.proto 也会存在同样的 Parent 的实例属性,且所有 Child 实例的 proto 指向同一内存地址。同时上述实现也都没有对静态属性的继承。

还有一些其他不完美的继承方式,我们这里不再过多介绍。

下面我们给出一个比较完整的方案,它解决了上面一系列的问题,我们先看代码:

function inherit(Child, Parent) {
     // 继承原型上的属性 
    Child.prototype = Object.create(Parent.prototype)
     // 修复 constructor
    Child.prototype.constructor = Child
    // 存储超类
    Child.super = Parent
    // 静态属性继承
    if (Object.setPrototypeOf) {
        // setPrototypeOf es6
        Object.setPrototypeOf(Child, Parent)
    } else if (Child.__proto__) {
        // __proto__ es6 引入,但是部分浏览器早已支持
        Child.__proto__ = Parent
    } else {
        // 兼容 IE10 等陈旧浏览器
        // 将 Parent 上的静态属性和方法拷贝一份到 Child 上,不会覆盖 Child 上的方法
        for (var k in Parent) {
            if (Parent.hasOwnProperty(k) && !(k in Child)) {
                Child[k] = Parent[k]
            }
        }
    }
}

具体原理已经包含在了注释当中。需要指出的是,上述静态属性继承仍然存在一个问题:在陈旧浏览器中,属性和方法的继承我们是静态拷贝的,继承完后续父类的改动不会自动同步到子类。这是不同于正常面向对象思想的,但是这种组合式继承,已经相对完美、优雅。

继承 Date

值得一提的一个小细节是:前面几种继承方式无法实现对 Date 对象的继承。我们来进行测试:

function DateConstructor() {
    Date.apply(this, arguments)
    this.foo = 'bar'
}
inherit(DateConstructor, Date)
DateConstructor.prototype.getMyTime = function() {
    return this.getTime()
};

let date = new DateConstructor()
console.log(date.getMyTime())

将会得到报错:Uncaught TypeError: this is not a Date object.

究其原因,是因为 JavaScript 的日期对象只能通过 JavaScript Date 作为构造函数来实例化得到。因此 v8 引擎实现代码中就一定有所限制,如果发现调用 getTime() 方法的对象不是 Date 构造函数构造出来的实例,则抛出错误。

那么如何实现对 Date 的继承呢?

function DateConstructor() {
    var dateObj = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))()
    Object.setPrototypeOf(dateObj, DateConstructor.prototype)
    dateObj.foo = 'bar'
    return dateObj
}
Object.setPrototypeOf(DateConstructor.prototype, Date.prototype)
DateConstructor.prototype.getMyTime = function getTime() {
    return this.getTime()
}
let date = new DateConstructor()
console.log(date.getMyTime())

我们来分析一下代码,调用构造函数 DateConstructor 返回的对象 dateObj 有:

dateObj.__proto__ === DateConstructor.prototype

而我们通过:

Object.setPrototypeOf(DateConstructor.prototype, Date.prototype)

实现了:

DateConstructor.prototype.__proto__ === Date.prototype

所以连起来就是:

date.__proto__.__proto__ === Date.prototype

继续分析,DateConstructor 构造函数里,返回的 dateObj 是一个真正的 Date 对象,因为:

var dateObj = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))()var dateObj = new(Function.prototype.bind.apply(Date, [Date].concat(Array.prototype.slice.call(arguments))))()

它终归还是由 Date 构造函数实例化出来的,因此它有权调用 Date 原型上的方法,而不会被引擎限制。

整个实现过程通过更改原型关系,在构造函数里调用原生构造函数 Date,并返回其实例的方法,“欺骗了”浏览器。当然这样的做法比较取巧,其副作用是更改了原型关系,这样也会干扰浏览器某些优化操作。

那么有没有更加“体面”的方式呢?

其实随着 ES6 class 的推出,我们完全可以直接使用 extends 关键字了:

class DateConstructor extends Date {
    constructor() {
        super()
        this.foo ='bar'
    }
    getMyTime() {
        return this.getTime()
    }
}
let date = new DateConstructor()

上面的方法可以完美执行:

date.getMyTime()
// 1558921640586

直接在支持 ES6 class 的浏览器中使用完全没有问题,可是我们项目大部分都是使用 Babel 进行编译。按照 Babel 编译 class 的方法,运行其产出后,仍然会得到报错“Uncaught TypeError: this is not a Date object.”,因此我们可以得知:Babel 并没有对继承 Date 进行特殊处理,无法做到兼容。

jQuery 中的对象思想

可能你会有这样的问题:“所有的面试官都那么注重面向对象,可是我在工作中很少涉及啊?面向对象到底有什么用?”

对于这个问题我想说,“如果你没有开发大型复杂项目的经验,不具备封装抽象的思想,也许确实用不到面向对象,也很难解释为什么要有面向对象的设计和考察。”接下来,我就从 jQuery 源码架构设计入手,分析一下基本的原型以及原型链知识如何在 jQuery 源码中发挥作用。

“什么,这都哪一年了你还在说 jQuery?”

其实优秀的思想是永远不过时的,研究清楚 jQuery 的设计思想,你仍然会会受益匪浅。

我们从一个问题开始:

const pNodes = $('p')
// 我们得到一个数组
const divNodes= $('div')
// 我们得到一个数组

但是我们又可以:

const pNodes = $('p')
pNodes.addClass('className')

数组上可是没有 addClass 方法的吧?

这个问题先放一边。我们想一想$是什么?你的第一反应可能是一个函数,因此我们可以这样调用执行:

$('p')

但是你一定又见过这样的使用:

$.ajax()

那么$又是一个对象,它有 Ajax 的静态方法。

类似:

// 构造函数
function $() {
}
$.ajax = function () {
    // ...
}

实际上,我们翻看 jQuery 源码架构会发现(具体内容有删减和改动):

var jQuery = (function(){
    var $
    // ...
    $ = function(selector, context) {
        return function (selector, context) {
            var dom = []
            dom.__proto__ = $.fn
            // ...
            return dom
        }
    }
    $.fn = {
        addClass: function() {
            // ...
        },
        // ...
    }
    $.ajax = function() {
        // ...
    }
    return $
})()
window.jQuery = jQuery
window.$ === undefined && (window.$ = jQuery)

我们顺着源码分析,当调用$('p')时,最终返回的是 dom,而 dom.proto 指向了$.fn$.fn是包含了多种方法的对象集合。因此返回的结果(dom)可以在其原型链上找到 addClass 这样的方法。同理,$('span')也不例外,任何实例都不例外。

$('span').__proto__ === $.fn

同时 Ajax 方法直接挂载在构造函数$上,它是一个静态属性方法。

请你仔细体会整个 jQuery 的架构,其实翻译成 ES class 就很好理解了(不完全对等):

class $ {
  static ajax() {
    // ...
  }
  constructor(selector, context) {
    this.selector = selector
    this.context = context
    // ...
  }
  addClass() {
    //  ...
  }
}

这个应用虽然并不复杂,但还是很微妙地表现出来了面向对象的精妙设计。

类继承和原型继承的区别

上面我们已经了解了 JavaScript 中的原型继承,那么它和传统面向对象语言的类继承有什么不同呢?这就涉及编程语言范畴了,传统的面向对象语言的类继承,会引发一些问题:

  • 紧耦合问题

  • 脆弱基类问题

  • 层级僵化问题

  • 必然重复性问题

  • 大猩猩—香蕉问题

以上这些内容属于纯理论,下面我借用 Eric Elliott 的著名文章“Difference between class prototypal inheritance”,来展开说明类继承和原型继承的优劣。我们先看下图:

2021217-163948.png

通过上图,我们看出一些问题(单一继承、紧耦合以及层级分类问题),对于类 8,只想继承五边形的属性,却得到了继承链上其他并不需要的属性,比如五角星,正方形属性。这就是大猩猩/香蕉问题,“我只想要一个香蕉,但是你给我了整个森林”。

对于类 9,对比其父类,我只需要把五角星属性修改成四角星,但是五角星继承自基类 1,如果要去修改,那就会影响整个继承树(脆弱基类/层级僵化问题);好吧,我不去修改,那就需要给类 9 新建一个基类(必然重复性问题)。

那么基于原型的继承如何解决上述问题呢?

2021217-163944.gif

采用原型继承,其实本质是对象组合,可以避免复杂纵深的层级关系。当类 1 需要四角星特性的时候,只需要组合新特性即可,不会影响到其他实例。

总结

面向对象是一个永远说不完的话题,更是一个永远不会过时的话题,具备良好的面向对象架构能力,对于开发者来说至关重要。同时由于 JavaScript 面向对象的特殊性,它区别于其他语言,显得“与众不同”。我们在了解 JavaScript 原型、原型链知识的前提下,对比其他语言的思想,就变得非常重要和有意义了。

本讲内容总结如下:

Drawing 2.png

从下一讲开始,我们将深入数据结构这个话题。数据结构是算法的基础,其本身也包含了算法的部分内容。如果你想要掌握算法,一定要先有一个巩固的数据结构基础。下一讲我们将用 JavaScript 实现几个常见的数据结构,帮助你在不同的场景中,找到最为适合的数据结构处理问题。

  • 0
    点赞
  • 1
    收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:编程工作室 设计师:CSDN官方博客 返回首页
评论

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值