React - 检查元素在 DOM 中是否可见

问题描述 投票:0回答:11

我正在构建一个表单 - 用户需要回答一系列问题(单选按钮),然后才能进入下一个屏幕。对于字段验证,我使用 yup (npm 包)和 redux 作为状态管理。

对于一种特定场景/组合,会显示一个新屏幕 (div),要求用户进行确认(复选框),然后用户才能继续。我想仅在显示时应用此复选框的验证。

如何使用 React 检查元素(div)是否显示在 DOM 中?

我的想法是将变量“isScreenVisible”设置为 false,如果满足条件,我会将状态更改为“true”。

我正在检查并在 _renderScreen() 中将“isScreenVisible”设置为 true 或 false,但由于某种原因它会进入无限循环。

我的代码:

class Component extends React.Component {

  constructor(props) {
    super(props);

    this.state = {
      formisValid: true,
      errors: {},
      isScreenVisible: false
    }

    this.FormValidator = new Validate();
    this.FormValidator.setValidationSchema(this.getValidationSchema());
  }

  areThereErrors(errors) {
    var key, er = false;
    for(key in errors) {
      if(errors[key]) {er = true}
    }
    return er;
  }

  getValidationSchema() {
    return yup.object().shape({
      TravelInsurance: yup.string().min(1).required("Please select an option"),
      MobilePhoneInsurance: yup.string().min(1).required("Please select an option"),
      Confirmation: yup.string().min(1).required("Please confirm"),
    });
  }

  //values of form fields
  getValidationObject() {
    let openConfirmation = (this.props.store.Confirmation === true)? 'confirmed': ''

    return {
      TravelInsurance: this.props.store.TravelInsurance,
      MobilePhoneInsurance: this.props.store.MobilePhoneInsurance,
      Confirmation: openConfirmation,
    }
  }

  setSubmitErrors(errors) {
    this.setState({errors: errors});
  }

  submitForm() {
    var isErrored, prom, scope = this, obj = this.getValidationObject();
    prom = this.FormValidator.validateSubmit(obj);

    prom.then((errors) => {
      isErrored = this.FormValidator.isFormErrored();

      scope.setState({errors: errors}, () => {
        if (isErrored) {
        } else {
          this.context.router.push('/Confirm');
        }
      });
    });
  }

  saveData(e) {
    let data = {}
    data[e.target.name] = e.target.value

    this.props.addData(data)

    this.props.addData({
      Confirmation: e.target.checked
    })
  }

  _renderScreen = () => {
    const {
      Confirmation
    } = this.props.store

    if(typeof(this.props.store.TravelInsurance) !== 'undefined' && typeof(this.props.store.MobilePhoneInsurance) !== 'undefined') &&
    ((this.props.store.TravelInsurance === 'Yes' && this.props.store.MobilePhoneInsurance === 'No') ||
    (this.props.store.TravelInsurance === 'No' && this.props.store.MobilePhoneInsurance === 'Yes')){

        this.setState({
            isScreenVisible: true
        })

          return(
            <div>
                <p>Please confirm that you want to proceed</p>

                  <CheckboxField
                    id="Confirmation"
                    name="Confirmation"
                    value={Confirmation}
                    validationMessage={this.state.errors.Confirmation}
                    label="I confirm that I would like to continue"
                    defaultChecked={!!Confirmation}
                    onClick={(e)=> {this.saveData(e)} }
                  />
                </FormLabel>
            </div>
          )
      }
      else{
        this.setState({
            isScreenVisible: false
        })
      }
  }

  render(){
    const {
      TravelInsurance,
      MobilePhoneInsurance
    } = this.props.store

    return (
      <div>           
          <RadioButtonGroup
            id="TravelInsurance"
            name="TravelInsurance"
            checked={TravelInsurance}
            onClick={this.saveData.bind(this)}
            options={{
              'Yes': 'Yes',
              'No': 'No'
            }}
            validationMessage={(this.state.errors.TravelInsurance) ? this.state.errors.TravelInsurance : null }
          />

        <RadioButtonGroup
          id="MobilePhoneInsurance"
          name="MobilePhoneInsurance"
          checked={MobilePhoneInsurance}
          onClick={this.saveData.bind(this)}
          options={{
            'Yes': 'Yes',
            'No': 'No'
          }}
          validationMessage={(this.state.errors.MobilePhoneInsurance) ? this.state.errors.MobilePhoneInsurance : null }
        />

        this._renderScreen()

        <ButtonRow
            primaryProps={{
                children: 'Continue',
                onClick: e=>{
                this.submitForm();
            }
        }}
      </div>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    store: state.Insurance,
  }
}

const Insurance = connect(mapStateToProps,{addData})(Component)

export default Insurance
javascript reactjs
11个回答
222
投票

这是一个可重用的钩子,它利用了 IntersectionObserver API。

挂钩

export default function useOnScreen(ref: RefObject<HTMLElement>) {

  const [isIntersecting, setIntersecting] = useState(false)

  const observer = useMemo(() => new IntersectionObserver(
    ([entry]) => setIntersecting(entry.isIntersecting)
  ), [ref])


  useEffect(() => {
    observer.observe(ref.current)
    return () => observer.disconnect()
  }, [])

  return isIntersecting
}

使用方法

const DummyComponent = () => {
  
  const ref = useRef<HTMLDivElement>(null)
  const isVisible = useOnScreen(ref)
  
  return <div ref={ref}>{isVisible && `Yep, I'm on screen`}</div>
}

46
投票

您可以将引用附加到要检查它是否在视口上的元素,然后使用类似以下内容:

  /**
   * Check if an element is in viewport
   *
   * @param {number} [offset]
   * @returns {boolean}
   */
  isInViewport(offset = 0) {
    if (!this.yourElement) return false;
    const top = this.yourElement.getBoundingClientRect().top;
    return (top + offset) >= 0 && (top - offset) <= window.innerHeight;
  }


  render(){

     return(<div ref={(el) => this.yourElement = el}> ... </div>)

  }

您可以附加诸如

onScroll
之类的侦听器并检查元素何时出现在视口上。

您还可以将Intersection Observer API与polyfil一起使用或使用HoC组件来完成这项工作


28
投票

根据 Avraam 的回答,我编写了一个兼容 Typescript 的小钩子来满足实际的 React 代码约定。

import { useRef, useEffect, useState } from "react";
import throttle from "lodash.throttle";

/**
 * Check if an element is in viewport

 * @param {number} offset - Number of pixels up to the observable element from the top
 * @param {number} throttleMilliseconds - Throttle observable listener, in ms
 */
export default function useVisibility<Element extends HTMLElement>(
  offset = 0,
  throttleMilliseconds = 100
): [Boolean, React.RefObject<Element>] {
  const [isVisible, setIsVisible] = useState(false);
  const currentElement = useRef<Element>();

  const onScroll = throttle(() => {
    if (!currentElement.current) {
      setIsVisible(false);
      return;
    }
    const top = currentElement.current.getBoundingClientRect().top;
    setIsVisible(top + offset >= 0 && top - offset <= window.innerHeight);
  }, throttleMilliseconds);

  useEffect(() => {
    document.addEventListener('scroll', onScroll, true);
    return () => document.removeEventListener('scroll', onScroll, true);
  });

  return [isVisible, currentElement];
}

使用示例:

const Example: FC = () => {
  const [ isVisible, currentElement ] = useVisibility<HTMLDivElement>(100);

  return <Spinner ref={currentElement} isVisible={isVisible} />;
};

您可以在Codesandbox上找到示例。 我希望你会发现它有帮助!


9
投票

@Alex Gusev 没有 lodash 并使用 useRef 回答

import { MutableRefObject, useEffect, useRef, useState } from 'react'

/**
 * Check if an element is in viewport
 * @param {number} offset - Number of pixels up to the observable element from the top
 */
export default function useVisibility<T>(
  offset = 0,
): [boolean, MutableRefObject<T>] {
  const [isVisible, setIsVisible] = useState(false)
  const currentElement = useRef(null)

  const onScroll = () => {
    if (!currentElement.current) {
      setIsVisible(false)
      return
    }
    const top = currentElement.current.getBoundingClientRect().top
    setIsVisible(top + offset >= 0 && top - offset <= window.innerHeight)
  }

  useEffect(() => {
    document.addEventListener('scroll', onScroll, true)
    return () => document.removeEventListener('scroll', onScroll, true)
  })

  return [isVisible, currentElement]
}

使用示例:

 const [beforeCheckoutSubmitShown, beforeCheckoutSubmitRef] = useVisibility<HTMLDivElement>()

 return (
     <div ref={beforeCheckoutSubmitRef} />

6
投票

在使用 TypeScript 尝试不同的建议解决方案后,由于第一次渲染将默认的

useRef
设置为
null
,我们一直面临错误。

这里有我们的解决方案,以防它对其他人有帮助😊

挂钩

useInViewport.ts

import React, { useCallback, useEffect, useState } from "react";

export function useInViewport(): { isInViewport: boolean; ref: React.RefCallback<HTMLElement> } {
    const [isInViewport, setIsInViewport] = useState(false);
    const [refElement, setRefElement] = useState<HTMLElement | null>(null);

    const setRef = useCallback((node: HTMLElement | null) => {
        if (node !== null) {
            setRefElement(node);
        }
    }, []);

    useEffect(() => {
        if (refElement && !isInViewport) {
            const observer = new IntersectionObserver(
                ([entry]) => entry.isIntersecting && setIsInViewport(true)
            );
            observer.observe(refElement);

            return () => {
                observer.disconnect();
            };
        }
    }, [isInViewport, refElement]);

    return { isInViewport, ref: setRef };
}

使用方法

SomeReactComponent.tsx

import { useInViewport } from "../layout/useInViewport";

export function SomeReactComponent() {
    const { isInViewport, ref } = useInViewport();

    return (
        <>
            <h3>A component which only renders content if it is in the current user viewport</h3>

            <section ref={ref}>{isInViewport && (<ComponentContentOnlyLoadedIfItIsInViewport />)}</section>
        </>
    );
}

解决方案感谢@isma-navarro😊


5
投票

我也遇到了同样的问题,而且,看起来,我在纯 React jsx 中找到了非常好的解决方案,无需安装任何库。

import React, {Component} from "react";
    
    class OurReactComponent extends Component {

    //attach our function to document event listener on scrolling whole doc
    componentDidMount() {
        document.addEventListener("scroll", this.isInViewport);
    }

    //do not forget to remove it after destroyed
    componentWillUnmount() {
        document.removeEventListener("scroll", this.isInViewport);
    }

    //our function which is called anytime document is scrolling (on scrolling)
    isInViewport = () => {
        //get how much pixels left to scrolling our ReactElement
        const top = this.viewElement.getBoundingClientRect().top;

        //here we check if element top reference is on the top of viewport
        /*
        * If the value is positive then top of element is below the top of viewport
        * If the value is zero then top of element is on the top of viewport
        * If the value is negative then top of element is above the top of viewport
        * */
        if(top <= 0){
            console.log("Element is in view or above the viewport");
        }else{
            console.log("Element is outside view");
        }
    };

    render() {
        // set reference to our scrolling element
        let setRef = (el) => {
            this.viewElement = el;
        };
        return (
            // add setting function to ref attribute the element which we want to check
            <section ref={setRef}>
                {/*some code*/}
            </section>
        );
    }
}

export default OurReactComponent;

我试图弄清楚如何为视口中的元素设置动画。

这是 CodeSandbox 上的工作项目。


3
投票

这是基于

Creaforge
的答案,但针对您想要检查组件是否可见(并且在 TypeScript 中)的情况进行了更优化。

挂钩

function useWasSeen() {
  // to prevents runtime crash in IE, let's mark it true right away
  const [wasSeen, setWasSeen] = React.useState(
    typeof IntersectionObserver !== "function"
  );

  const ref = React.useRef<HTMLDivElement>(null);
  React.useEffect(() => {
    if (ref.current && !wasSeen) {
      const observer = new IntersectionObserver(
        ([entry]) => entry.isIntersecting && setWasSeen(true)
      );
      observer.observe(ref.current);
      return () => {
        observer.disconnect();
      };
    }
  }, [wasSeen]);
  return [wasSeen, ref] as const;
}

使用方法

const ExampleComponent = () => {
  const [wasSeen, ref] = useWasSeen();  
  return <div ref={ref}>{wasSeen && `Lazy loaded`}</div>
}

请记住,如果您的组件没有在调用钩子的同时安装,您将不得不使此代码变得更加复杂。就像将依赖数组变成

[wasSeen, ref.current]


1
投票

@Creaforge 的 Intersection Observer 方法基于 TypeScript 的方法,修复了如果在挂载元素之前调用钩子则

ref.current
可能未定义的问题:

export default function useOnScreen<Element extends HTMLElement>(): [
  boolean,
  React.RefCallback<Element>,
] {
  const [intersecting, setIntersecting] = useState(false);
  const observer = useMemo(
    () => new IntersectionObserver(([entry]) => setIntersecting(entry.isIntersecting)),
    [setIntersecting],
  );

  const currentElement = useCallback(
    (ele: Element | null) => {
      if (ele) {
        observer.observe(ele);
      } else {
        observer.disconnect();
        setIntersecting(false);
      }
    },
    [observer, setIntersecting],
  );

  return [intersecting, currentElement];
}

用途:

const [endOfList, endOfListRef] = useOnScreen();
...
return <div ref={endOfListRef} />

0
投票

基于@GuCier评论。 我们可以添加另一个参数来指定容器元素。 这对于模态、表格、列表、任何带有滚动的非主体元素可能很有用。

import { useEffect, useMemo, useState } from 'react';

// A custom hook that receives an element and an optional
// parent element (defaults to body) and returns a boolean
// value based on whether the element in question
// is visible inside the said element.
// This information is useful if we want to prevent unnecessary
// image or complex component renders.
export const useIsVisible = (elRef, containerRef) => {
  const [isIntersecting, setIntersecting] = useState(false);

  const observer = useMemo(
    () =>
      new IntersectionObserver(
        ([entry]) => setIntersecting(entry.isIntersecting),
        { root: containerRef?.current }
      ),
    [elRef, containerRef] //eslint-disable-line
  );

  useEffect(() => {
    observer.observe(elRef.current);
    return () => observer.disconnect();
  }, []); //eslint-disable-line

  return isIntersecting;
};


0
投票

支持使用滚动条快速滚动,无视觉错误,打字稿友好

import {useEffect, useState, useRef, RefObject} from 'react';

/**
 * Custom Hook to determine if an element is in the viewport.
 * @param rootMargin Margin around the root, similar to the CSS margin property.
 * @returns A boolean state indicating visibility.
 */
export const useInView = (ref: RefObject<HTMLElement>, rootMargin: string = '0px') => {
    const [isInView, setInView] = useState(false);

    // Use a single instance of IntersectionObserver
    const observerRef = useRef<IntersectionObserver>();

    useEffect(() => {
        if (observerRef.current) {
            observerRef.current.disconnect(); // Disconnect previous observer if it exists
        }

        observerRef.current = new IntersectionObserver((entries) => {
            // Set state based on whether the element is intersecting
            setInView(entries[0].isIntersecting);
        }, { rootMargin });

        const { current } = ref;
        if (current) {
            observerRef.current.observe(current);
        }

        return () => {
            observerRef.current?.disconnect();
        };
    }, [ref, rootMargin]); // Re-run effect if ref or rootMargin changes

    return isInView;
};

-1
投票

根据@Alex Gusev 的帖子回答

React hook,通过一些修复并基于 rxjs 库来检查元素是否可见。

import React, { useEffect, createRef, useState } from 'react';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, throttleTime } from 'rxjs/operators';

/**
 * Check if an element is in viewport
 * @param {number} offset - Number of pixels up to the observable element from the top
 * @param {number} throttleMilliseconds - Throttle observable listener, in ms
 * @param {boolean} triggerOnce - Trigger renderer only once when element become visible
 */
export default function useVisibleOnScreen<Element extends HTMLElement>(
  offset = 0,
  throttleMilliseconds = 1000,
  triggerOnce = false,
  scrollElementId = ''
): [boolean, React.RefObject<Element>] {
  const [isVisible, setIsVisible] = useState(false);
  const currentElement = createRef<Element>();

  useEffect(() => {
    let subscription: Subscription | null = null;
    let onScrollHandler: (() => void) | null = null;
    const scrollElement = scrollElementId
      ? document.getElementById(scrollElementId)
      : window;
    const ref = currentElement.current;
    if (ref && scrollElement) {
      const subject = new Subject();
      subscription = subject
        .pipe(throttleTime(throttleMilliseconds))
        .subscribe(() => {
          if (!ref) {
            if (!triggerOnce) {
              setIsVisible(false);
            }
            return;
          }

          const top = ref.getBoundingClientRect().top;
          const visible =
            top + offset >= 0 && top - offset <= window.innerHeight;
          if (triggerOnce) {
            if (visible) {
              setIsVisible(visible);
            }
          } else {
            setIsVisible(visible);
          }
        });
      onScrollHandler = () => {
        subject.next();
      };
      if (scrollElement) {
        scrollElement.addEventListener('scroll', onScrollHandler, false);
      }
      // Check when just loaded:
      onScrollHandler();
    } else {
      console.log('Ref or scroll element cannot be found.');
    }

    return () => {
      if (onScrollHandler && scrollElement) {
        scrollElement.removeEventListener('scroll', onScrollHandler, false);
      }
      if (subscription) {
        subscription.unsubscribe();
      }
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [offset, throttleMilliseconds, triggerOnce, scrollElementId]);

  return [isVisible, currentElement];
}
© www.soinside.com 2019 - 2024. All rights reserved.