MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript子类的安全机制

2021-08-311.7k 阅读

JavaScript 中的继承基础

在深入探讨 JavaScript 子类的安全机制之前,我们先来回顾一下 JavaScript 中的继承是如何工作的。JavaScript 通过原型链实现继承,这是一种独特的方式,与一些传统面向对象语言(如 Java、C++)基于类的继承有所不同。

原型链继承

在 JavaScript 中,每个函数都有一个 prototype 属性,它是一个对象,包含了希望被共享的属性和方法。当一个函数被用作构造函数来创建对象时,新创建的对象会通过 __proto__ 属性(现代 JavaScript 推荐使用 Object.getPrototypeOf()Object.setPrototypeOf() 方法来操作原型)链接到构造函数的 prototype 对象。这就形成了一条原型链。

例如:

function Animal(name) {
    this.name = name;
}

Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};

function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}

// 设置 Dog 的原型为 Animal 的实例,建立继承关系
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    console.log(this.name +'barks.');
};

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // 输出: Buddy makes a sound.
myDog.bark();  // 输出: Buddy barks.

在上述代码中,Dog 函数通过 Object.create(Animal.prototype) 设置其原型为 Animal 原型的一个实例,从而继承了 Animal 的属性和方法。Animal.call(this, name) 则确保 Dog 实例正确地初始化 name 属性。

ES6 类继承

ES6 引入了 class 关键字,使 JavaScript 的继承语法更接近传统面向对象语言。实际上,class 只是基于原型链继承的语法糖。

class Animal {
    constructor(name) {
        this.name = name;
    }

    speak() {
        console.log(this.name +'makes a sound.');
    }
}

class Dog extends Animal {
    constructor(name, breed) {
        super(name);
        this.breed = breed;
    }

    bark() {
        console.log(this.name +'barks.');
    }
}

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.speak(); // 输出: Buddy makes a sound.
myDog.bark();  // 输出: Buddy barks.

这里,class Dog extends Animal 表明 Dog 类继承自 Animal 类。super(name) 调用父类的构造函数来初始化 name 属性。

子类安全机制的重要性

随着 JavaScript 在大型应用程序、服务器端(如 Node.js)以及复杂前端框架中的广泛使用,确保子类的安全性变得至关重要。以下是一些原因:

防止意外修改

在大型代码库中,不同的开发人员可能会在不同时间对继承体系进行修改。如果没有适当的安全机制,可能会意外地修改子类的行为,导致难以调试的错误。例如,一个开发人员可能在父类中添加了一个新方法,而这个方法在子类中有不同的预期行为,由于没有合适的保护措施,子类可能会受到影响。

数据完整性

子类通常会扩展或修改父类的行为。如果没有安全机制,子类可能会以不正确的方式访问或修改父类的数据,破坏数据的完整性。例如,一个表示账户余额的父类,子类可能会在没有正确验证的情况下修改余额,导致账户数据出现错误。

安全漏洞

在涉及安全敏感信息的应用程序(如金融应用、身份验证系统)中,不安全的子类可能会引入安全漏洞。恶意代码可能通过子类的不当行为获取或修改敏感数据。例如,一个处理用户认证的父类,子类可能会错误地重写认证方法,使得未经授权的访问得以通过。

保护子类的属性和方法

封装

封装是面向对象编程的一个重要原则,在 JavaScript 中也同样适用。通过将属性和方法封装在内部,可以控制外部对它们的访问。

在 ES6 类中,可以使用 # 前缀来表示私有字段。

class BankAccount {
    #balance;

    constructor(initialBalance) {
        this.#balance = initialBalance;
    }

    getBalance() {
        return this.#balance;
    }

    deposit(amount) {
        if (amount > 0) {
            this.#balance += amount;
        }
    }

    withdraw(amount) {
        if (amount > 0 && amount <= this.#balance) {
            this.#balance -= amount;
        }
    }
}

class SavingsAccount extends BankAccount {
    constructor(initialBalance, interestRate) {
        super(initialBalance);
        this.interestRate = interestRate;
    }

    applyInterest() {
        const balance = this.getBalance();
        const interest = balance * this.interestRate;
        this.deposit(interest);
    }
}

const mySavings = new SavingsAccount(1000, 0.05);
mySavings.applyInterest();
console.log(mySavings.getBalance()); // 输出: 1050

在上述代码中,BankAccount 类的 #balance 字段是私有的,子类 SavingsAccount 不能直接访问 #balance,只能通过父类提供的公共方法(如 getBalancedepositwithdraw)来操作余额。这确保了余额的修改是经过验证的,保护了数据的完整性。

访问修饰符模拟

虽然 JavaScript 没有像 Java 那样的显式 publicprivateprotected 访问修饰符,但可以通过约定和闭包来模拟。

function Animal(name) {
    let _name = name;

    this.getName = function() {
        return _name;
    };

    this.setName = function(newName) {
        // 这里可以添加验证逻辑
        _name = newName;
    };

    this.speak = function() {
        console.log(_name +'makes a sound.');
    };
}

function Dog(name, breed) {
    Animal.call(this, name);
    this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
    console.log(this.getName() +'barks.');
};

const myDog = new Dog('Buddy', 'Golden Retriever');
myDog.bark();  // 输出: Buddy barks.
// 不能直接访问 _name,只能通过 getName 和 setName 方法
// console.log(myDog._name); // 这会导致错误

在这个例子中,通过在构造函数内部使用 let _name 来模拟私有变量。外部代码不能直接访问 _name,只能通过 getNamesetName 方法进行访问和修改,这在一定程度上保护了子类对父类数据的访问。

防止原型链污染

理解原型链污染

原型链污染是一种安全漏洞,攻击者可以通过修改原型对象,影响整个继承链上的对象。例如:

// 恶意代码
Object.prototype.evilMethod = function() {
    console.log('I am an evil method added to the prototype chain!');
};

function MyClass() {}

const myObj = new MyClass();
myObj.evilMethod(); // 输出: I am an evil method added to the prototype chain!

在上述代码中,恶意代码在 Object.prototype 上添加了一个方法,由于所有对象都继承自 Object,所以 MyClass 的实例 myObj 也会受到影响。

防止原型链污染的方法

  1. 使用 Object.create(null):创建没有原型的对象可以防止原型链污染。
const myObj = Object.create(null);
// 不会受到原型链污染,因为没有原型
// myObj.evilMethod(); // 这会导致错误,因为没有 evilMethod 方法
  1. 严格模式:在严格模式下,对不可写、不可配置的属性进行赋值会抛出错误,这有助于防止意外修改原型。
function MyClass() {
    'use strict';
    // 这里尝试修改不可写的原型属性会抛出错误
}
  1. 避免直接修改 Object.prototype:永远不要在代码中直接修改 Object.prototype,除非你非常清楚自己在做什么。如果需要扩展对象的功能,可以使用 Object.defineProperty()Object.defineProperties() 来定义新的属性,这样可以更精确地控制属性的特性。

子类中的方法重写安全

方法重写的潜在问题

当子类重写父类的方法时,如果不小心,可能会导致意外的行为。例如,重写的方法可能没有正确调用父类的方法,导致父类的初始化或逻辑没有执行。

class Shape {
    constructor() {
        this.initialized = false;
    }

    draw() {
        this.initialized = true;
        console.log('Drawing a shape.');
    }
}

class Circle extends Shape {
    constructor(radius) {
        super();
        this.radius = radius;
    }

    draw() {
        // 没有调用 super.draw(),导致 initialized 不会被设置为 true
        console.log('Drawing a circle with radius'+ this.radius);
    }
}

const myCircle = new Circle(5);
myCircle.draw();
console.log(myCircle.initialized); // 输出: false

在上述代码中,Circle 类重写了 draw 方法,但没有调用 super.draw(),导致 initialized 没有被正确设置。

安全的方法重写

  1. 总是调用 super 方法:在重写方法时,确保在适当的位置调用 super 方法,以执行父类的逻辑。
class Shape {
    constructor() {
        this.initialized = false;
    }

    draw() {
        this.initialized = true;
        console.log('Drawing a shape.');
    }
}

class Circle extends Shape {
    constructor(radius) {
        super();
        this.radius = radius;
    }

    draw() {
        super.draw();
        console.log('Drawing a circle with radius'+ this.radius);
    }
}

const myCircle = new Circle(5);
myCircle.draw();
console.log(myCircle.initialized); // 输出: true
  1. 遵循 Liskov 替换原则:Liskov 替换原则指出,子类应该可以替换其父类,并且程序的行为不会发生改变。这意味着子类重写的方法应该保持与父类方法相同的行为契约。例如,父类方法的输入和输出类型、前置条件和后置条件在子类重写时应该保持一致。
class Rectangle {
    constructor(width, height) {
        this.width = width;
        this.height = height;
    }

    getArea() {
        return this.width * this.height;
    }
}

class Square extends Rectangle {
    constructor(side) {
        super(side, side);
    }

    // 保持与父类相同的行为契约
    getArea() {
        return super.getArea();
    }
}

const myRectangle = new Rectangle(4, 5);
const mySquare = new Square(5);

console.log(myRectangle.getArea()); // 输出: 20
console.log(mySquare.getArea());  // 输出: 25

在这个例子中,Square 类继承自 Rectangle 类,并且重写了 getArea 方法,但保持了与父类相同的行为,符合 Liskov 替换原则。

子类的类型安全

类型检查

在 JavaScript 中,由于其动态类型的特性,类型错误可能在运行时才被发现。对于子类来说,确保传递给方法的参数类型正确非常重要。

  1. 使用 typeof 进行基本类型检查
class Calculator {
    add(a, b) {
        if (typeof a!== 'number' || typeof b!== 'number') {
            throw new Error('Both arguments must be numbers.');
        }
        return a + b;
    }
}

class ScientificCalculator extends Calculator {
    squareRoot(x) {
        if (typeof x!== 'number') {
            throw new Error('Argument must be a number.');
        }
        if (x < 0) {
            throw new Error('Cannot calculate square root of a negative number.');
        }
        return Math.sqrt(x);
    }
}

const myCalculator = new ScientificCalculator();
// myCalculator.add(2, '3'); // 这会抛出错误
console.log(myCalculator.add(2, 3)); // 输出: 5
console.log(myCalculator.squareRoot(9)); // 输出: 3
  1. 使用 instanceof 进行对象类型检查:当处理对象参数时,可以使用 instanceof 来检查对象是否是某个类的实例。
class Animal {}

class Dog extends Animal {}

function feed(animal) {
    if (!(animal instanceof Animal)) {
        throw new Error('Argument must be an instance of Animal.');
    }
    console.log('Feeding the animal.');
}

const myDog = new Dog();
feed(myDog); // 输出: Feeding the animal.
// feed({}); // 这会抛出错误

类型断言

有时候,开发人员知道某个变量的实际类型,但 JavaScript 的类型系统无法自动推断。这时可以使用类型断言。

class Shape {}

class Circle extends Shape {
    constructor(radius) {
        super();
        this.radius = radius;
    }
}

function drawShape(shape) {
    if (shape instanceof Circle) {
        const circle = shape as Circle;
        console.log('Drawing a circle with radius'+ circle.radius);
    } else {
        console.log('Drawing other shape.');
    }
}

const myCircle = new Circle(5);
drawShape(myCircle); // 输出: Drawing a circle with radius 5

在上述代码中,通过 const circle = shape as Circle 进行类型断言,告诉编译器 shape 实际上是 Circle 类型,这样就可以访问 Circle 特有的属性 radius

总结与最佳实践

  1. 使用封装:通过私有字段(ES6 的 # 前缀)或闭包模拟的私有变量来保护属性,防止子类和外部代码直接访问和修改。
  2. 避免原型链污染:不直接修改 Object.prototype,使用 Object.create(null) 创建无原型对象,启用严格模式。
  3. 安全地重写方法:在子类重写方法时,确保调用 super 方法以执行父类逻辑,遵循 Liskov 替换原则。
  4. 类型安全:使用 typeofinstanceof 进行类型检查,必要时使用类型断言来确保类型安全。

通过遵循这些安全机制和最佳实践,可以构建更健壮、安全的 JavaScript 子类继承体系,减少潜在的错误和安全漏洞。在实际开发中,尤其是在大型项目中,这些措施对于代码的可维护性和安全性至关重要。同时,随着 JavaScript 语言的不断发展,新的特性和工具可能会进一步增强子类的安全机制,开发人员需要持续关注并学习相关知识。