reactjs 当ref指向DOM元素时,使用ref.current作为useEffect的依赖项安全吗?

fumotvh3  于 2023-05-22  发布在  React
关注(0)|答案(6)|浏览(251)

我知道ref是一个可变的容器,所以它不应该列在useEffect的依赖项中,但是ref.current可以是一个不断变化的值。
当ref用于存储像<div ref={ref}>这样的DOM元素时,以及当我开发依赖于该元素的自定义钩子时,假设ref.current可以随着时间的推移而改变,如果组件有条件地返回:

const Foo = ({inline}) => {
  const ref = useRef(null);
  return inline ? <span ref={ref} /> : <div ref={ref} />;
};

我的自定义效果接收ref对象并使用ref.current作为依赖项是否安全?

const useFoo = ref => {
  useEffect(
    () => {
      const element = ref.current;
      // Maybe observe the resize of element
    },
    [ref.current]
  );
};

我读过这篇评论说ref应该在useEffect中使用,但我不知道任何情况下,ref.current被更改,但效果不会触发。
正如那个问题所建议的,我应该使用回调ref,但是ref as参数对于集成多个钩子非常友好:

const ref = useRef(null);
useFoo(ref);
useBar(ref);

而回调引用更难使用,因为用户被强制组合它们:

const fooRef = useFoo();
const barRef = useBar();
const ref = element => {
  fooRef(element);
  barRef(element);
};

<div ref={ref} />

这就是为什么我问在useEffect中使用ref.current是否安全。

2skhul33

2skhul331#

这是不安全的,因为改变引用不会触发渲染,因此,不会触发useEffect
React Hook useEffect有一个不必要的依赖:'ref. current'。排除它或删除依赖数组。像'ref.current'这样的可变值不是有效的依赖项,因为改变它们不会重新呈现组件。(react-hooks/exhaustive-deps)
反模式示例:

const Foo = () => {
  const [, render] = useReducer(p => !p, false);
  const ref = useRef(0);

  const onClickRender = () => {
    ref.current += 1;
    render();
  };

  const onClickNoRender = () => {
    ref.current += 1;
  };

  useEffect(() => {
    console.log('ref changed');
  }, [ref.current]);

  return (
    <>
      <button onClick={onClickRender}>Render</button>
      <button onClick={onClickNoRender}>No Render</button>
    </>
  );
};

与此模式相关的一个真实的用例是当我们想要有一个持久引用时,即使元素卸载
检查下一个例子,当它卸载时,我们不能保持元素大小。我们将尝试使用useRefuseEffect组合如上,但它不会工作

// BAD EXAMPLE, SEE SOLUTION BELOW
const Component = () => {
  const ref = useRef();

  const [isMounted, toggle] = useReducer((p) => !p, true);
  const [elementRect, setElementRect] = useState();

  useEffect(() => {
    console.log(ref.current);
    setElementRect(ref.current?.getBoundingClientRect());
  }, [ref.current]);

  return (
    <>
      {isMounted && <div ref={ref}>Example</div>}
      <button onClick={toggle}>Toggle</button>
      <pre>{JSON.stringify(elementRect, null, 2)}</pre>
    </>
  );
};

令人惊讶的是,为了解决这个问题,我们需要直接处理node,同时使用useCallback存储函数:

// GOOD EXAMPLE
const Component = () => {
  const [isMounted, toggle] = useReducer((p) => !p, true);
  const [elementRect, setElementRect] = useState();

  const handleRect = useCallback((node) => {
    setElementRect(node?.getBoundingClientRect());
  }, []);

  return (
    <>
      {isMounted && <div ref={handleRect}>Example</div>}
      <button onClick={toggle}>Toggle</button>
      <pre>{JSON.stringify(elementRect, null, 2)}</pre>
    </>
  );
};

  • 请参阅React Docs中的另一个示例:如何测量DOM节点?
  • 更多阅读和示例参见useEffect的用法
bkhjykvo

bkhjykvo2#

2021年答案:

本文解释了将refs与useEffect一起使用的问题:Ref objects inside useEffect Hooks
如果将useRef钩子与跳过呈现的useEffect合并,则它可能是自定义钩子的陷阱。您的第一React是将ref.current添加到useEffect的第二个参数中,这样一旦ref更改,它就会更新。但是ref直到你的组件渲染后才会更新-这意味着,任何跳过渲染的useEffect在下一次渲染之前都不会看到ref的任何更改。
同样如本文所述,官方的react文档现在已经更新了推荐的方法(使用回调而不是ref + effect)。请参阅如何测量DOM节点?:

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}
inb24sb2

inb24sb23#

我也遇到了同样的问题,我用Typescript创建了一个自定义钩子,用ref callback创建了一个官方方法。希望对大家有所帮助。

export const useRefHeightMeasure = <T extends HTMLElement>() => {
  const [height, setHeight] = useState(0)

  const refCallback = useCallback((node: T) => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height)
    }
  }, [])

  return { height, refCallback }
}
lbsnaicq

lbsnaicq4#

我也遇到过类似的问题,我的ESLint抱怨useCallbackuseCallback中的使用。我在我的项目中添加了一个自定义钩子来规避这个eslint警告。每当ref对象更改时,它切换一个变量以强制重新计算useCallback

import { RefObject, useCallback, useRef, useState } from "react";

/**
 * This hook can be used when using ref inside useCallbacks
 * 
 * Usage
 * ```ts
 * const [toggle, refCallback, myRef] = useRefWithCallback<HTMLSpanElement>();
 * const onClick = useCallback(() => {
    if (myRef.current) {
      myRef.current.scrollIntoView({ behavior: "smooth" });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [toggle]);
  return (<span ref={refCallback} />);
  • @returns
    */
    function useRefWithCallback<T extends HTMLSpanElement | HTMLDivElement | HTMLParagraphElement>(): [
    boolean,
    (node: any) => void,
    RefObject
    ] {
    const ref = useRef<T | null>(null);
    const [toggle, setToggle] = useState(false);
    const refCallback = useCallback(node => {
    ref.current = node;
    setToggle(val => !val);
    }, []);

return [toggle, refCallback, ref];
}

export default useRefWithCallback;

yizd12fk

yizd12fk5#

我在尝试使用ResizeObserver时遇到了这个问题。最后使用回调引用observeRef,我从执行观察的自定义钩子中传递回来。

import { useState, useCallback, useRef } from 'react';

export interface ElementDimensions {
  contentWidth: number;
  contentHeight: number;
  clientWidth: number;
  clientHeight: number;
  scrollWidth: number;
  scrollHeight: number;
  isOverflowingX: boolean;
  isOverflowingY: boolean;
  isOverflowing: boolean;
}

export interface UseResizeObserverResponse {
  observeRef: (target: HTMLElement) => void;
  dimensions: ElementDimensions;
}

/**
 * @returns ref to pass to the target element, ElementDimensions
 */
export const useResizeObserver = (): UseResizeObserverResponse => {
  const [dimensions, setDimensions] = useState<ElementDimensions>({} as ElementDimensions);
  const observer = useRef<ResizeObserver | null>(null); // we only need one observer instance
  const element = useRef<HTMLElement | null>(null);

  const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
    if (!Array.isArray(entries)) {
      return;
    }

    const entry = entries[0];
    const newDimensions: ElementDimensions = {
      contentWidth: entry.contentRect.width,
      contentHeight: entry.contentRect.height,
      clientWidth: entry.target.clientWidth,
      clientHeight: entry.target.clientHeight,
      scrollWidth: entry.target.scrollWidth,
      scrollHeight: entry.target.scrollHeight,
      isOverflowingX: entry.target.clientWidth < entry.target.scrollWidth,
      isOverflowingY: entry.target.clientHeight < entry.target.scrollHeight,
      // compute once on access then replace the getter with result
      get isOverflowing() {
        delete this.isOverflowing;
        return (this.isOverflowing = this.isOverflowingX || this.isOverflowingY);
      },
    };

    setDimensions(newDimensions);
  }, []);

  // initialize resize observer
  const observeRef = useCallback(
    (target: HTMLElement) => {
      // the callback ref fires often without a target, so only process when we have a target
      if (!target) {
        return;
      }

      // instantiate a new observer if needed
      if (!observer.current) {
        observer.current = new ResizeObserver((entries) => handleResize(entries));
      }

      // monitor the new element with cleanup of the old element
      if (element.current !== target) {
        element.current && observer.current?.disconnect(); // call disconnect if monitoring old element
        observer.current.observe(target);
        element.current = target;
      }
    },
    [handleResize]
  );

  return { observeRef, dimensions };
};

用它

const { observeRef, dimensions } = useResizeObserver();
  console.log('Observed dimensions: ', dimensions);
  return <div ref={observeRef} >Observe me</div>;

测试

import { act, renderHook } from '@testing-library/react';
import { useResizeObserver } from './useResizeObserver';

describe('useResizeObserver', () => {
  let listener: ResizeObserverCallback;
  let mockObserverInstance: ResizeObserver;

  beforeEach(() => {
    mockObserverInstance = {
      observe: jest.fn(),
      unobserve: jest.fn(),
      disconnect: jest.fn(),
    };

    window.ResizeObserver = class MockResizeObserver {
      public constructor(ls: ResizeObserverCallback) {
        listener = ls;
      }
      public observe(elem: HTMLElement) {
        mockObserverInstance.observe(elem);
      }
      public disconnect() {
        mockObserverInstance.disconnect();
      }
    } as typeof ResizeObserver;
  });

  afterEach(() => {
    listener = undefined as unknown as ResizeObserverCallback;
    mockObserverInstance = undefined as unknown as ResizeObserver;
    window.ResizeObserver = undefined as unknown as typeof ResizeObserver;
  });

  it('should return a callback ref', () => {
    const { result } = renderHook(() => useResizeObserver());
    expect(result.current).toEqual({ observeRef: expect.any(Function), dimensions: {} });
    expect(mockObserverInstance.observe).not.toHaveBeenCalled();
  });

  it('should synchronously set up ResizeObserver listener', () => {
    const { result } = renderHook(() => useResizeObserver());
    expect(listener).toBeUndefined();

    act(() => {
      const div = document.createElement('div');
      result.current.observeRef(div);
    });

    expect(typeof listener).toBe('function');
  });

  it('should monitor element target with observer', () => {
    const { result } = renderHook(() => useResizeObserver());
    expect(result.current).toEqual({ observeRef: expect.any(Function), dimensions: {} });
    expect(mockObserverInstance.observe).not.toHaveBeenCalled();

    const elem: HTMLElement = document.createElement('div');
    result.current.observeRef(elem);
    expect(mockObserverInstance.observe).toHaveBeenCalledWith(elem);
  });

  it('should stop monitoring old element when new element is provided', () => {
    const { result } = renderHook(() => useResizeObserver());
    expect(result.current).toEqual({ observeRef: expect.any(Function), dimensions: {} });
    expect(mockObserverInstance.observe).not.toHaveBeenCalled();

    const elem: HTMLElement = document.createElement('div');
    result.current.observeRef(elem);
    expect(mockObserverInstance.observe).toHaveBeenCalledWith(elem);
    expect(mockObserverInstance.disconnect).not.toHaveBeenCalled();

    const elem2: HTMLElement = document.createElement('span');
    result.current.observeRef(elem2);
    expect(mockObserverInstance.disconnect).toHaveBeenCalled();
    expect(mockObserverInstance.observe).toHaveBeenCalledWith(elem2);
  });

  it('should track rectangle of a DOM element', () => {
    const { result } = renderHook(() => useResizeObserver());

    let div: HTMLElement;
    act(() => {
      div = document.createElement('div');
      result.current.observeRef(div);
    });

    act(() => {
      listener(
        [
          {
            target: div,
            contentRect: {
              width: 200,
              height: 200,
            },
          } as unknown as ResizeObserverEntry,
        ],
        {} as ResizeObserver
      );
    });

    const expectedDimensions = {
      clientHeight: 0,
      clientWidth: 0,
      contentHeight: 200,
      contentWidth: 200,
      isOverflowing: false,
      isOverflowingX: false,
      isOverflowingY: false,
      scrollHeight: 0,
      scrollWidth: 0,
    };

    expect(result.current.dimensions).toMatchObject(expectedDimensions);
  });

  it('should not update dimensions when no entries provided to listener', () => {
    const { result } = renderHook(() => useResizeObserver());

    let div: HTMLElement;
    act(() => {
      div = document.createElement('div');
      result.current.observeRef(div);
    });

    act(() => {
      listener(undefined as unknown as ResizeObserverEntry[], {} as ResizeObserver);
    });

    expect(result.current.dimensions).toMatchObject({});
  });
});
s71maibg

s71maibg6#

我已经停止使用useRef,现在只使用useState一次或两次:

const [myChart, setMyChart] = useState(null)

const [el, setEl] = useState(null)
useEffect(() => {
    if (!el) {
        return
    }
    // attach to element
    const myChart = echarts.init(el)
    setMyChart(myChart)
    return () => {
        myChart.dispose()
        setMyChart(null)
    }
}, [el])

useEffect(() => {
    if (!myChart) {
        return
    }
    // do things with attached object
    myChart.setOption(... data ...)
}, [myChart, data])

return <div key='chart' ref={setEl} style={{ width: '100%', height: 1024 }} />

对于图表,auth和其他非react库很有用,因为它保留了一个元素ref和初始化的对象,并且可以根据需要直接处理它
我现在不知道为什么useRef首先存在...?

相关问题