前端必刷系列之红宝书——第 8 章

"红宝书" 通常指的是《JavaScript 高级程序设计》,这是一本由 Nicholas C. Zakas(尼古拉斯·扎卡斯)编写的 JavaScript 书籍,是一本广受欢迎的经典之作。这本书是一部翔实的工具书,满满的都是 JavaScript 知识和实用技术。

不管你有没有刷过红宝书,如果现在还没掌握好,那就一起来刷红宝书吧,go!go!go!

系列文章:

第一部分:基本知识(重点、反复阅读)

  1. 前端必刷系列之红宝书------第 1、2 章
  2. 前端必刷系列之红宝书------第 3 章
  3. 前端必刷系列之红宝书------第 4、5 章
  4. 前端必刷系列之红宝书------第 6 章

第二部分:进阶内容(重点、反复阅读)

  1. 前端必刷系列之红宝书------第 7 章
  2. 前端必刷系列之红宝书------第 8 章

第 8 章 对象、类与面向对象编程

理解对象

对象:一组属性的无序集合。

Object.defineProperty() 是 JavaScript 中用于定义或修改对象属性的方法。

通过这个方法,你可以精确地控制属性的行为,包括可写性、可枚举性、可配置性等。

该方法接受三个参数:要定义属性的对象、属性名和属性描述符对象

js 复制代码
Object.defineProperty(obj, prop, descriptor)
  • obj: 要定义属性的对象。
  • prop: 要定义或修改的属性名。
  • descriptor: 包含属性特性的对象,可以设置以下属性:
    • value: 属性的值,默认为 undefined
    • writable: 属性是否可写,布尔值,默认为 false
    • enumerable: 属性是否可枚举,布尔值,默认为 false
    • configurable: 属性是否可配置,布尔值,默认为 false
    • get: 一个获取属性值的函数。
    • set: 一个设置属性值的函数。

数据属性

js 复制代码
let obj = {};

Object.defineProperty(obj, 'name', {
    value: 'John',
    writable: true,
    enumerable: true,
    configurable: true
});

console.log(obj.name); // John

访问器属性

js 复制代码
let obj = {
    _name: 'John',
    get name() {
        console.log('Getting name');
        return this._name;
    },
    set name(value) {
        console.log('Setting name to', value);
        this._name = value;
    }
};

obj.name = 'Alice'; // Setting name to Alice
console.log(obj.name); // Getting name, Alice

可配置性

js 复制代码
let obj = {
    name: 'John'
};

Object.defineProperty(obj, 'name', {
    configurable: false
});

delete obj.name; // Error: Cannot delete property 'name' of object

Object.defineProperty() 允许你更细致地控制对象属性的行为,特别是在创建或修改对象的属性时非常有用。

Vue2 的数据响应式的原理就是借用了 Object.defineProperty()

定义多个属性

Object.defineProperties() 是 JavaScript 中用于定义或修改多个对象属性的方法。

Object.defineProperty() 不同,Object.defineProperties() 接受两个参数:要定义属性的对象和一个包含多个属性描述符的对象。

js 复制代码
Object.defineProperties(obj, descriptors)
  • obj: 要定义属性的对象。
  • descriptors: 包含多个属性特性的对象,其中每个键值对的键是属性名,值是属性描述符对象,描述符对象的结构与 Object.defineProperty() 中的描述符相同。
js 复制代码
let obj = {};

Object.defineProperties(obj, {
    name: {
        value: 'John',
        writable: true,
        enumerable: true,
        configurable: true
    },
    age: {
        value: 30,
        writable: false,
        enumerable: true,
        configurable: true
    },
    sayHello: {
        value: function() {
            console.log('Hello!');
        },
        enumerable: false,
        configurable: true
    }
});

console.log(obj.name); // John
console.log(obj.age); // 30
obj.sayHello(); // Hello!

读取属性的特性

Object.getOwnPropertyDescriptor() 是 JavaScript 中用于获取对象属性描述符的方法。它接受两个参数:要获取属性描述符的对象和属性名,然后返回一个包含该属性特性的对象。

js 复制代码
Object.getOwnPropertyDescriptor(obj, prop)
  • obj: 要获取属性描述符的对象。
  • prop: 要获取的属性名。
js 复制代码
let obj = {
    name: 'John',
    age: 30
};

let descriptor = Object.getOwnPropertyDescriptor(obj, 'name');

console.log(descriptor);
// Output:
// {
//    value: 'John',
//    writable: true,
//    enumerable: true,
//    configurable: true
// }

这个方法常用于检查属性的特性,特别是在进行属性的复杂操作或者在使用 Object.defineProperty() 时需要了解已有特性的情况。

合并对象

Object.assign() 是 JavaScript 中用于将一个或多个源对象的属性复制到目标对象的方法。它是一个浅拷贝操作,只复制对象的属性值,不复制对象的引用。如果目标对象已经有相同属性名的属性,它们将被后面的源对象覆盖。

js 复制代码
Object.assign(target, ...sources)
  • target: 目标对象,将源对象的属性复制到这个对象。
  • sources: 一个或多个源对象,它们的属性将被复制到目标对象。
js 复制代码
let target = { a: 1, b: 2 };
let source1 = { b: 3, c: 4 };
let source2 = { d: 5 };

Object.assign(target, source1, source2);

console.log(target);
// Output: { a: 1, b: 3, c: 4, d: 5 }

Object.assign() 不会递归复制对象内部的对象,而是复制对象的引用。如果源对象的属性值是对象,它们仍然会被视为同一对象。

js 复制代码
let obj = { a: 1, b: { c: 2 } };

// 使用 Object.assign() 克隆对象
let clone = Object.assign({}, obj);

console.log(clone);
// Output: { a: 1, b: { c: 2 } }

console.log(obj === clone); // false
console.log(obj.b === clone.b); // true,因为是浅拷贝

对象标识与相等判定

对象标识指的是对象在内存中的唯一标识符。两个不同的对象具有不同的标识。可以使用 === 操作符来比较对象的标识。

js 复制代码
let obj1 = { name: 'John' };
let obj2 = { name: 'John' };

console.log(obj1 === obj2); // false,不同的对象标识

相等判定是指比较对象的值是否相等。可以使用 ===== 进行相等判定。相等判定的结果取决于比较操作符的类型。

js 复制代码
let obj1 = { name: 'John' };
let obj2 = { name: 'John' };

console.log(obj1 == obj2); // false,因为对象标识不同
console.log(obj1.name == obj2.name); // true,比较对象属性值

Object.is() 是 JavaScript 中用于比较两个值是否严格相等的方法。它的行为与 === 运算符相似,但有一些区别。主要的区别在于 Object.is() 对于特殊值(+0、-0、NaN)的处理以及对对象的处理。

js 复制代码
console.log(Object.is(5, 5)); // true
console.log(Object.is(5, '5')); // false
console.log(Object.is(-0, 0)); // false
console.log(Object.is(NaN, NaN)); // true

console.log(Object.is(+0, -0)); // false
console.log(Object.is(+0, 0)); // true
console.log(Object.is(-0, 0)); // false

增强的对象语法

js 复制代码
// 属性值的简写
let name = "John";
let age = 30;

let person = { name, age };

// 方法的简写
let person = {
    name: "John",
    sayHello() {
        console.log("Hello!");
    }
};

// 计算属性名
let prop = "name";

let person = {
    [prop]: "John"
};

对象解构

js 复制代码
// 对象的解构
let person = { name: "John", age: 30 };

let { name, age } = person;

console.log(name); // "John"
console.log(age);  // 30

// 别名
let person = { name: "John", age: 30 };

// 使用对象解构,同时给变量取别名
let { name: fullName, age: years } = person;

console.log(fullName); // "John"
console.log(years);     // 30

// 默认值
let person = { name: "John" };
// 使用对象解构,设置默认值
let { name, age = 25 } = person;

console.log(name); // "John"
console.log(age);  // 25,age 没有在 person 对象中定义,使用了默认值

// 嵌套解构
let person = { name: "John", address: { city: "New York", zip: "10001" } };

// 嵌套对象解构
let { name, address: { city, zip } } = person;

console.log(name); // "John"
console.log(city); // "New York"
console.log(zip);  // "10001"

// 剩余属性
let person = { name: "John", age: 30, city: "New York" };

let { name, ...rest } = person;

console.log(name); // "John"
console.log(rest); // { age: 30, city: "New York" }

// 扩展运算符
let person = { name: "John", age: 30 };
let additionalInfo = { city: "New York", occupation: "Developer" };

let combined = { ...person, ...additionalInfo };

创建对象

工厂模式

使用工厂函数来创建对象,工厂函数实际上是一个返回新对象的函数。

js 复制代码
function createPerson(name, age, gender) {
    return {
        name: name,
        age: age,
        gender: gender
    };
}

let person = createPerson("John", 30, "Male");

工厂模式虽然可以解决创建多个类似对象的问题,但是没有解决对象标识问题(即新创建对象是什么类型)

构造函数模式

构造函数模式是一种使用构造函数来创建对象的方式。通过使用 new 关键字调用构造函数,可以创建一个新的对象,并且构造函数中的 this 关键字引用了这个新创建的对象。

构造函数名称的首字母都是要大写的。

js 复制代码
// 构造函数
function Person(name, age, gender) {
    // 使用 this 关键字引用新创建的对象
    this.name = name;
    this.age = age;
    this.gender = gender;

    // 如果需要添加方法,也可以在构造函数内部定义
    this.sayHello = function() {
        console.log("Hello, my name is " + this.name);
    };
}

// 使用构造函数创建对象
let person1 = new Person("John", 30, "Male");
let person2 = new Person("Alice", 25, "Female");

// 访问属性
console.log(person1.name); // John
console.log(person2.age);  // 25

// 调用方法
person1.sayHello(); // Hello, my name is John
person2.sayHello(); // Hello, my name is Alice

person1 instanceof Object // true
person1 instanceof Person // true

定义自定义构造函数可以确保实例被标识为特定类型,相比工厂模式,这是一个很大的好处。但是也有一个弊端,就是导致自定义类型引用的代码不能很好的聚集在一起。

原型模式

对象的原型模式是 JavaScript 中的一种创建和继承对象的方式

每个对象都有一个原型(prototype)属性,原型是一个对象,它包含可以被共享的属性和方法。

当试图访问一个对象的属性或方法时,如果对象本身没有定义这个属性或方法,JavaScript 会从对象的原型链中寻找。

对象的原型链指的是从对象自身开始,一直到达原型链的顶端。可以通过对象的 __proto__ 属性访问其原型。

js 复制代码
let person = { name: "John" };
console.log(person.__proto__ === Object.prototype); // true

需要注意的是,如果在构造函数中定义方法,每次创建对象时都会为该方法分配新的内存空间。如果有多个实例共享相同的方法,推荐将方法定义在构造函数的原型上,以避免每次创建对象都复制一份方法。

JS 复制代码
function Person(name, age, gender) {
    this.name = name;
    this.age = age;
    this.gender = gender;
}

// 在构造函数的原型上定义方法
Person.prototype.sayHello = function() {
    console.log("Hello, my name is " + this.name);
};

let person1 = new Person("John", 30, "Male");
let person2 = new Person("Alice", 25, "Female");

person1.sayHello(); // Hello, my name is John
person2.sayHello(); // Hello, my name is Alice

person1 instanceof Object // true
person1 instanceof Person // true

isPrototypeOf() 是 JavaScript 中 Object 对象的一个方法,用于检测一个对象是否是另一个对象的原型。该方法返回一个布尔值,如果调用该方法的对象是传入的对象的原型,返回 true,否则返回 false

js 复制代码
// 定义一个原型对象
let personPrototype = {
    sayHello: function() {
        console.log("Hello!");
    }
};

// 创建一个对象,并将 personPrototype 设置为其原型
let person = Object.create(personPrototype);

// 使用 isPrototypeOf() 判断原型关系
console.log(personPrototype.isPrototypeOf(person)); // true

// 使用 Object.getPrototypeOf() 获取对象的原型 
let prototypeOfPerson = Object.getPrototypeOf(person); 
console.log(prototypeOfPerson === personPrototype); // true

Object.setPrototypeOf()可能会严重影响代码性能。不推荐。

为了避免使用 Object.setPrototypeOf() 可能造成的性能下降,可以通过 Object.create() 来创建一个新对象,同时为其指定原型:

js 复制代码
let biped = {
    numLength: 2
}
let person = Object.create(biped)
person.name = 'Matt'

person.name // Matt
person.numLength // 2
Object.getPrototypeOf(person) === biped // true
  • Object.keys(obj): 返回一个包含对象所有可枚举属性的数组。
  • Object.values(obj): 返回一个包含对象所有可枚举属性值的数组。
  • Object.entries(obj): 返回一个包含对象所有可枚举属性键值对的数组。
  • Object.hasOwnProperty(prop): 检查对象是否包含指定属性,不会检查原型链。

for-in 循环和 Object.assign() 的枚举顺序是不确定的,取决于 js 引擎,可能因浏览器而异。

Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()、Object.assgin() 的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。

继承

很多面向对象语言都支持两种继承:接口继承和实现继承

实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。

原型链继承

原型链继承方式的基本思想:通过原型继承多个引用类型的属性和方法。

构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指向构造函数,而实例有一个内部指针指向原型

原型链是 JavaScript 中用于实现对象继承的一种机制。每个对象都有一个原型对象,而原型对象也可以有自己的原型,形成一个链式结构。

通过原型链,JavaScript 实现了对象之间的继承关系。子对象可以访问父对象的属性和方法,形成了一种基于原型的继承。

js 复制代码
// 父构造函数
function Animal(name) {
    this.name = name;
}

// 在父构造函数的原型上添加方法
Animal.prototype.makeSound = function() {
    console.log("Generic animal sound");
};

// 子构造函数
function Dog(name, breed) {
    // 实现对父构造函数的属性继承
    Animal.call(this, name);
    this.breed = breed;
}

// 通过原型链继承
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog;

// 创建子对象实例
let myDog = new Dog("Buddy", "Golden Retriever");

// 调用继承的方法
myDog.makeSound(); // Generic animal sound

原型链继承的问题

  1. 共享问题

原型链继承导致子对象实例共享父对象原型上的属性和方法。如果在子对象实例上修改原型上的属性,会影响到所有子对象实例。

js 复制代码
let dog1 = new Dog("Buddy", "Golden Retriever");
let dog2 = new Dog("Max", "Labrador");

dog1.makeSound(); // Generic animal sound
dog2.makeSound(); // Generic animal sound

// 修改原型上的属性,会影响所有子对象实例
Dog.prototype.makeSound = function() {
    console.log("Woof! Woof!");
};

dog1.makeSound(); // Woof! Woof!
dog2.makeSound(); // Woof! Woof!
  1. 构造函数参数传递问题

在原型链继承中,子构造函数调用父构造函数时传递的参数存在一定的限制,可能不够灵活。无法在创建子对象实例时向父构造函数传递参数,因为父构造函数的调用发生在子对象实例创建之前。

js 复制代码
function Animal(name) {
    this.name = name;
}

function Dog(breed) {
    Animal.call(this, "DefaultName"); // 无法传递具体的名字参数
    this.breed = breed;
}

let myDog = new Dog("Golden Retriever");
console.log(myDog.name); // DefaultName
  1. 原型链深层次的问题

在原型链继承中,可能会存在多层嵌套的原型链,导致属性查找的性能问题。在查找属性时,如果找不到,JavaScript 引擎会沿着原型链向上查找,直到找到或者到达链的顶端。

js 复制代码
function Animal() {}

function Dog() {}

Dog.prototype = new Animal();

let myDog = new Dog();

// 原型链深度过大可能影响性能

盗用构造函数

盗用构造函数(Constructor Stealing)是一种通过在子构造函数中调用父构造函数来实现继承的技术。它是继承的一种形式,旨在解决原型链继承的一些问题,如共享属性和构造函数参数传递的限制。

在盗用构造函数的模式中,子构造函数使用父构造函数的实例作为自己的实例,从而达到复用父构造函数的属性的目的。这样,父构造函数的属性不再被共享,而且可以向父构造函数传递参数。

js 复制代码
// 父构造函数
function Animal(name) {
    this.name = name;
    this.sound = "Generic animal sound";
}

// 子构造函数
function Dog(name, breed) {
    // 盗用构造函数
    Animal.call(this, name);
    
    this.breed = breed;
}

// 创建子对象实例
let myDog = new Dog("Buddy", "Golden Retriever");

console.log(myDog.name);   // Buddy
console.log(myDog.sound);  // Generic animal sound
console.log(myDog.breed);  // Golden Retriever

盗用构造函数的问题:

  1. 方法不共享: 由于方法都在构造函数内部定义,它们不会被共享。每个实例都有自己的方法副本,可能导致内存浪费。
  2. 无法继承原型链上的方法: 通过盗用构造函数,无法继承父构造函数原型链上的方法,这减少了代码的复用性。

组合继承

组合继承是一种在 JavaScript 中实现继承的模式,结合了构造函数继承和原型链继承的优点,避免了它们各自的缺点。在组合继承中,使用构造函数来继承属性,同时通过原型链来继承方法,以实现更有效的继承。

js 复制代码
// 父构造函数
function Animal(name) {
    this.name = name;
}

// 在父构造函数的原型上添加方法
Animal.prototype.makeSound = function() {
    console.log("Generic animal sound");
};

// 子构造函数
function Dog(name, breed) {
    // 继承属性(通过构造函数调用)
    Animal.call(this, name);
    
    this.breed = breed;
}

// 继承方法(通过原型链)
Dog.prototype = new Animal();
Dog.prototype.constructor = Dog;

// 子对象实例
let myDog = new Dog("Buddy", "Golden Retriever");

// 调用继承的方法
myDog.makeSound(); // Generic animal sound

组合继承的问题:

  1. 原型链上多余的实例属性: 子构造函数通过 new Animal() 创建了一个实例,这个实例包含了一些不必要的属性,可能会影响性能。
  2. 构造函数调用两次: 子构造函数调用了两次父构造函数,一次是通过 Animal.call(this, name),一次是通过 new Animal(),可能导致一些不必要的计算和内存浪费。

寄生式继承

寄生式继承的思路:类似于寄生构造函数和工厂模式,创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象

js 复制代码
// 原始对象
function createAnimal(name) {
    let animal = {
        name: name,
        makeSound: function() {
            console.log("Generic animal sound");
        }
    };

    // 在原始对象基础上增强
    animal.makeCustomSound = function(customSound) {
        console.log(customSound);
    };

    return animal;
}

// 创建继承对象
function createDog(name, breed) {
    let dog = createAnimal(name);

    // 在继承对象基础上增强
    dog.breed = breed;

    // 重写方法
    let originalMakeSound = dog.makeSound;
    dog.makeSound = function() {
        originalMakeSound.call(this); // 调用原始方法
        console.log("Woof! Woof!");
    };

    return dog;
}

// 使用寄生式继承创建对象
let myDog = createDog("Buddy", "Golden Retriever");

// 调用继承对象的方法
myDog.makeSound(); // Generic animal sound \n Woof! Woof!
myDog.makeCustomSound("Custom sound!"); // Custom sound!

寄生式继承问题:

  1. 不同实例之间的方法不共享: 寄生式继承中,每个对象的方法都是独立复制的,不会共享,可能导致一定的内存浪费。
  2. 构造函数不能传递参数: 与寄生构造函数继承类似,寄生式继承无法传递参数给构造函数,因为并没有使用 new 操作符来调用构造函数。

寄生式组合继承

寄生式组合继承是一种结合构造函数继承和寄生式继承的继承模式,旨在避免组合继承的缺点。组合继承的问题在于它会调用两次父构造函数,而寄生式组合继承通过寄生式继承来继承父类的原型,避免了不必要的构造函数调用。

js 复制代码
// 父构造函数
function Animal(name) {
    this.name = name;
}

// 在父构造函数的原型上添加方法
Animal.prototype.makeSound = function() {
    console.log("Generic animal sound");
};

// 子构造函数
function Dog(name, breed) {
    // 继承属性(通过构造函数调用)
    Animal.call(this, name);

    this.breed = breed;
}

// 寄生式继承父构造函数的原型
function inheritPrototype(child, parent) {
    // 创建一个空的对象,该对象的原型指向父构造函数的原型
    let prototype = Object.create(parent.prototype);
    // 将子构造函数的原型指向该空对象
    child.prototype = prototype;
    // 修复子构造函数的构造器
    child.prototype.constructor = child;
}

// 使用寄生式组合继承
inheritPrototype(Dog, Animal);

// 在子构造函数的原型上添加方法
Dog.prototype.makeSound = function() {
    // 调用父构造函数原型上的方法
    Animal.prototype.makeSound.call(this);
    console.log("Woof! Woof!");
};

// 创建子对象实例
let myDog = new Dog("Buddy", "Golden Retriever");

// 调用继承的方法
myDog.makeSound(); // Generic animal sound \n Woof! Woof!

// 检查构造器
console.log(myDog.constructor); // Dog

定义类也有两种主要方式:类声明和类表达式

与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能

另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制

js 复制代码
// 类声明
class Person {}

// 类表达式
const Animal = class {}

使用 new 调用类的构造函数会执行如下操作:

  1. 在内存中创建一个新对象
  2. 在这个新对象内部的 [[Prototype]] 指针被赋值为构造函数的 prototype 属性。
  3. 构造函数内部的 this 被赋值为这个新对象。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象,否则返回刚创建的新对象。

在 ES6 中,使用 classextends 语法糖,继承关系更加清晰,而不需要手动操作原型链。 super 关键字用于在子类的构造函数中调用父类的构造函数,实现对父类属性的继承。

js 复制代码
// 父类
class Animal {
    constructor(name) {
        this.name = name;
    }

    makeSound() {
        console.log("Generic animal sound");
    }
}

// 子类继承父类
class Dog extends Animal {
    constructor(name, breed) {
        // 调用父类构造函数,实现属性继承
        super(name);

        this.breed = breed;
    }

    // 子类添加自己的方法
    bark() {
        console.log("Woof! Woof!");
    }
}

// 创建子类实例
let myDog = new Dog("Buddy", "Golden Retriever");

// 调用继承的方法
myDog.makeSound(); // Generic animal sound
// 调用子类自己的方法
myDog.bark(); // Woof! Woof!

super 使用的几个注意问题:

  • super 只能在派生类构造函数和静态方法中使用。
  • 不能单独引用 super,要么用它调用构造函数,要么用它引用静态方法。
  • 调用 super() 会调用父类构造函数,并将返回实例赋值给 this。
  • super 行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
  • 如果没有定义类构造函数,在实例化派生类会调用 super(),而且会传入所有传给派生类的参数。
  • 在类构造函数中,不能在调用 super() 之前引用 this。
  • 如果派生类中显示定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象。

小结

本章节内容真的很多,而且很重要,需要反复阅读。

未完待续...

参考资料

《JavaScript 高级程序设计》(第 4 版)

相关推荐
GIS开发特训营2 分钟前
Vue零基础教程|从前端框架到GIS开发系列课程(七)响应式系统介绍
前端·vue.js·前端框架·gis开发·webgis·三维gis
Cachel wood28 分钟前
python round四舍五入和decimal库精确四舍五入
java·linux·前端·数据库·vue.js·python·前端框架
学代码的小前端30 分钟前
0基础学前端-----CSS DAY9
前端·css
joan_8534 分钟前
layui表格templet图片渲染--模板字符串和字符串拼接
前端·javascript·layui
程序猿进阶34 分钟前
深入解析 Spring WebFlux:原理与应用
java·开发语言·后端·spring·面试·架构·springboot
还是大剑师兰特1 小时前
什么是尾调用,使用尾调用有什么好处?
javascript·大剑师·尾调用
m0_748236111 小时前
Calcite Web 项目常见问题解决方案
开发语言·前端·rust
Watermelo6171 小时前
详解js柯里化原理及用法,探究柯里化在Redux Selector 的场景模拟、构建复杂的数据流管道、优化深度嵌套函数中的精妙应用
开发语言·前端·javascript·算法·数据挖掘·数据分析·ecmascript
m0_748248941 小时前
HTML5系列(11)-- Web 无障碍开发指南
前端·html·html5
m0_748235612 小时前
从零开始学前端之HTML(三)
前端·html