Dynamically Monitor DOM Element Height Changes

1. Background Overview

I have encountered such a situation. There is a fixed area on our web page, this area will be used to render rich text strings containing images and other resources pulled from the backend. It needs to fully display all content while the content does not exceed a maximum height. If the maximum content is exceeded, only the content within the maximum height range will be displayed. The excess is hidden, and a button "Show more" is used to show the user more options.

This seemingly simple requirement actually involves a difficulty, which is how to dynamically monitor the height change of the content area?

It will contain image resources. They will make network requests when rendering, and wait for the image to load to trigger the browser to reflow. The height of this area is propped up. Therefore, the height of the content area changes dynamically, and the time point of the change is unknown, so how do we know that the height of our content area has changed?

For this I have tried the following:

MutationObserver

IntersectionObserver

ResizeObserver

Listen to the onload event of all resources

2. MutationObserver

The MutationObserver interface provides the ability to monitor changes made to the dom tree. It was designed as a replacement for the old Mutation Events feature, which was part of the DOM3 Events specification.

observe(target, options)

This method will observe the changes of a single Node or all descendant nodes in the DOM tree according to the passed options configuration. It has a total of seven properties, which will not be introduced here.

So how do we use this API to monitor the height change of the target area?

1. First we need to create a reference to the dom root node of the area.

// useRef creates a reference
const contentRef = useRef();

// Bind ref
<div
   className="content"
   dangerouslySetInnerhtml={{ __html: details }}
   style={{ maxHeight }}
   ref={contentRef}
/>;

2. Then we need to create a MutationObserver instance.

const [height, setHeight] = useState(-1);
const [observer, setObserver] = useState<MutationObserver>(null!);
useEffect(() => {
   const observer = new MutationObserver((mutationList) => {
     if (height !== contentRef.current?.clientHeight) {
       console.log("Height changed!");
       setHeight(contentRef.current.clientHeight);
     }
   });
   setObserver(observer);
}, []);

3. When our ref or observer changes, observe the ref node.

useEffect(() => {
   if (!observer || !contentRef.current) return;
   observer.observe(contentRef.current, {
     childList: true, // Changes of child nodes (add, delete or change)
     attributes: true, // Attribute changes
     characterData: true, // Change of node content or node text
     subtree: true, // whether to apply the observer to all descendants of this node
   });
}, [contentRef.current, observer]);

Below is the complete code.

const Details = () => {
    // useRef creates a reference
    const contentRef = useRef();
    const [height, setHeight] = useState(-1);
    const [observer, setObserver] = useState<MutationObserver>(null!);

    useEffect(() => {
          const observer = new MutationObserver((mutationList) => {
            if (height !== contentRef.current?.clientHeight) {
                console.log('The height has changed!');
                setHeight(contentRef.current.clientHeight);
            }
          });
          setObserver(observer);
    }, []);

    useEffect(() => {
          if (!observer || !contentRef.current) return
          observer.observe(contentRef, {
            childList: true, // Changes of child nodes (add, delete or change)
            attributes: true, // Attribute changes
            characterData: true, // Change of node content or node text
            subtree: true// whether to apply the observer to all descendant nodes of this node
          });
    }, [contentRef.current, observer]);

    // bind ref
    return<div className="content" dangerouslySetInnerHTML={{ __html: details }} style={{ maxHeight }} ref={contentRef} />
}

After some of the above operations, we found that the effect was not achieved at all, because our CSS properties did not change at all (we used maxHeight to constrain the height of the container),. However, after the resource is loaded, the browser reflow does not change the CSS property at all, and its height is automatically calculated.

So this solution is useless! But it can indeed listen to changes made by modifying the height of the container, for example, contentRef.current.style.height = '1000px'. This API can monitor this operation, but it does not meet our scenario. Of course, its browser compatibility is okay too.

3. Intersection Observer

After coding, we finally found that MutationObserver can't achieve the effect we want at all. In fact, my mentality has undergone some changes, but it doesn't matter! We can change our minds. Since we can't display the corresponding "expand more" operation by listening to the height change of the container, can we fix this "expand more" to a position, and then hide the excess part?

When our content is automatically stretched and reaches the specified height, the button for our "expand more" operation is displayed. It sounds good and does what it wants! Don't talk nonsense, let's start!

Because it only involves the writing of the corresponding CSS style, it will not be displayed. After processing, it is true that when the container height is less than the specified height, the "Show More" button will not be displayed. After the maximum value is exceeded, the button will be displayed.

But also encountered a problem. Action buttons have heights, and if our content height is between the maximum height - the button height and the container's maximum height, the button will produce a portion of the display. At the same time, some effects are hidden. This is not what we want!

Obviously, this effect is not in line with the requirements. Our "Show more" button has only two states, either showing all, or not showing, and there is no such partial showing effect.

So I checked the relevant materials and learned about the IntersectionObserver API, which can monitor whether an element enters the user's field of view. It is used almost the same as MutationObserver, just with a different name. There is a more important attribute in the value it monitors: intersectionRatio.

With this API, I have a new design idea. When the user scrolls the web page (or not, when the target area is already on the screen), the value of intersectionRatio can be obtained. Then decide whether to show the "Show more" button by judging whether this value is equal to 1.

But after my coding implementation, I found that when the scroll event occurs, the change of intersectionRatio is unreliable. Sometimes it is fully visible, but it is not equal to 1. After several rounds of experiments, the result remains the same. But it can indeed be used to determine whether an element is in the user's field of view. Due to the unreliable results of the use, I still gave up this solution (maybe there is a problem with the way I use it).

4. ResizeObserver

As the name suggests, this API is designed to monitor DOM size changes. It's just that it is still in the experimental stage, and the compatibility of various browsers is very poor, so it is basically ignored.

5. Listen to the onload event of all resources

Since none of the above methods worked, I racked my brains and came up with another method. I can listen to the onload event of all DOM elements with the src attribute, and then judge the height of the current container through its callback.

This implementation method is completely in line with the purpose in terms of ideas, and the specific practice is as follows.

const [height, setHeight] = useState(-1);
const [showMore, setShowMore] = useState(false);
// For the definition of contentRef, see the MutationObserver section
useEffect(() => {
   const sources = contentRef.current.querySelectorAll("[src]");
   sources.onload = () => {
     const height = contentRef?.current?.clientHeight?? 0;
     const show = height >= parseInt(MAX_HEIGHT, 10);

     setHeight(height); setShowMore(show);
   };
}, []);

In this way, the height of the container can be judged accordingly after the image in the rich text is loaded. However, in this way, there is uncertainty, that is, it is impossible to judge whether all resources that are highly propped up by content have been found.

6. Iframe

This is the ultimate solution, and it's the one adopted in this context. Since the window can listen to the resize event, we can use an iframe to achieve the same effect. The specific method is to nest a hidden iframe with a height of 100% in the container and judge the height of the current container by listening to its resize event.

Without further ado, the specific implementation is as follows.

  const Detail: FC<{}> = () => {
  const ref = useRef<HTMLDivElement>(null);
  const ifr = useRef<HTMLIFrameElement>(null);
  const [height, setHeight] = useState(-1);
  const [showMore, setShowMore] = useState(false);
  const [maxHeight, setMaxHeight] = useState(MAX_HEIGHT);
  const introduceInfo = useAppSelect(
    (state) => state.courseInfo?.data?.introduce_info??{}
  );
  const details = introduceInfo.details ?? "";
  const isFolded = maxHeight === MAX_HEIGHT;
  const onresize = useCallback(() => {
    const height = ref?.current?.clientHeight?? 0;
    const show = height >= parseInt(MAX_HEIGHT, 10);

    setHeight(height); setShowMore(show);
    if (ifr.current && show) {
      ifr.current.remove();
    }
  }, []);

  useEffect(() => {
    if (!ref.current || !ifr.current?.contentWindow) return;
    ifr.current.contentWindow.onresize = onresize;
    onresize();
  }, [details]);

  if (!details) returnnull;

  return (
    <section className="section detail-content">
      <div className="content-wrapper">
        <div
          className="content"
          dangerouslySetInnerHTML={{ __html: details }}
          style={{ maxHeight }}
          ref={ref}
        />
        {/* This iframe is used to dynamically monitor content height changes */}
        <iframe title={IFRAME_ID} id={IFRAME_ID} ref={ifr} />
      </div>
      {isFolded && showMore && (
        <>
          <div
            className="show-more"
            onClick={() => {
              setMaxHeight(isFolded ? "none" : MAX_HEIGHT);
            }}
          >
            view all
            <IconArrowDown className="icon" />
          </div>
          <div className="mask" />
        </>
      )}
    </section>
  );
};

This method is actually a hack of ResizeObserver. After many practices, it meets the functional requirements.

7. Summary

Monitoring the height change of DOM elements can be solved by an inline iframe.

To solve the problem, we should consider as many situations as possible, compare multiple solutions, and adopt the most reliable solution. Keep learning and inquire about more information, the problems you have encountered have basically been stepped on by predecessors.



Leave a reply



Submit