稳定引用

稳定引用是 React 中检查 State 更新和处理虚拟 DOM 的基础,也是优化 React 性能的核心内容之一。

React 通过比较前后两次渲染的虚拟 DOM 来确定哪些组件需要更新,如果一个组件的 props 和 state 没有发生变化,那么这个组件就将跳过渲染。所以稳定引用实际上就是 React 可以用最简单的策略确定所要判断的对象没有发生任何变化。

引用是一个在很多系统级语言中才广泛存在的概念,但是这并不代表在 Javascript 中就不存在引用的概念。在内存中,每一个对象都有一个独立的内存地址,这段内存中所保存的内容就是这个对象的内容,如果一个对象的值保存的是其他对象的内存地址,通常就会称这个对象 引用 了另一个对象。

Tip

引用的概念的确跟指针是一样的,但是两个概念不一样的地方在于,引用是可以直接访问被引用对象的值的,而指针则需要完成解引用操作以后才可以访问被指向对象的值。

但是 Javascript 中是无法直接访问对象的内存地址,而且无法通过语句、查询获取到对象的内存地址的,也就是说我们无法通过在其他语言中常用的方法来判断对象的引用是否发生了变化。

判断对象是否具有稳定引用

最常用的方法是使用严格相等运算符===,如果在多次操作中,对象的引用保持一致,那么===的比较结果将始终为true

例如:

const obj1 = { a: 1, { b:2}};
const obj2 = { a: 1, { b:2}};
const obj3 = obj1;

console.log(obj1 === obj2); // false
console.log(obj1 === obj3); // true

在这个示例中,obj1obj2虽然内容相同,但是它们并不是同一个对象,所以引用也不相同。

另外还可以通过使用Symbol给对象挂一个标签的形式来确定对象实例,从而确定对象引用的稳定。例如:

const uniqueKey = Symbol("unique");

function tagObj(obj: any) {
  if (!obj[uniqueKey]) {
    obj[uniqueKey] = Math.random().toString(24).slice(2);
  }
  return obj[uniqueKey];
}

const obj1 = { a: 1, b: 2 };
console.log(tagObj(obj1)); // 第一次调用会给未标记的对象创建一个标签
console.log(tagObj(obj1)); // 第二次调用会返回对象中保存的标签

这样,根据调用这个函数返回的标签内容就可以知道当前引用的对象是否发生了变化。

哪些地方需要使用稳定引用

稳定引用的使用对于 React 的性能优化非常重要,在部分 Hook 中都是要求使用稳定引用的。

  • useEffect的依赖项数组。useEffect的第二个参数是一个依赖项数组,如果这个数组中的某个值在渲染的时候发生了变化,那么这个useEffect的第一个参数就会启动执行,如果某个值在每次渲染的时候都发生变化,那么这个 Effect 就会在每次渲染的时候都重复执行,这样就会导致性能问题。
  • useCallback的依赖项数组。useCallback的第二个参数也是一个依赖项数组,如果这个依赖项数组中的某个值发生了变化,那么useCallback就会返回一个新状态的函数。这种行为可能影响的并不只是当前的组件,还可能通过 Context 影响组件树中其他的组建的渲染,从而导致性能问题。
  • useMemo的依赖项数组。useMemo用于创建 Memoized 值,通常用来优化复杂和高耗时的计算,它的依赖项数组也是跟useCallback的依赖项数组一样的,当其中存在值的变化时,useMemo就会重新生成一个新的 Memoized 值。
  • 组件的props参数对象。给一个组件传递一个对象作为组件的参数是一个十分普遍的用法。如果这个props对象在每次渲染的时候都传递一个新的对象,那么即便是这个props对象的内容没有发生变化,那么这个组件也会触发重新渲染。

除了以上这些情况以外,在使用 Zustand 等状态库,并且会涉及到是否会触发组件的重新渲染的时候,其性能优化通常都会与对象的稳定引用有关。

如何创建和确保一个对象的稳定引用

在 Javascript 中确保一个对象的稳定引用的原则就是避免对象在多次操作中被替换。

对于稳定引用的创建和维持,通常可以使用使用以下策略。

  • 对于计算值,使用useMemo来在依赖项不变的情况下返回相同的对象。
  • 对于函数,使用useCallback来在依赖项不变的情况下返回相同的函数。
  • 使用useRef来创建一个到目标对象的引用,使对象的引用变化被隐藏起来。
  • 使用单例模式确保对象在全局只有一个可用实例。
  • 使用WeakMap或者Map维护引用,这两个数据类型可以将对的绑定到一个唯一标识符,从而创建一个对象的稳定引用。
  • 使用扩展语法{ ... }或者Object.assign来创建新对象,利用浅拷贝保证对象中的第一层内容的引用不变。
  • 使用Object.freeze冻结对象浅层,确保对象内容不可变。
  • 在代码中尽可能的对对象的修改采用就地操作,而不是创建一个新对象替换原有的引用。
  • 使用Proxy对对象的访问和修改进行拦截,实现更细粒度的控制。

除开那些使用 React 中提供的 Hook 来加成稳定引用的创建方法,这里拣选一些 Javascript 中自身实现的创建稳定引用的方法进行进一步说明。

单例模式

如果这个对象是全局共享的,那么可以使用单例模式固定的返回唯一实例。以下是一个用来创建单例的函数。

const Singleton = (function () {
  let instance;
  return function (defaultValue) {
    if (!instance) {
      instance = defaultValue ?? {};
    }
    return instance;
  };
})();

const obj1 = Singleton(); // 此时可以完成创建单一实例的操作。

使用 WeakMap 维护引用

WeakMap是一个特殊的Map,它的键必须是对象或者一个Symbol,而这个键对应的值就没有什么要求,可以是任意类型。WeakMap的键不会阻止垃圾回收,也就是说当一个对象不再被其他对象引用的时候,即便它依旧作为WeakMap的键,也不会影响它的回收。

所以根据WeakMap的特点,它的键也是可以用来维护对象的稳定引用的。也就是利用可被回收的键作为被引用对象的标签对引用进行标记,只要键不被回收那么通过它取得的值就一定是一个稳定的引用。

以下是一个在WeakMap中保存和获取引用的示例。

const refMap = new WeakMap();

function getObject(key) {
  if (!refMap.hasKey(key)) {
    refMap.set(key, {});
  }
  return refMap.get(key);
}

const key = {};
const obj1 = getObject(key);

使用 Proxy 封装对对象的访问

Javascript 中的Proxy可以实现对象的稳定引用,并且可以确保对象在操作过程中不会意外被替换。

例如可以使用Proxy拦截对对象的访问和修改。

let customObj = { counter: 0 };

const handler = {
  set(target, key, value) {
    target[key] = value;
    return true;
  },
};

const proxy = new Proxy(customObj, handler);

// 这里通过Proxy来修改对象
proxy.counter = 1;
proxy.counter++;

Proxy构造函数的第二个参数handler里,定义的set方法只是一个捕获器方法,包括set在内,这个handler对象还可以使用以下捕获器来重新定义对对象产生相应操作的时候的替换逻辑。

  • getPrototypeOf(target): object | null,用于捕捉Object.getPrototypeOf方法调用。
  • setPrototypeOf(target, prototype): boolean,用于捕捉Object.setPrototypeOf方法调用。
  • isExtensible(target): boolean,用于捕捉Object.isExtensible方法的调用。
  • preventExtensions(target): boolean,用于捕捉Object.preventExtensions方法的调用。
  • getOwnPropertyDescriptor(target, prop): object | undefined,用于捕捉Object.getOwnPropertyDescriptor方法调用。
  • defineProperty(target, property, descriptor): boolean,用于捕捉Object.defineProperty方法的调用,并且会拦截proxy.property=value的操作。
  • has(target, prop): boolean,用于捕获in操作,例如"a" in proxy;还可以拦截with(proxy) { (prop); }with检查操作。
  • get(target, property, receiver): any,用于捕捉从对象的属性读取的操作,例如proxy[prop]或者proxy.prop
  • set(target, property, value, reciever): boolean,用于捕捉设置对象属性值的操作,例如proxy[prop]=value或者proxy.prop=value
  • deleteProperty(target, property): boolean,用于捕捉针对对象属性的delete操作,例如delete proxy[prop]或者delete proxy.prop
  • ownKeys(target): Iterable<any>,用于捕捉Object.getOwnPropertyNamesObject.getOwnPropertySymbolsObject.keys等方法的调用。
  • apply(target, thisArg, argumentsList): any,用于捕捉函数的调用,例如proxy(...args)Function.prototype.apply()或者Function.prototype.call()等。
  • construct(target, argumentsList, newTarget): object,用于捕捉new操作符,这个方法有一个限制,就是要求被代理对象必须可以使用new target()的形式创建实例。

了解了Proxy的这些功能以后,下面再放出两个示例。

这个示例创建的Proxy可以递归稳定子对象的引用。

function createDeepStableProxy(target) {
  return new Proxy(target, {
    get(obj, prop) {
      const value = obj[prop];
      if (typeof value === "object" && value !== null) {
        return createDeepStableProxy(value);
      }
      return value;
    },
    set(obj, prop, value) {
      obj[prop] = value;
      return true;
    },
  });
}

const obj = createDeepStableProxy({ nested: { key: "value" } });

try {
  obj.nested = { key2: "value2" };
} catch (e) {
  console.error(e.message); // 替换nested属性会出错
}

下面这个示例借助WeakSet实现了引用一致性检查。WeakSet的特点跟WeakMap近似,只不过WeakSet中保存的直接就是对象引用本身。

const references = new WeakSet();

function createReferenceTrackingProxy(target) {
  references.add(target);
  return new Proxy(target, {
    get(obj, prop) {
      if (!references.has(obj)) {
        throw new Error("Access to replaced object");
      }
      return obj[prop];
    },
    set(obj, prop, value) {
      if (!references.has(obj)) {
        throw new Error("Modification of replaced object");
      }
      obj[prop] = value;
      return true;
    },
  });
}

const obj = createReferenceTrackingProxy({ key: "value" });
obj.key = "value2"; // 这个操作是允许的,但是对未被记录的对象进行操作会抛出异常