事件绑定与类继承结合时的最佳实践

229 阅读3分钟

原文地址: github.com/yinxin630/b…
技术交流群: fiora.suisuijiang.com/

事件绑定和类继承都是很常用的东西, 当它俩结合起来时, 可能并不会像你所想的那样工作

来看一个最简单的例子, 在构造函数中绑定 click 事件, 点击后打印 "click"this.a
在该例中 this.a 会打印什么呢? 会打印 undefined, 因为 handleClick 的 this 指向是 button dom 对象, dom 对象没有 a 属性

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title>Document</title>
    </head>
    <body>
        <button id="click">click</button>
        <script>
            class Base {
                a = 1;
                constructor() {
                    document.querySelector("#click").addEventListener("click", this.handleClick);
                }
                handleClick() {
                    console.log("click", this.a); // click undefined
                }
            }
            new Base();
        </script>
    </body>
</html>

首先, 可以用箭头函数来解决 this 指向问题, 在 react 中这种写法很常见, 这没什么问题

handleClick = () => {
    console.log("click", this.a); // click 1
};

但是, 当与类继承相结合时会怎样呢? 如下的例子中, 派生类继承基类并重载 handleClick 方法
点击后会并不会输出 "click2", 因为基类的 handleClick 是定义在实例属性上, 而派生类的 handleClick 是定义在派生类的原型链上, 实例属性访问优先级大于原型链, 所以根本没执行到派生类的 handleClick

class Base {
    a = 1;
    constructor() {
        document.querySelector("#click").addEventListener("click", this.handleClick);
    }
    handleClick = () => {
        console.log("click", this.a); // click 1
    };
}
class Derived extends Base {
    handleClick() {
        super.handleClick();
        console.log("click2", this.a); // not run
    }
}
new Derived();

尝试通过原型链直接调用派生类的 handleClick, 注意! 由于是直接调用的, super.handleClick() 不可用需要注释掉
会输出 click2 1, 但是不会调到基类方法

class Base {
    a = 1;
    handleClick = () => {
        console.log("click", this.a); // not run
    };
}
class Derived extends Base {
    handleClick() {
        //   super.handleClick();
        console.log("click2", this.a); // click2 1
    }
}
const ins = new Derived();
Derived.prototype.handleClick.call(ins);

修改一下, 将基类改为普通函数, 并在绑定事件时 bind this, 这就是我们所期望的效果了

class Base {
    a = 1;
    constructor() {
        document.querySelector("#click").addEventListener("click", this.handleClick.bind(this));
    }
    handleClick() {
        console.log("click", this.a); // click 1
    }
}
class Derived extends Base {
    handleClick() {
        super.handleClick();
        console.log("click2", this.a); // click2 1
    }
}

接下来, 我们增加一个需求, 新增一个按钮用来取消事件订阅
如下所示, 点击 unsubscribe 按钮后调用 removeEventListener 取消事件订阅, 但是并不起作用(包括注释那行)
为什么呢? 因为订阅和取消订阅的并不是同一个方法, 订阅时的 bind 调用会返回一个全新函数, 由于没有保存该函数引用, 调用 removeEventListener 也就无法将其取消订阅
怎么解决呢?

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <title>Document</title>
    </head>
    <body>
        <button id="click">click</button>
        <br />
        <button id="unsubscribe">unsubscribe</button>
        <script>
            class Base {
                a = 1;
                constructor() {
                    document.querySelector("#click").addEventListener("click", this.handleClick.bind(this));
                    document.querySelector("#unsubscribe").addEventListener("click", () => {
                        // 无法取消订阅
                        document.querySelector("#click").removeEventListener("click", this.handleClick.bind(this));
                        // document.querySelector("#click").removeEventListener("click", this.handleClick);
                    });
                }
                handleClick() {
                    console.log("click", this.a);
                }
            }
            class Derived extends Base {
                handleClick() {
                    super.handleClick();
                    console.log("click2", this.a);
                }
            }
            new Derived();
        </script>
    </body>
</html>

只要将 bind 后的实例保存下来即可, 这样就能确保订阅和取消订阅的是同一方法了, 完美达成期望

constructor() {
    this.handleClick = this.handleClick.bind(this); // 保存 bind 后方法
    document.querySelector('#click').addEventListener('click', this.handleClick);
    document.querySelector('#unsubscribe').addEventListener('click', () => {
        // 可以取消订阅
        document.querySelector('#click').removeEventListener('click', this.handleClick);
    });
}

还可以用 (www.npmjs.com/package/aut…) 装饰器自动完成 bind 操作

class Base {
    a = 1;
    constructor() {
        document.querySelector("#click").addEventListener("click", this.handleClick.bind(this));
    }
    @autobind // 装饰器
    handleClick() {
        console.log("click", this.a); // click 1
    }
}
class Derived extends Base {
    @autobind
    handleClick() {
        super.handleClick();
        console.log("click2", this.a); // click2 1
    }
}

总结

  1. 用箭头函数解决 this 绑定问题时, 该方法(其实是属性)无法被重载
  2. bind 调用会返回一个全新方法, 无法用其原方法取消事件订阅