JS 面向对象(其一,以响应式数据为例介绍类/接口/继承)

443 阅读8分钟

前言

在读这篇文章之前,我希望你已经读过《JavaScript 面试题外的【this】》这篇文章。毕竟在 js 中面向对象离不了 this。了解 js 中 this 的设计思路,也是理解 js 面向对象的基础条件之一。

其实 js 中的 this ,设计的目的便是实现看起来像 java 的 oop 模式。

如 this 一样,js 面向对象的文章也是不知凡几,但绝大多数仅仅是介绍 js 的相关语法,讲明思路的却很少。这篇文章讲“面向对象”,但是不讲 prototype,不讲 this,不讲 class,这些语法 mdn 上搜索就能了解地八九不离十。本文讲的是如何用封装,继承,多态地方式组织代码,并以此在 js 中实现一个响应式的数据结构。

1. 通过声明式的模板创建对象

何谓响应式?一个数据(val)发生变动(setVal)时候能触发(emit)所有依赖(watcher)这个数据的模块进行更新。

// reactive.js

let val = 0
let watchers = []

// 获取值
export function getVal(){
    return val
}

// 修改方法
export function setVal(newVal){
    val = newVal
    watchers.forEach(v=>v.emit())
}

// 添加监听
export function attach(watcher){
    watchers = watchers.filter(v=>v!==watcher).concat([watcher])
}

// 取消监听
export function detach(watcher){
    watchers = watchers.filter(v=>v!==watcher)
}

以上是一个对上述描述的简单实现。

// test.js
import { getVal, setVal, attach, detach } from "reactive.js"

let v = getVal() + 1
const watcher = ()=>{v = getVal() +1} 
attach(watcher)

console.log(v) // 输出 1
setVal(1) 
console.log(v) // 输出 2

detach(watcher)
setVal(2) 
console.log(v) // 输出 2

测试一下,确实数据是响应式地进行更新。

当出现多个响应式地数据时,不可能每一个数据逐个去复写这段代码。通过一个工厂(factory)函数批量生成则是顺理成章的事情。

// reactive.js
export function factory(v = null) {
    let val = v
    let watchers = []
    return {
        getVal() {
            return val
        },
        setVal(newVal) {
            e
            return val
        },
        attach(watcher) {
            watchers = watchers.filter(v => v !== watcher).concat([watcher])
        }, 
        detach(watcher){
            watchers = watchers.filter(v=>v!==watcher)
        }
    }
}

函数实在是太方便了,同时也太灵活了。可在我们写过大量的这种工厂函数后,会认为这种灵活带来不少麻烦。工厂函数中很多一些通用的模式,无法在代码中体现出来。

  • function factory(v = null) 一个构造的入参
  • let watchers = [] 声明一个内部的变量
  • return { getVal() {return val} 声明一个暴露在外部的方法

灵活意味着想怎么写就怎么写,反正机器都能跑。但人读代码的时候却不这么轻松。甚至很多工厂函数跟其他函数无法一眼看出区别,那么一种语法糖的出现就很有必要了。

// reactive.js

// 通过 class 声明一个 “工厂函数”
export class Reactive {
    // 声明对象初始化的方法,接受外部参数
    constructor(val = undefined) {
        this.#val = val
    }

    // 声明内部私有的变量
    #val = undefined
    #watchers = []

    // 声明给外部调用的函数
    getVal() {
        return this.#val
    }

    setVal(newVal) {
        this.#val = newVal
        this.#watcher.forEach(v => v.emit(this))
    }

    updateVal(fn) {
        const newVal = fn(this.#val)
        this.setVal(newVal)
    }

    attach(watcher) {
        this.#watchers = this.#watchers.filter(v => v != watcher).concat([watcher])
    }

    detach(watcher) {
        this.#watchers = this.#watchers.filter(v => v != watcher)
    }
}

通过 class 将构造方法,私有/公开,属性/方法,以一种声明的形式表示出来,成了一个更好的创建对象的方式。

2. 通过接口表示一种公共的特征

在上述代码中似乎已经以一种显示声明的方式把一个响应式数据结构 Reactive 完成了。但代码中依赖存在一处隐式的逻辑。

this.#watcher.forEach(v => v.emit(this))

这段代码表明所有的 watcher 都需要一个 emit 方法供外部调用。

反过来是不是所有存在 emit 方法的都是一个合法的 watcher 呢?

并不尽然,毕竟会存在同名的情况。

如果以一种更加准确的方式描述 watcher ,大抵应该是这样:

一个存在 emit 方法的对象,其 emit 是接受一个 Reactive 对象作为参数,以响应这个 Reactive 数据变化的函数

满足这个特征的都是一个合法的 watcher 。无论这个对象具体是用来做什么。它可以通过 Reactive 数据衍生出来的新的 Reactive,也可以是根据这个 Reactive 变化进行一些带副作用的操作,当然也可以啥也不干……

这个特征就是接口。

对于静态类型的语言,可以通过类型签名的方式显性的提示这个行为。譬如 typesript。

type Watcher = {
    emit(r:Reactive): void
}

如果要和 class 结合起来,可以声明某个模板创建的对象都实现了哪些特征

// 声明接口
interface Watcher {
    emit(r:Reactive):void
}
// Foo 实现了 Watcher 接口
class Foo implements Watcher{
    emit(r){
        // 具体实现
    }
}

可惜的是,js 是一门动态语言,无法在提供类型标注进行声明,那么只能依赖一个一个注释了,是吗?

并不如此,对 js 语法上心的人应该似乎已经联想到了 js 中的迭代器,以及 Symbol.iterator。

Symbol.iterator 为每一个对象定义了默认的迭代器。该迭代器可以被 for...of 循环使用。

Symbol.iterator 标识了迭代器接口,实现了 Symbol.iterator 的对象既能被 for...of 迭代。

在 js 中用 Symbol 标识接口,虽然依旧不能进行静态类型检查。但是少能够避免重名问题,也能让接口的注释显得更加清晰一点。

// reactive.js

// Watcher 接口
// 是接受一个 Reactive 对象作为参数
// 以响应这个 Reactive 数据变化的函数
export const emit = Symbol()

之后为其添加一个实现 —— Effect,即根据 Reactive 变化进行一些带副作用的操作。

// reactive.js

export class Effect {
    #fn = () => { }
    constructor(fn) { this.#fn = fn }
    [emit](r) {
        this.#fn(r)
    }
}

3. 通过继承具体化一个类

目前我们已经定义了 Reactive 类表示我们这个响应式的数据。现在有一个需求,响应式的数据 double_a 根据响应式数据 a 的变动进行变动。

于是有了如下代码

// test.js

import { Reactive, Effect } from "reactive.js"

const a = new Reactive(0)

const double_a = new Reactive(a.getVal() * 2)

const eff = new Effect(() => { 
    double_a.setVal(a.getVal() * 2) 
})

a.attach(eff)

console.log(double_a.getVal()) // 输出 0
a.setVal(1)
console.log(double_a.getVal()) // 输出 2

显然类似 Reactive ,像 double_a 这种需求也会大量出现,总不能出现一次写一次吧。于是在此请上 class 为此专门类数据创造一个模板。

若是分析一下,不难发现这个值本身就是 Reactive,只不过是一类比较特殊的 Reactive,并不是手动触发更新,而是响应其他 Reactive 进行更新 —— 它同时也是 Watcher !

这种拥有 “具体化/抽象化” 关系的两个类,并不需要我们手动的把代码重复一遍,通过继承 extends 就能解决。

// reactive.js

export class Computed extends Reactive {

    #inputs = []
    #getter = (...args) => args
    #setter = null
    #deps = []

    constructor(
        inputs,
        getter = (...args) => args,
        setter = null
    ) {
        super()
        
        this.#inputs = inputs
        this.#getter = getter
        this.#setter = setter

        this.#deps = inputs.filter(v => v instanceof Reactive)
        // 在依赖数据上添加监听
        this.#deps.forEach(v => v.attach(this))
        // 更新当前数据
        this.#update()
    }

    // 当前数据更新方法
    #update() {
        super.setVal(
            this.#getter(
                ...this.#inputs.map(
                    v => v instanceof Reactive
                        ? v.getVal()
                        : v
                )
            )
        )
    }

    setVal(newVal) {
        // 如果不存在 setter ,报错
        if (!this.#setter) throw new Error(`Computed: setter is not exist!`)
        // 存在 setter 则调用 setter
        this.#setter(newVal)
    }

    // 监听数据更新则调用 #update,更新当前数据 
    [emit]() {
        this.#update()
    }

    // Computed 特有的注销方法
    destory(){
        this.#deps.forEach(v=>v.detach(this))
    }
}

对于 extends,大多人关注点在于父类与子类,方法/属性/构造函数的复用。子类默认继承父类的所有属性/方法,能够复写父类的属性/方法,能也通过(super)调用父类的属性/方法。

对于此也出现大量的争论,有的人认为这种方式直接继承了一些功能少写了很多重复代码,也有人深恶痛绝,认为这种隐式的继承带来了很多难以发现的错误,也有人号召通过接口组合而不是继承来解决复用问题。

但大多数人却忽略了语义这个层面。

例如,公犬会叫,母犬也会叫,所以公犬继承自母犬吗?不是,这时候应该抽象一个犬的父类,公犬/母犬都继承这个类。

实际开发也会出现这个情况。我开发了一个下拉列表组件和一个树组件。这时又来了一个下拉菜单树组件的需求。不少人犯了难,到底这货是该继承下拉列表还是树,还是用多继承?

显然这个组件既用到了下拉列表的代码有用到了树的代码,以代码复用,功能继承的角度应该多继承。但是站在语义的角度显然都不合适,应该将原来下拉列表拆分为下拉组件和列表组件。而下拉树组件则继承自下拉组件,内部实现一个树组件。

这样设计和我们之前的描述也相符,父类(下拉组件)和子类(下拉树/下拉列表)是一个抽象化/具象化的关系。

后记

在 es6 之前,有种声音不绝于耳 —— js并不是一门面向对象的语言,而是一门基于对象的语言。

在 es6 之后,也有种声音不绝于耳 —— js 中的 class 只不过是一个语法糖而已。

到底什么是面向对象?这篇文章就是希望能从 class,interface,extends 等关键字跳脱出来,思考一种组织代码的思路。

下一篇文章,JS 面向对象(其二),打算以实现一个 dom 模板引擎为例,介绍 UML 以及对象数据结构。如果大家觉得本文还有点内容,还请高抬贵手点个赞哈。