【React避坑指南】被绑架的子组件

1,557 阅读8分钟

前言

沉浸式地写过一个React项目就会发现,不同于一些替你做决定的框架,“潜规则”丰富的React远比看上去要难相处。

React中主要有两类坑点,一种是现象不符合预期,让你措手不及,严重影响开发进度。另一种是看似风平浪静,水下暗流涌动,不动声色地孕育隐患。

官方文档不会介绍花样百出的最差实践,所以下一批开发者又会掉入相同的陷阱。隐藏的坑点需要开发者亲自下地扫雷,经验主义发挥了重要作用,尤其是在Hooks使用中。

这个系列的文章会介绍一些React使用的常见陷阱,带你追溯原因和探索解决方案,帮助新手迅速跳过坑点。

往期文章:

【React避坑指南】useEffect依赖引用类型

上一篇文章我们讲述了useEffect依赖引用类型时,即使依赖项的值不变,也会执行引起不必要的重渲染。这次我们关注另一种无意义的重渲染。

绑架式重渲染

这里由外向内定义了三层组件,结构是 App -> A -> B | C。每个组件都设置了独立的state,点击button后会更新state,组件之间没有props传递。

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const clear = changeBgColor("parent", "green");
    return clear;
  });

  return (
    <div id="parent" className="component">
      <button onClick={() => setCount(count + 1)}> APP</button>
      <CompA />
    </div>
  );
}

function CompA() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const clear = changeBgColor("a", "red");
    return clear;
  });

  return (
    <div id="a" className="component">
      <button onClick={() => setCount(count + 1)}>Component A</button>
      <CompB />
      <CompC />
    </div>
  );
}

function CompB() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const clear = changeBgColor("b", "yellow");
    return clear;
  });

  return (
    <div id="b" className="component">
      <button onClick={() => setCount(count + 1)}>Component B</button>
    </div>
  );
}

function CompC() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const clear = changeBgColor("c", "blue");
    return clear;
  });

  return (
    <div id="c" className="component">
      <button onClick={() => setCount(count + 1)}> Component C</button>
    </div>
  );
}

export default App;

我们知道,useEffect如果不添加第二个参数,每次组件刷新都会执行回调,借助这个特性,我们为每一个组件绑定背景渐变的动画,便于观察组件是否刷新。

const changeBgColor = (key: string, color: string) => {
  const ele = document.getElementById(key) as HTMLElement;
  
  ele.style.backgroundColor = color;

  setTimeout(() => {
    ele.style.transition = "background-color 1s";
    ele.style.backgroundColor = "transparent";
  }, 0);

  const timer = setTimeout(() => {
    ele.style.transition = "";
  }, 1000);

  return () => clearTimeout(timer);
};

当我们更新最外层组件App的state时,App会重渲染,作为子组件树的A、B、C会一同刷新。进一步依次更新每一个子组件的状态,可以发现:

  • 更新A的state -> A、B、C刷新, App不变;
  • 更新B的state -> B刷新, 其余不变;
  • 更新C的state -> C刷新, 其余不变;

根据以上观察,于是有了结论:

父组件渲染会导致整个子组件树刷新,无关子组件的状态或参数是否改变。子组件渲染不会影响父组件和兄弟组件。

子组件从属于父组件,随父组件刷新,看似符合直觉。

但在这个案例中,子组件不会接受父组件的props,子组件render的内容与父组件无关,父组件刷新时,子组件状态也不会改变,但子组件却被“绑架”着执行了一轮重渲染,这貌似也有争议。

因为我们期待的理想状态,就如同Svelte文档中讲到的:

Svelte 编写的代码在应用程序的状态更改时就能像做外科手术一样更新 DOM。

但是这时不得不提起另一句:

框架的设计是权衡的艺术,框架之间的差异反应出设计者的认知。

React认为在大多数场景下,子组件并非纯渲染组件,其props继承自父组件,父组件状态更新,会影响到子组件render的内容,因此子随父变的逻辑适用80%以上的场景,其余场景可以提供API专门应对。

于是就有了React.memo

保护罩,可隔离

React.memo是一种高阶函数,可以用于优化函数组件的性能。当使用React.memo包装一个组件时,React.memo会将组件的props与前一次渲染的props进行浅比较。如果props没有发生变化,则React.memo会使用上一次渲染的结果,而不重新渲染组件。

我们将上例中的内层组件C用memo包裹,重复上述试验,可以观察到, 无论父组件的状态如何变化,C组件岿然不动,脱离了父辈绑架。

const MemoC = memo(CompC);

如果我们继续把Memo包裹的组件上提至A组件,可以发现A组件及其子组件,都不受最外层App状态更新的影响,Memo如同保护罩防止了内层组件的刷新。

保护罩,但镂空

这个问题看似解决了,上例中的组件没有涉及props的传递, 我们进一步让组件更加复杂,给内层组件A增加一个propskeyvalue,值为外层组件的state

const App = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const clear = changeBgColor("parent", "green");
    return clear;
  });

  return (
    <div id="parent" className="component">
      <button onClick={() => setCount(count + 1)}>App</button>
      <CompA value={0} />
    </div>
  );
};

const CompA: React.FC<{ value: number }> = memo(
  ({ value }) => {
    useEffect(() => {
      const clear = changeBgColor("a", "red");
      return clear;
    });

    return (
      <div id="a" className="component">
        <div>Memo (Component A) </div>
      </div>
    );
  }
);

尽管被Memo包裹,但由于props变化,内层的组件还是重新执行了一轮渲染。

如果把props值改为常量,内层组件不会刷新,memo发挥了缓存组件的作用,以上符合预期。

但是,当我在props中增加一个object,值为{ value: 0 }:

const App = () => {
  // ...
  
  return (
    <div id="parent" className="component">
      <button onClick={() => setCount(count + 1)}>App</button>
      <CompA value={0} object={{ value: 0 }}/>
    </div>
  );
};

const CompA: React.FC<{ value: number, object: { value: number } }> = memo(
  ({ value, object }) => {
  	// ...
  }
);

发现尽管每次传递的props恒定,而且子组件使用Memo包裹,但父组件刷新时还是连带子组件,似乎memo这一层保护罩是镂空的。

看过上一篇文章的同学一定能猜到,这还是由于Javascript中引用类型的储存方式和React Hooks的浅比较机制决定的。

useEffect类似,memo也采用浅比较决定是否执行组件的render(),对于原始类型,这并没有问题。但当props为引用类型时,尽管object内部的值相同,父组件每次刷新,都会新建另一个object传给子组件: const object = { value: 0 }

由于前后二次创建的object在内存中的地址完全不同,{ value: 0 } === { value: 0 }浅比较始终为false,子组件进行了无效刷新。

所以说,memo这层函数组件保护罩,看似坚固,实际是镂空型钢丝网,并非想象中的铁板一张。

保护罩,但后果自负

但具体应用中,我们不能放弃在props中传递引用类型,但如何让引用类型的内存地址保持一致呢?

这里先给出第一种解法, 从父组件传递的内容入手。

const App = () => {
  // ...

  const object = useMemo(() => {
    return { value: 0 }
  }, []);
  
  return (
    <div id="parent" className="component">
      <button onClick={() => setCount(count + 1)}>App</button>
      <CompA value={0} object={object}/>
    </div>
  );
};

既然每一次函数组件的刷新导致props重新声明,那不如把引用类型的propsuseMemo包裹成可缓存的变量,只在组件挂载时创建这个变量,后续更新组件并不会改变object的保存的地址和值。

如果object作为props同时传给了多个子组件,这种从上层组件解决问题的方式,就非常适合,对于新组件的拓展,少了很多心智负担。

但是这种思路就像是给镂空保护罩附带了使用说明,要求被过滤的物体尺寸不能小于一个数值,否则后果自负。

保护罩,但Plus

第二种方案从子组件的刷新机制入手,深入了解memo会发现,它的第二个参数是一个可选的比较函数areEqualmemo利用这个函数判断组件的props是否发生变化。如果areEqual函数没有传入,则默认使用浅层比较shallowEqual

function memo<Props extends object>(
  Component: (props: Props) => ReactElement | null,
  areEqual?: (prevProps: Props, nextProps: Props) => boolean,
): NamedExoticComponent<Props>;

function areEqual(prevProps: object, nextProps: object): boolean {
  return shallowEqual(prevProps, nextProps);
}

function shallowEqual(objA: unknown, objB: unknown): boolean {
  if (Object.is(objA, objB)) {
    return true;
  }

  if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  for (let i = 0; i < keysA.length; i++) {
    if (
      !Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
      !Object.is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }

  return true;
}

所以我们可以自定义这个函数的判断逻辑,使用deepClone或者手动比较对象的值。

const CompA: React.FC<{ value: number; object: any }> = memo(
  ({ value, object }) => {

    //...

    return (
      <div id="a" className="component">
        <div>Memo (Component A) </div>
      </div>
    );
  },
  (prev, next) => {
    // 对象的深比较
    return deepCompare(prev, next);
    // 或采用自定义逻辑
    // return prev.value === next.value && prev.object.value === next.object.value;
  }
);

这种方法背后的思路是追根溯源的改变memo的刷新逻辑,如果很多组件都要使用memo,完全可以封装一个deepMemo一劳永逸的作为保护罩Plus。

const deepMemo = (FunctionComponent) => {
  return memo(FunctionComponent, (prev, next) => {
    return deepCompare(prev, next);
  })
}

总结

说来一圈,这个问题并不复杂,但疏于关注的重渲染的开发者未必会意识到,而且正如上篇提到的,这篇文章依然遵循我们解决问题的一般思路:

由异常现象出发(子组件无效渲染),到文档和源码追根溯源(memo的用法),在语言底层定位原因(基本类型和引用类型的储存方式不同),围绕原因提出治标(修改外部传入props)或治本(修改内部刷新机制)的方案,优化方案形成更好的工程实践(deepMemo),总结形成可推广复用的逻辑