手写 JS 对象的属性排序与过滤:处理枚举性与 Symbol 键名

各位编程爱好者,大家好!

今天我们将深入探讨 JavaScript 对象属性管理的一个高级且极具实用性的主题:手写 JS 对象的属性排序与过滤,并特别关注枚举性与 Symbol 键名。在日常开发中,我们与 JavaScript 对象打交道最多,但往往忽视了其内部属性的一些精妙之处。我们常常认为对象的属性是无序的,或者只关注那些 for...in 循环能遍历到的属性。然而,当我们需要更精细地控制对象属性的呈现、处理或序列化时,仅仅依靠 Object.keys()for...in 是远远不够的。

本讲座将带大家从底层机制出发,逐步构建一套强大的属性管理工具,让您能够全面掌控对象的每一个属性,无论是普通的字符串键、非枚举属性,还是独特的 Symbol 键。我们将通过大量代码示例,深入理解 JavaScript 引擎如何处理属性,并学习如何利用这些机制来满足各种复杂的业务需求。

一、 JavaScript 对象属性的本质:有序性与可见性之谜

在深入排序和过滤之前,我们必须首先理解 JavaScript 对象属性的底层机制。这包括属性的类型、它们的特性以及 JavaScript 引擎如何管理它们的顺序。

1.1 属性的分类:字符串键与 Symbol 键

JavaScript 对象的属性键主要分为两类:

  • 字符串键(String Keys):这是我们最常见的属性键,可以是任何字符串。ES2015 规范对字符串键的顺序有明确规定:
    • 整数索引(Integer-like String Keys):那些能被解析为非负整数的字符串(例如 "0", "1", "100"),它们会按照数值大小升序排列。
    • 普通字符串键(Regular String Keys):不属于整数索引的其他字符串,它们会按照创建时的插入顺序排列。
  • Symbol 键(Symbol Keys):ES2015 引入的新基本数据类型,用于创建独一无二的属性键,常用于避免命名冲突。Symbol 键在对象内部的存储和迭代中,与字符串键是分开处理的,它们按照创建时的插入顺序排列。

1.2 属性的特性:枚举性、可写性、可配置性

每个属性都有一组被称为“属性描述符”(Property Descriptor)的特性,这些特性定义了属性的行为:

  • value:属性的值(仅适用于数据属性)。
  • writable:如果为 true,属性的值可以被修改(仅适用于数据属性)。
  • get:一个函数,在读取属性时被调用(仅适用于访问器属性)。
  • set:一个函数,在写入属性时被调用(仅适用于访问器属性)。
  • enumerable:如果为 true,属性会在 for...in 循环、Object.keys()Object.values()Object.entries() 等操作中被枚举。这是我们今天关注的重点之一。
  • configurable:如果为 true,属性的描述符可以被修改,属性可以被删除。

这些特性可以通过 Object.defineProperty() 方法来定义或修改,通过 Object.getOwnPropertyDescriptor() 方法来获取。

示例:不同类型的属性

const mySymbol = Symbol('uniqueId');
const anotherSymbol = Symbol('data');

const obj = {
    // 字符串键 - 整数索引
    '1': 'Value 1',
    '0': 'Value 0',
    '10': 'Value 10',

    // 字符串键 - 普通字符串
    name: 'Alice',
    city: 'New York',

    // Symbol 键
    [mySymbol]: 'My Symbol Value',
    [anotherSymbol]: 123
};

// 添加一个非枚举属性
Object.defineProperty(obj, 'secret', {
    value: 'This is a secret property',
    enumerable: false, // 设为非枚举
    writable: true,
    configurable: true
});

// 添加一个非枚举的访问器属性
Object.defineProperty(obj, 'fullName', {
    get() {
        return `${this.name} Smith`;
    },
    enumerable: false, // 设为非枚举
    configurable: true
});

// 添加一个非可配置属性
Object.defineProperty(obj, 'version', {
    value: '1.0.0',
    enumerable: true,
    writable: true,
    configurable: false // 设为非可配置
});

console.log("原始对象:", obj);

// 尝试用 Object.keys() 遍历
console.log("nObject.keys():", Object.keys(obj));
// 结果:['0', '1', '10', 'name', 'city', 'version']
// 注意:整数索引按数值排序,普通字符串按插入顺序,非枚举和Symbol键被忽略

// 尝试用 for...in 遍历
console.log("nfor...in loop:");
for (const key in obj) {
    // 过滤掉原型链上的属性,只关注自有属性
    if (obj.hasOwnProperty(key)) {
        console.log(`  ${key}: ${obj[key]}`);
    }
}
// 结果:'0', '1', '10', 'name', 'city', 'version'
// 与 Object.keys() 类似,也是只遍历枚举的字符串属性

// 获取所有自有字符串属性(包括非枚举的)
console.log("nObject.getOwnPropertyNames():", Object.getOwnPropertyNames(obj));
// 结果:['0', '1', '10', 'name', 'city', 'secret', 'fullName', 'version']
// 注意:包含了 'secret' 和 'fullName'

// 获取所有自有 Symbol 属性
console.log("nObject.getOwnPropertySymbols():", Object.getOwnPropertySymbols(obj));
// 结果:[Symbol(uniqueId), Symbol(data)]

// 获取所有自有属性(字符串和 Symbol,包括非枚举的)
console.log("nReflect.ownKeys():", Reflect.ownKeys(obj));
// 结果:['0', '1', '10', 'name', 'city', 'secret', 'fullName', 'version', Symbol(uniqueId), Symbol(data)]
// 这是我们进行全面排序和过滤的基础!

从上述示例中,我们可以清晰地看到不同方法在获取对象属性时的差异:

  • Object.keys()for...in 关注的是枚举的字符串属性
  • Object.getOwnPropertyNames() 关注的是所有自有字符串属性(无论枚举与否)。
  • Object.getOwnPropertySymbols() 关注的是所有自有 Symbol 属性
  • Reflect.ownKeys() 关注的是所有自有属性(字符串和 Symbol,无论枚举与否),并且其返回的顺序遵循 ES2015 规范:整数索引(升序) -> 普通字符串(插入顺序) -> Symbol 键(插入顺序)

Reflect.ownKeys() 是我们实现全面属性管理的关键起点,因为它提供了最完整的自有属性列表,且具有可预测的默认顺序。

二、 过滤对象属性:按需选取

属性过滤是管理对象属性的第一步,它允许我们根据各种标准(如枚举性、键类型、自定义条件)来选择我们感兴趣的属性。

2.1 基础过滤:枚举性与键类型

我们可以基于属性的枚举状态和键的类型来进行过滤。

过滤规则速览表

方法 属性类型 枚举性要求 描述
Object.keys() 字符串键 必须枚举 获取所有可枚举的字符串键
Object.getOwnPropertyNames() 字符串键 获取所有自有字符串键(包括不可枚举)
Object.getOwnPropertySymbols() Symbol 键 获取所有自有 Symbol 键
Reflect.ownKeys() 字符串键 & Symbol 键 获取所有自有键(包括不可枚举的字符串和 Symbol)

手写过滤函数:filterObjectKeys

我们将构建一个通用的函数,它能够根据提供的选项来过滤属性。

/**
 * 从对象中过滤属性键。
 * @param {object} obj 要处理的对象。
 * @param {object} options 过滤选项。
 * @param {boolean} [options.includeEnumerable=true] 是否包含可枚举属性。
 * @param {boolean} [options.includeNonEnumerable=false] 是否包含不可枚举属性。
 * @param {boolean} [options.includeStrings=true] 是否包含字符串键。
 * @param {boolean} [options.includeSymbols=false] 是否包含 Symbol 键。
 * @param {function(string|symbol, any, PropertyDescriptor): boolean} [options.predicate] 自定义过滤函数。
 *        接收 key, value, descriptor 作为参数,返回 true 则包含该属性。
 * @returns {(string|symbol)[]} 过滤后的属性键数组。
 */
function filterObjectKeys(obj, options = {}) {
    const defaultOptions = {
        includeEnumerable: true,
        includeNonEnumerable: false,
        includeStrings: true,
        includeSymbols: false,
        predicate: null
    };
    const opts = { ...defaultOptions, ...options };

    // 1. 获取所有自有属性键作为起点
    const allKeys = Reflect.ownKeys(obj);

    const filteredKeys = [];

    for (const key of allKeys) {
        const descriptor = Object.getOwnPropertyDescriptor(obj, key);
        if (!descriptor) {
            // 这通常不应该发生,除非属性被删除或传入了非自有属性
            continue;
        }

        const isEnumerable = descriptor.enumerable;
        const isSymbol = typeof key === 'symbol';
        const isString = typeof key === 'string';

        // 2. 根据枚举性进行过滤
        if ((isEnumerable && !opts.includeEnumerable) || (!isEnumerable && !opts.includeNonEnumerable)) {
            continue;
        }

        // 3. 根据键类型进行过滤
        if ((isString && !opts.includeStrings) || (isSymbol && !opts.includeSymbols)) {
            continue;
        }

        // 4. 应用自定义谓词函数(如果存在)
        if (opts.predicate && !opts.predicate(key, obj[key], descriptor)) {
            continue;
        }

        filteredKeys.push(key);
    }

    return filteredKeys;
}

// 示例对象 (同上文)
const mySymbol = Symbol('uniqueId');
const anotherSymbol = Symbol('data');
const obj = {
    '1': 'Value 1', '0': 'Value 0', '10': 'Value 10',
    name: 'Alice', city: 'New York',
    [mySymbol]: 'My Symbol Value', [anotherSymbol]: 123
};
Object.defineProperty(obj, 'secret', { value: 'This is a secret property', enumerable: false });
Object.defineProperty(obj, 'fullName', { get() { return `${this.name} Smith`; }, enumerable: false });
Object.defineProperty(obj, 'version', { value: '1.0.0', enumerable: true, configurable: false });

console.log("n--- 属性过滤示例 ---");

// 示例 1: 只获取可枚举的字符串键 (等同于 Object.keys())
console.log("只获取可枚举的字符串键:", filterObjectKeys(obj, {
    includeNonEnumerable: false,
    includeSymbols: false
}));
// 预期:['0', '1', '10', 'name', 'city', 'version']

// 示例 2: 获取所有字符串键 (等同于 Object.getOwnPropertyNames())
console.log("获取所有字符串键:", filterObjectKeys(obj, {
    includeEnumerable: true,
    includeNonEnumerable: true,
    includeSymbols: false
}));
// 预期:['0', '1', '10', 'name', 'city', 'secret', 'fullName', 'version']

// 示例 3: 只获取 Symbol 键 (等同于 Object.getOwnPropertySymbols())
console.log("只获取 Symbol 键:", filterObjectKeys(obj, {
    includeStrings: false,
    includeSymbols: true,
    includeEnumerable: true, // Symbol 键没有严格的枚举性要求,但这里保持一致
    includeNonEnumerable: true
}));
// 预期:[Symbol(uniqueId), Symbol(data)]

// 示例 4: 获取所有属性键 (等同于 Reflect.ownKeys())
console.log("获取所有属性键:", filterObjectKeys(obj, {
    includeEnumerable: true,
    includeNonEnumerable: true,
    includeStrings: true,
    includeSymbols: true
}));
// 预期:['0', '1', '10', 'name', 'city', 'secret', 'fullName', 'version', Symbol(uniqueId), Symbol(data)]

// 示例 5: 自定义过滤 - 只获取值为字符串且键名长度大于4的属性
console.log("自定义过滤 (值是字符串且键长>4):", filterObjectKeys(obj, {
    includeEnumerable: true,
    includeNonEnumerable: true,
    includeStrings: true,
    includeSymbols: false, // 假设我们只关心字符串键
    predicate: (key, value, descriptor) => typeof key === 'string' && key.length > 4 && typeof value === 'string'
}));
// 预期:['secret', 'fullName', 'version'] (如果fullName的get返回字符串)

// 示例 6: 自定义过滤 - 只获取非可配置的属性
console.log("自定义过滤 (非可配置属性):", filterObjectKeys(obj, {
    includeEnumerable: true,
    includeNonEnumerable: true,
    includeStrings: true,
    includeSymbols: true,
    predicate: (key, value, descriptor) => !descriptor.configurable
}));
// 预期:['version']

这个 filterObjectKeys 函数提供了强大的灵活性,我们可以通过组合 options 参数来精确地控制哪些属性键被包含在结果中。特别是 predicate 选项,它允许我们基于属性的键、值、甚至完整的属性描述符来进行高度定制化的过滤。

三、 排序对象属性:建立自定义秩序

一旦我们过滤出了所需的属性,下一步就是对它们进行排序。虽然 JavaScript 对象的属性在内部有其默认的顺序(如 Reflect.ownKeys() 所体现的),但在许多情况下,我们需要根据自己的逻辑来重新排列这些属性。

3.1 排序的挑战与策略

挑战:JavaScript 对象本身并不保证属性的自定义插入顺序。当你创建一个新对象并用 Object.defineProperty 填充属性时,它会尽力保留你提供的顺序,但对于非整数索引的字符串键,尤其是在旧版引擎中,这种保证并不绝对。然而,现代 JavaScript 引擎(ES2015+)在 Reflect.ownKeys()Object.getOwnPropertyNames() 等方法中,对于字符串键和 Symbol 键的顺序已经有了明确的规范,这为我们提供了排序的基础。

策略

  1. 获取所有属性键:使用 Reflect.ownKeys() 获取一个包含所有自有属性键的数组。
  2. 应用过滤:对这个数组进行过滤,只保留我们需要的属性。
  3. 应用排序:对过滤后的属性键数组进行排序。
  4. 重建新对象:根据排序后的键数组,创建一个新的对象,并使用 Object.getOwnPropertyDescriptor()Object.defineProperty() 将原始属性及其描述符精确地复制到新对象中。这是确保排序生效的关键步骤。

3.2 手写排序函数:sortObjectKeys

我们将构建一个 sortObjectKeys 函数,它接收一个比较器函数,并返回排序后的属性键数组。

/**
 * 对对象属性键进行排序。
 * @param {object} obj 要处理的对象。
 * @param {function((string|symbol), (string|symbol), PropertyDescriptor, PropertyDescriptor): number} comparator
 *        排序比较器函数。与 Array.prototype.sort 的比较器类似,
 *        接收 keyA, keyB, descriptorA, descriptorB。
 *        返回负数表示 keyA 在 keyB 之前,正数表示 keyA 在 keyB 之后,0 表示相等。
 * @param {object} [filterOptions] 过滤选项,与 filterObjectKeys 相同。
 * @returns {(string|symbol)[]} 排序后的属性键数组。
 */
function sortObjectKeys(obj, comparator, filterOptions = {}) {
    // 首先应用过滤,获取我们感兴趣的属性键
    const keysToFilterAndSort = filterObjectKeys(obj, filterOptions);

    if (typeof comparator !== 'function') {
        throw new Error("Comparator must be a function.");
    }

    // 使用 Array.prototype.sort 进行排序
    // 为了在比较器中使用描述符,我们预先获取所有描述符
    const descriptorsCache = new Map();
    const getDescriptor = (key) => {
        if (!descriptorsCache.has(key)) {
            descriptorsCache.set(key, Object.getOwnPropertyDescriptor(obj, key));
        }
        return descriptorsCache.get(key);
    };

    keysToFilterAndSort.sort((keyA, keyB) => {
        const descriptorA = getDescriptor(keyA);
        const descriptorB = getDescriptor(keyB);
        return comparator(keyA, keyB, descriptorA, descriptorB);
    });

    return keysToFilterAndSort;
}

// 示例对象 (同上文)
// ... (obj 定义保持不变) ...

console.log("n--- 属性排序示例 ---");

// 示例 1: 默认的 Reflect.ownKeys() 顺序 (只是为了对比)
console.log("Reflect.ownKeys() 默认顺序:", Reflect.ownKeys(obj));

// 示例 2: 字符串键按字母顺序排序,Symbol 键保持原位 (仅限字符串和Symbol键)
console.log("字符串键字母排序,Symbol键保持原位:", sortObjectKeys(obj, (keyA, keyB) => {
    const isSymbolA = typeof keyA === 'symbol';
    const isSymbolB = typeof keyB === 'symbol';

    if (isSymbolA && isSymbolB) {
        // 如果都是 Symbol,保持它们 Reflect.ownKeys() 提供的原始插入顺序
        // 这需要更复杂的逻辑,因为我们失去了原始索引。
        // 对于本例,我们假设Symbol键的相对顺序不重要,或者可以按description排序。
        // 这里为了简化,我们让它们在字符串键之后,相对顺序不改变。
        return 0; // 相对顺序不变
    }
    if (isSymbolA) return 1; // Symbol 键排在字符串键之后
    if (isSymbolB) return -1; // 字符串键排在 Symbol 键之前

    // 都是字符串键,按字母顺序排序
    return String(keyA).localeCompare(String(keyB));
}, { includeStrings: true, includeSymbols: true, includeEnumerable: true, includeNonEnumerable: true }));
// 预期结果:['0', '1', '10', 'city', 'fullName', 'name', 'secret', 'version', Symbol(uniqueId), Symbol(data)]
// 注意:整数索引 '0', '1', '10' 仍然按数值排序,然后普通字符串按字母,Symbol在最后。

// 示例 3: 所有键(字符串和 Symbol)按键名(或 Symbol description)的长度升序排序
console.log("所有键按长度升序排序:", sortObjectKeys(obj, (keyA, keyB) => {
    const nameA = typeof keyA === 'symbol' ? keyA.description || '' : String(keyA);
    const nameB = typeof keyB === 'symbol' ? keyB.description || '' : String(keyB);
    return nameA.length - nameB.length;
}, { includeStrings: true, includeSymbols: true, includeEnumerable: true, includeNonEnumerable: true }));
// 预期结果:['0', '1', 'name', 'city', 'secret', 'version', 'fullName', 'uniqueId', 'data'] (这里的Symbol会按description排序)

// 示例 4: 将 'name' 属性放在第一位,其余的按默认 Reflect.ownKeys() 顺序
console.log("将 'name' 属性置顶:", sortObjectKeys(obj, (keyA, keyB) => {
    if (keyA === 'name') return -1; // 'name' 永远在前面
    if (keyB === 'name') return 1;  // 'name' 永远在前面
    return 0; // 其他属性保持相对顺序
}, { includeStrings: true, includeSymbols: true, includeEnumerable: true, includeNonEnumerable: true }));
// 预期结果:['name', '0', '1', '10', 'city', 'secret', 'fullName', 'version', Symbol(uniqueId), Symbol(data)]

3.3 重建对象以应用排序

仅仅对键数组进行排序是不够的,我们还需要创建一个新的对象来实际反映这种排序。这个过程需要精确地复制原始属性的描述符,以确保属性的枚举性、可写性、可配置性以及 getter/setter 行为都被保留。

/**
 * 根据给定的键数组和原始对象,创建一个新的对象,并保留属性描述符。
 * @param {object} originalObj 原始对象。
 * @param {(string|symbol)[]} sortedKeys 排序后的属性键数组。
 * @returns {object} 包含排序后属性的新对象。
 */
function createObjectFromProperties(originalObj, sortedKeys) {
    const newObj = {};
    for (const key of sortedKeys) {
        const descriptor = Object.getOwnPropertyDescriptor(originalObj, key);
        if (descriptor) {
            Object.defineProperty(newObj, key, descriptor);
        }
    }
    return newObj;
}

console.log("n--- 重建对象示例 ---");

// 获取按字母排序后的所有字符串键
const sortedStringKeys = sortObjectKeys(obj, (keyA, keyB) => {
    // 确保只比较字符串,Symbol 键会在过滤阶段被排除
    if (typeof keyA === 'string' && typeof keyB === 'string') {
        return keyA.localeCompare(keyB);
    }
    return 0;
}, { includeStrings: true, includeSymbols: false, includeEnumerable: true, includeNonEnumerable: true });

console.log("排序后的字符串键:", sortedStringKeys);
// 预期:['0', '1', '10', 'city', 'fullName', 'name', 'secret', 'version']

// 根据排序后的键重建对象
const sortedStringObj = createObjectFromProperties(obj, sortedStringKeys);
console.log("重建后的对象 (仅字符串键):", sortedStringObj);
console.log("重建后的对象键顺序 (Object.keys()):", Object.keys(sortedStringObj));
console.log("重建后的对象键顺序 (Object.getOwnPropertyNames()):", Object.getOwnPropertyNames(sortedStringObj));
console.log("重建后的对象键顺序 (Reflect.ownKeys()):", Reflect.ownKeys(sortedStringObj));
// 预期:'0', '1', '10', 'city', 'fullName', 'name', 'secret', 'version' (顺序一致)

// 验证属性描述符是否保留
console.log("原始 'secret' 属性是否可枚举:", Object.getOwnPropertyDescriptor(obj, 'secret').enumerable); // false
console.log("重建后 'secret' 属性是否可枚举:", Object.getOwnPropertyDescriptor(sortedStringObj, 'secret').enumerable); // false
console.log("原始 'fullName' 值:", obj.fullName); // Alice Smith
console.log("重建后 'fullName' 值:", sortedStringObj.fullName); // Alice Smith

通过 createObjectFromProperties 函数,我们成功地将排序应用于新的对象。这对于需要将对象以特定顺序进行序列化、显示或传递给需要有序属性的 API 时尤其有用。

四、 综合管理:一个全能函数

为了更方便地管理对象属性,我们可以将过滤、排序和重建的逻辑封装到一个单一的函数中。

/**
 * 综合管理对象属性:过滤、排序并可选地重建新对象。
 * @param {object} obj 原始对象。
 * @param {object} [options] 综合管理选项。
 * @param {object} [options.filter] 过滤选项,与 filterObjectKeys 相同。
 * @param {function((string|symbol), (string|symbol), PropertyDescriptor, PropertyDescriptor): number} [options.sortComparator]
 *        排序比较器函数,与 sortObjectKeys 相同。如果未提供,则不进行排序。
 * @param {boolean} [options.reconstruct=false] 如果为 true,则返回一个新的对象,否则返回排序/过滤后的键数组。
 * @returns {object | (string|symbol)[]} 根据 reconstruct 选项返回新对象或键数组。
 */
function manageObjectProperties(obj, options = {}) {
    const defaultOptions = {
        filter: {
            includeEnumerable: true,
            includeNonEnumerable: false,
            includeStrings: true,
            includeSymbols: false,
            predicate: null
        },
        sortComparator: null,
        reconstruct: false
    };
    const opts = { ...defaultOptions, ...options };
    opts.filter = { ...defaultOptions.filter, ...options.filter }; // 深度合并 filter 选项

    // 1. 过滤属性键
    let keys = filterObjectKeys(obj, opts.filter);

    // 2. 排序属性键
    if (opts.sortComparator && typeof opts.sortComparator === 'function') {
        // 为了提高效率,这里直接在 keys 数组上进行排序,而不是再次调用 sortObjectKeys
        const descriptorsCache = new Map();
        const getDescriptor = (key) => {
            if (!descriptorsCache.has(key)) {
                descriptorsCache.set(key, Object.getOwnPropertyDescriptor(obj, key));
            }
            return descriptorsCache.get(key);
        };
        keys.sort((keyA, keyB) => {
            const descriptorA = getDescriptor(keyA);
            const descriptorB = getDescriptor(keyB);
            return opts.sortComparator(keyA, keyB, descriptorA, descriptorB);
        });
    }

    // 3. 根据需要重建对象或返回键数组
    if (opts.reconstruct) {
        return createObjectFromProperties(obj, keys);
    } else {
        return keys;
    }
}

console.log("n--- 综合管理函数示例 ---");

// 示例 1: 获取所有自有属性键,按键名字母倒序排列
const allKeysSortedDesc = manageObjectProperties(obj, {
    filter: {
        includeEnumerable: true,
        includeNonEnumerable: true,
        includeStrings: true,
        includeSymbols: true
    },
    sortComparator: (keyA, keyB) => {
        const nameA = typeof keyA === 'symbol' ? keyA.description || '' : String(keyA);
        const nameB = typeof keyB === 'symbol' ? keyB.description || '' : String(keyB);
        return nameB.localeCompare(nameA); // 倒序
    },
    reconstruct: false
});
console.log("所有键按键名倒序排列:", allKeysSortedDesc);
// 预期:['version', 'uniqueId', 'secret', 'name', 'fullName', 'data', 'city', '10', '1', '0'] (Symbol 也会按 description 倒序)

// 示例 2: 获取所有可枚举的字符串属性,按键名长度升序排序,并重建新对象
const sortedEnumerableStringObj = manageObjectProperties(obj, {
    filter: {
        includeEnumerable: true,
        includeNonEnumerable: false,
        includeStrings: true,
        includeSymbols: false
    },
    sortComparator: (keyA, keyB) => String(keyA).length - String(keyB).length,
    reconstruct: true
});
console.log("重建对象 (可枚举字符串,按长度升序):", sortedEnumerableStringObj);
console.log("重建对象键顺序 (Reflect.ownKeys()):", Reflect.ownKeys(sortedEnumerableStringObj));
// 预期:
// {
//   '0': 'Value 0',
//   '1': 'Value 1',
//   name: 'Alice',
//   city: 'New York',
//   '10': 'Value 10',
//   version: '1.0.0'
// }
// Reflect.ownKeys() 结果与此对象表示的顺序一致

// 示例 3: 获取所有非枚举属性,不排序,只返回键数组
const nonEnumerableKeys = manageObjectProperties(obj, {
    filter: {
        includeEnumerable: false,
        includeNonEnumerable: true,
        includeStrings: true,
        includeSymbols: true // 如果有非枚举 Symbol 也会包含
    },
    sortComparator: null, // 不排序
    reconstruct: false
});
console.log("所有非枚举属性键:", nonEnumerableKeys);
// 预期:['secret', 'fullName'] (如果Symbol键是可枚举的,则不会出现在这里)
// 实际上,Symbol键没有enumerable属性,它们通常被视为非enumerable,除非通过defineProperty明确设置。
// 但 Object.getOwnPropertyDescriptor 对于 Symbol 键的 enumerable 属性是存在的,且默认为 false。
// 所以这里会包含 Symbol 键,如果它们没有被明确设置为可枚举。
// 对于 Symbol 键,enumerable 描述符通常是 `false`,但它们仍然可以通过 `Object.getOwnPropertySymbols()` 访问。
// `filterObjectKeys` 中的 `isEnumerable` 会正确地处理 Symbol 键的描述符。
// 因此,这里的 Symbol 键如果其 descriptor.enumerable 为 false,会被包含。

// 验证 Symbol 键的 enumerable 描述符
console.log("Symbol(uniqueId) enumerable:", Object.getOwnPropertyDescriptor(obj, mySymbol).enumerable); // false
console.log("Symbol(data) enumerable:", Object.getOwnPropertyDescriptor(obj, anotherSymbol).enumerable); // false

// 重新运行示例 3, 预期会包含 Symbol 键
const nonEnumerableKeysWithSymbols = manageObjectProperties(obj, {
    filter: {
        includeEnumerable: false,
        includeNonEnumerable: true, // 包含非枚举
        includeStrings: true,
        includeSymbols: true // 包含 Symbol
    },
    reconstruct: false
});
console.log("所有非枚举属性键 (含 Symbol):", nonEnumerableKeysWithSymbols);
// 预期:['secret', 'fullName', Symbol(uniqueId), Symbol(data)]

这个 manageObjectProperties 函数是对象属性管理的瑞士军刀。它将我们之前讨论的所有功能集成在一起,提供了一个统一的接口,使得对对象属性的精细控制变得简单而强大。

五、 高级考量与应用场景

5.1 性能考量

在循环中频繁调用 Object.getOwnPropertyDescriptor() 可能会带来一定的性能开销,尤其是在处理具有大量属性的对象时。在 sortObjectKeysmanageObjectProperties 函数中,我们通过 descriptorsCache Map 来缓存属性描述符,以避免重复获取,从而优化了性能。对于绝大多数日常应用,这种开销是可接受的。如果遇到极端性能瓶颈,可能需要更底层的优化,但这通常超出了 JavaScript 本身的范畴。

5.2 深度复制与引用类型

createObjectFromProperties 函数创建的是一个浅拷贝。它复制了属性的描述符,但如果属性的值是对象或数组等引用类型,那么新旧对象中的这些属性仍然会引用同一个底层数据结构。如果需要深度复制,您需要在 createObjectFromProperties 中添加递归的深拷贝逻辑,例如使用 JSON.parse(JSON.stringify(value)) (但有局限性,如不能处理函数、Symbol、undefined)或更健壮的深拷贝库。

5.3 继承属性

我们所有的函数都专注于处理对象的自有属性(own properties)。它们不会遍历原型链上的继承属性。这是因为在大多数需要排序和过滤的场景中,我们更关心对象实例本身所拥有的属性。如果需要处理继承属性,您可以使用 for...in 循环(它会遍历可枚举的继承属性),然后结合 Object.getOwnPropertyDescriptor() 进行过滤,但排序继承属性通常没有意义,因为它们存在于原型对象上,而非实例本身。

5.4 实际应用场景

  • API 请求或响应标准化:当与外部 API 交互时,有时需要确保请求体或响应数据中的字段以特定顺序出现,以满足某些严格的协议要求或提高可读性。
  • 配置对象处理:在处理应用程序配置对象时,可能需要将特定重要的配置项放在前面,或者过滤掉不相关的内部配置。
  • UI 组件的数据展示:在渲染表格或列表时,你可能希望以用户定义的顺序显示对象的属性。
  • 调试与日志:在调试复杂的对象时,自定义排序和过滤可以帮助你快速聚焦于关键属性,提高调试效率。
  • 自定义序列化:虽然 JSON.stringify 有其默认行为,但通过预处理对象,我们可以控制哪些属性被序列化以及它们的顺序。

六、 掌握对象的深层奥秘

通过今天的讲座,我们深入探索了 JavaScript 对象属性的内部机制,从基础的枚举性、键类型到高级的属性描述符。我们亲手构建了一套强大的工具集,包括 filterObjectKeys 用于按需筛选属性,sortObjectKeys 用于按自定义规则排列属性,以及 createObjectFromProperties 用于精确地重建具有所需顺序和描述符的新对象。最终,我们将其整合到 manageObjectProperties 这个全能函数中,实现了对对象属性的全面、灵活控制。

理解并能够“手写”这些属性管理逻辑,意味着您不再仅仅是 JavaScript 的使用者,更是其底层机制的掌控者。这种能力将极大地提升您在处理复杂数据结构时的灵活性和效率,让您能够更好地应对各种挑战,编写出更健壮、更可维护的代码。希望今天的分享能为大家在 JavaScript 编程的道路上带来新的启发和工具!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注