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

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

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

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

我想到的方法是将一个变量‘ 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
138848 次浏览

You can attach a ref to the element that you want to check if it is on the viewport and then have something like:

  /**
* 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>)


}

You can attach listeners like onScroll and check when the element will be on the viewport.

You can also use the Intersection Observer API with a polyfil or use a HoC component that does the job

Based on Avraam's answer I wrote a Typescript-compatible small hook to satisfy the actual React code convention.

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];
}


Usage example:

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


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


You can find the example on Codesandbox. I hope you will find it helpful!

I have had the same problem, and, looks, I found the pretty good solution in pure react jsx, without installing any libraries.

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;

I was trying to figure out how to animate elements if the are in viewport.

Here is work project on CodeSandbox.

Answer based on the post from @Alex Gusev

React hook to check whether the element is visible with a few fixes and based on the rxjs library.

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];
}

Here is a reusable hook that takes advantage of the IntersectionObserver API.

The hook

export default function useOnScreen(ref) {


const [isIntersecting, setIntersecting] = useState(false)


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


useEffect(() => {
observer.observe(ref.current)
// Remove the observer as soon as the component is unmounted
return () => { observer.disconnect() }
}, [])


return isIntersecting
}

Usage

const DummyComponent = () => {
  

const ref = useRef()
const isVisible = useOnScreen(ref)
  

return <div ref={ref}>{isVisible && `Yep, I'm on screen`}</div>
}

@Alex Gusev answer without lodash and using 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]
}

usage example:

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


return (
<div ref={beforeCheckoutSubmitRef} />

This is based on the answer from Creaforge but more optimized for the case when you want to check if the component has become visible (and in TypeScript).

Hook

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;
}

Usage

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

Keep in mind that if your component is not mounted at the same time as the hook is called you would have to make this code more complicated. Like turning dependency array into [wasSeen, ref.current]

TypeScript based approach to @Creaforge's Intersection Observer approach, that fixes the issue with ref.current being potentially undefined if the hook was called before the element is mounted:

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];
}


Usage:

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

After trying out the different proposed solutions with TypeScript, we have been facing errors due to the first render setting the default useRef to null.

Here you have our solution just in case it helps other people 😊

The hook

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 };
}

Usage

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>
</>
);
}

Solution thanks to @isma-navarro 😊