JavaScript 对象操作进阶:从属性描述符到对象创建模式

背景与收益

在实际开发中,我们经常遇到这样的场景:需要批量创建结构相似的对象,或者需要精确控制对象属性的行为(可写、可枚举、可配置等)。如果只用最基础的对象字面量和 Object.defineProperty,代码会变得冗长且难以维护。

本文将带你深入理解:

  • 如何高效地批量定义对象属性及其描述符
  • JavaScript 提供的对象限制方法及其实战应用场景
  • 创建多个同类对象的最佳实践:工厂模式 vs 构造函数

适合已掌握 JavaScript 基础语法、希望提升对象操作能力的开发者。


一、批量定义对象属性

1.1 问题场景

在上一章节中,我们学习了 Object.defineProperty 来定义单个属性的描述符。但实际开发中,一个对象往往有多个属性需要配置。如果每个属性都调用一次 defineProperty,代码会非常冗余:

js 复制代码
let obj = { JS: 1 };

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

Object.defineProperty(obj, 'age', {
  value: 18,
  writable: false,
  enumerable: true,
  configurable: true
});

能否通过遍历来优化?当然可以。

1.2 手动实现批量定义

我们可以将多个属性的描述符封装成对象,然后遍历处理:

js 复制代码
let obj = {
  JS: 1
};

let props = {
  name: {
    value: 'XiaoWu',
    writable: true,
    enumerable: true,
    configurable: true
  },
  age: {
    value: 18,
    writable: false,
    enumerable: true,
    configurable: true
  }
};

function defineProperties(obj, properties) {
  for (let prop in properties) {
    // hasOwnProperty 用于判断是否为对象自有属性(非继承属性)
    if (properties.hasOwnProperty(prop)) {
      Object.defineProperty(obj, prop, properties[prop]);
    }
  }
  return obj;
}

defineProperties(obj, props);

console.log(obj.name);  // XiaoWu
console.log(obj.age);   // 18

1.3 原生方法:Object.defineProperties

JavaScript 原生提供了 Object.defineProperties 方法,功能与我们手动实现的一致,但处理了更多边界情况:

js 复制代码
Object.defineProperties(obj, props);

实战案例:私有属性的访问控制

在实际开发中,我们常用 _ 前缀标识私有属性,并通过 getter/setter 控制访问:

js 复制代码
var obj = {
  _age: 20  // 私有属性,存储真实数据
};

Object.defineProperties(obj, {
  name: {
    configurable: true,
    enumerable: true,
    value: "小吴",
    writable: true
  },
  age: {
    configurable: false,
    enumerable: false,  // 不可枚举,for-in 遍历时不会出现
    get: function() {
      return this._age;
    },
    set: function(value) {
      this._age = value;
    }
  }
});

console.log(obj.age);  // 20
console.log(obj);      // { _age: 20, name: '小吴' }  注意:age 不可枚举
obj.age = 18;
console.log(obj.age);  // 18

设计思想

  • _age 是真实数据存储,外部不应直接访问
  • age 是对外暴露的接口,通过 getter/setter 控制访问逻辑
  • 这种"马甲模式"可以在 setter 中加入校验、日志等逻辑,保证数据安全

1.4 对象字面量中的 getter/setter

除了使用 defineProperties,我们也可以直接在对象字面量中定义 getter/setter:

js 复制代码
var obj = {
  _age: 20,
  set age(value) {
    this._age = value;
  },
  get age() {
    return this._age;
  }
};

两种写法的差异

写法 控制台输出 精细控制
对象字面量 { _age: 20, age: [Getter/Setter] } 无法配置 configurable/enumerable
defineProperties { _age: 20 } 可精确控制所有描述符

图 1:getter/setter 在终端的表达形式

选择建议

  • 简单场景:直接在对象字面量中定义,代码更简洁
  • 需要精细控制(如设置不可枚举):使用 defineProperties

二、对象方法补充

2.1 获取属性描述符

之前我们提到,[[]] 标记的内部属性无法直接访问,需要通过特定 API 获取:

js 复制代码
// 获取单个属性的描述符
Object.getOwnPropertyDescriptor(obj, prop);

// 获取所有自有属性的描述符
Object.getOwnPropertyDescriptors(obj);

示例

js 复制代码
var obj = {
  names: "小吴",
  age: 18
};

console.log(Object.getOwnPropertyDescriptor(obj, 'names'));
// { value: '小吴', writable: true, enumerable: true, configurable: true }

console.log(Object.getOwnPropertyDescriptors(obj));
// {
//   names: { value: '小吴', writable: true, enumerable: true, configurable: true },
//   age: { value: 18, writable: true, enumerable: true, configurable: true }
// }

图 2:obj 对象的属性描述符详情

2.2 对象限制方法

JavaScript 提供了三个方法来限制对象的可变性,它们的限制程度逐级递增:

2.2.1 Object.preventExtensions - 禁止扩展

禁止给对象添加新属性,但可以修改和删除现有属性:

js 复制代码
var obj = {
  names: "小吴",
  age: 18
};

Object.preventExtensions(obj);
obj.newProperty = 'new';  // 添加失败(严格模式下报错)
console.log(obj.newProperty);  // undefined

2.2.2 Object.seal - 密封对象

preventExtensions 基础上,将所有现有属性的 configurable 设为 false,禁止删除和重新配置属性:

js 复制代码
Object.seal(obj);
delete obj.age;  // 删除失败
console.log(obj.age);  // 18
obj.names = "JS高级";  // 可以修改值
console.log(obj.names);  // JS高级

2.2.3 Object.freeze - 冻结对象

seal 基础上,将所有现有属性的 writable 设为 false,完全冻结对象:

js 复制代码
Object.freeze(obj);
obj.names = "why";  // 修改失败
console.log(obj.names);  // JS高级

实战应用:Vue 性能优化

在 Vue 中,响应式系统会劫持对象的 getter/setter。如果有大量静态数据(如几十万条配置数据)不需要响应式,可以用 Object.freeze 冻结,避免 Vue 进行响应式处理,显著提升性能:

js 复制代码
// 大量静态数据
const staticData = Object.freeze([
  { id: 1, name: '数据1' },
  { id: 2, name: '数据2' },
  // ... 几十万条
]);

export default {
  data() {
    return {
      list: staticData  // 不会被 Vue 响应式处理
    };
  }
};

三种方法对比

方法 禁止新增 禁止删除 禁止修改值 禁止重新配置
preventExtensions
seal
freeze

三、创建多个对象的方案

3.1 问题场景

假设我们需要创建多个 Person 对象,每个对象都有 name、age、sex、address 等属性,以及 eating、running 等方法。如果用对象字面量:

js 复制代码
var p1 = {
  name: "小吴",
  age: 20,
  sex: "男",
  address: "福建",
  eating: function() {
    console.log(this.name + "在吃烧烤");
  },
  running: function() {
    console.log(this.name + "在跑步做运动");
  }
};

var p2 = {
  name: "why",
  age: 35,
  sex: "男",
  address: "广州",
  eating: function() {
    console.log(this.name + "在吃烧烤");
  },
  running: function() {
    console.log(this.name + "在跑步做运动");
  }
};

问题:代码重复率极高,难以维护。

解决方案

  1. 工厂模式
  2. 构造函数
  3. ES6 Class(后续章节)
  4. 原型 + Object.create(后续章节)

本文重点讲解前两种。

3.2 方案一:工厂模式

3.2.1 基本实现

工厂模式的核心思想:抽离共性,参数化差异,流水线生产

js 复制代码
function createPerson(name, age, sex, occupation, address) {
  var p = new Object();
  p.name = name;
  p.age = age;
  p.sex = sex;
  p.occupation = occupation;
  p.address = address;
  p.eating = function() {
    console.log(this.name + "在吃满汉全席");
  };
  return p;
}

var p1 = createPerson("小吴", 20, "男", "大三学生", "福建");
var p2 = createPerson("why", 35, "男", "全栈工程师兼教师", "广州");

console.log(p1, p2);

图 3:new 调用所产生的结构共性

3.2.2 工厂模式的缺点

  1. 类型信息丢失 :所有对象的类型都是 Object,无法区分是 Person 还是其他类型
  2. 无法利用原型链:每个对象都有自己的方法副本,无法共享,浪费内存
  3. 调试困难:堆栈跟踪中难以定位对象的创建源
js 复制代码
console.log(p1);  // Object { name: '小吴', age: 20, ... }
// 无法看出这是一个 Person 对象

适用场景

  • 简单的对象创建,不需要类型区分
  • 临时性的数据结构封装

3.3 方案二:构造函数

3.3.1 什么是构造函数

构造函数本质上是普通函数,但通过 new 关键字调用时,会执行特殊的对象创建流程:

js 复制代码
function foo() {
  console.log("foo~");
}

// 普通调用
foo();

// 构造函数调用
new foo();  // 或 new foo

3.3.2 new 操作符的执行流程

当使用 new 调用函数时,会自动执行以下步骤:

  1. 在内存中创建一个新的空对象
  2. 将这个对象的 [[Prototype]] 指向构造函数的 prototype 属性
  3. 将构造函数内部的 this 指向这个新对象
  4. 执行构造函数的代码(给 this 添加属性)
  5. 如果构造函数返回一个对象,则返回该对象;否则返回步骤 1 创建的对象
js 复制代码
function foo() {
  // 内部隐式执行:
  // var obj = {};
  // this = obj;
  console.log("foo~");
  // 隐式返回 this
}

var f1 = new foo();  // foo~
console.log(f1);     // foo {}

类型验证

js 复制代码
function XiaoWu(name) {
  this.name = name;
  console.log("我是小吴");
}

var f1 = new XiaoWu("小吴");  // 我是小吴
console.log(f1);  // XiaoWu { name: '小吴' }
console.log(f1.__proto__.constructor.name);  // XiaoWu

3.3.3 构造函数实现

js 复制代码
function Person(name, age, sex, address) {
  this.name = name;
  this.age = age;
  this.sex = sex;
  this.address = address;

  this.eating = function() {
    console.log(this.name + "在吃鱿鱼须");
  };
  this.running = function() {
    console.log(this.name + "在跟坤坤打篮球");
  };
}

var f1 = new Person("小吴同学", 20, "男", "福建");
console.log(f1);
// Person {
//   name: '小吴同学',
//   age: 20,
//   sex: '男',
//   address: '福建',
//   eating: [Function (anonymous)],
//   running: [Function (anonymous)]
// }

var f2 = new Person("小满zs", 23, "男", "北京");
var f3 = new Person("洛洛", 20, "萌妹子", "福建");

图 4:构造函数 Person 调用结果

3.3.4 如何识别构造函数

构造函数与普通函数在语法上没有区别,社区约定了以下规范:

  1. 命名规范:首字母大写,使用大驼峰命名(PascalCase)
  2. 编辑器提示 :当函数内使用 this 赋值时,编辑器会提示"此构造函数可能会转换为类声明"
js 复制代码
function XiaoWu(name) {
  this.name = name;  // 使用 this 赋值,编辑器识别为构造函数
}

图 5:如何区分是否为构造函数(编辑器中的构造函数)

注意 :只有通过 new 调用时,函数才真正成为构造函数。

3.3.5 构造函数的缺点

每次创建对象时,方法都会被重新创建,导致内存浪费:

js 复制代码
function foo() {
  function bar() {
    console.log("你猜一不一样");
  }
  return bar;
}

var f1 = foo();
var f2 = foo();
console.log(f1 === f2);  // false  每次调用都创建新的函数对象

应用到构造函数

js 复制代码
function XiaoWu(name, age, sex, address) {
  this.name = name;
  this.age = age;
  this.sex = sex;
  this.address = address;

  // 每次 new 都会创建新的函数对象
  this.eating = function() {
    console.log(this.name + "在吃鱿鱼须");
  };
  this.running = function() {
    console.log(this.name + "在跟坤坤打篮球");
  };
}

var f1 = new XiaoWu("小吴同学", 20, "男", "福建");
var f2 = new XiaoWu("小吴同学", 20, "男", "福建");

console.log(f1.eating === f2.eating);  // false
console.log(f1.running === f2.running);  // false

问题分析

  • 虽然 f1f2eating 方法功能完全相同,但它们是两个不同的函数对象
  • 当创建大量实例时,会造成内存浪费

解决方案:使用原型(Prototype),将方法定义在原型上,所有实例共享。这将在下一章节详细讲解。


四、工厂模式 vs 构造函数

对比维度 工厂模式 构造函数
调用方式 普通函数调用 使用 new 关键字
类型识别 所有对象都是 Object 可以识别具体类型(如 Person
原型链 无法利用 可以利用原型共享方法
内存占用 每个对象独立方法 每个对象独立方法(未优化时)
代码复杂度 简单直观 需要理解 newthis
适用场景 简单对象创建 需要类型区分和原型链的场景

选择建议

  • 简单场景、不需要类型区分:工厂模式
  • 需要类型识别、后续会用到原型链:构造函数
  • 现代开发:优先使用 ES6 Class(本质是构造函数的语法糖)

五、实战建议

5.1 属性描述符使用场景

  1. 配置对象保护:将配置对象冻结,防止被意外修改
  2. 私有属性模拟:通过不可枚举 + getter/setter 实现访问控制
  3. 数据校验:在 setter 中加入校验逻辑

5.2 对象创建模式选择

  1. 单个对象:对象字面量
  2. 少量同类对象:工厂模式或构造函数
  3. 大量同类对象:构造函数 + 原型(下一章)
  4. 现代项目:ES6 Class

5.3 性能优化要点

  1. 避免在构造函数中定义方法:应该定义在原型上(下一章详解)
  2. 大量静态数据使用 Object.freeze:特别是在 Vue 等响应式框架中
  3. 合理使用属性描述符:不要过度使用,会增加代码复杂度

六、总结与下一步

6.1 核心要点

  1. Object.defineProperties 可以批量定义属性描述符,比多次调用 defineProperty 更高效
  2. preventExtensionssealfreeze 三个方法提供了不同级别的对象保护
  3. 工厂模式简单直观,但无法识别对象类型
  4. 构造函数通过 new 调用,可以创建具有特定类型的对象
  5. 构造函数的缺点是方法无法共享,需要通过原型解决

6.2 遗留问题

在本文中,我们多次提到"原型"(Prototype),并且发现构造函数存在方法无法共享的问题。在控制台查看对象时,总能看到神秘的 [[Prototype]] 属性:

图 6:对象中的原型世界

6.3 下一章预告

在下一章节中,我们将深入学习:

  • 什么是原型(Prototype)和原型链
  • 如何通过原型实现方法共享,解决构造函数的内存浪费问题
  • 原型链的查找机制和继承原理
  • 大量内存图帮助理解原型的指向关系

原型是 JavaScript 中最重要的概念之一,理解原型是掌握 JavaScript 面向对象编程的关键。


相关推荐
IT_陈寒2 小时前
React开发者都在偷偷用的5个性能优化黑科技,你知道几个?
前端·人工智能·后端
还是大剑师兰特2 小时前
Vue3 前端专属配置(VSCode settings.json + .prettierrc)
前端·vscode·json
前端小趴菜052 小时前
vue3项目优化方案
前端·javascript·vue.js
Mr_Swilder2 小时前
WebGPU 基础 (WebGPU Fundamentals)
前端
张3蜂3 小时前
HTML5语义化标签:现代网页的骨架与灵魂
前端·html·html5
悟空瞎说3 小时前
我用 PixiJS 撸了个圆桌会议选座系统,从 0 到 1 踩坑全复盘
前端
码云之上3 小时前
从 SPA 到全栈:AI 时代的前端架构升级实践
前端·架构·ai编程
小陈同学呦3 小时前
关于如何使用CI/CD做自动化部署
前端·后端
前端Ah3 小时前
记 华为鸿蒙机型小程序使用uni.createInnerAudioContext() 播放音频播放两次的问题
前端