[리액트] LazyLoad refactoring

Jang Seok Woo·2022년 8월 30일
0

실무

목록 보기
129/136

기존 오픈소스 LazyLoad
출처 : https://github.com/twobin/react-lazyload/blob/master/src/index.jsx

import { debounce, throttle, isFunction } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styled from 'styled-components';

import { on, off } from './utils/event';
import scrollParent from './utils/scrollParent';

const Root = styled.div`
  &:empty {
    display: none;
  }
`;

const Placeholder = styled.div``;

const defaultBoundingClientRect = {
  top: 0,
  right: 0,
  bottom: 0,
  left: 0,
  width: 0,
  height: 0,
};
const LISTEN_FLAG = 'data-lazyload-listened';
const listeners = [];
let pending = [];

// try to handle passive events
let passiveEventSupported = false;
try {
  const opts = Object.defineProperty({}, 'passive', {
    get() {
      passiveEventSupported = true;
    },
  });
  window.addEventListener('test', null, opts);
} catch (e) {}
// if they are supported, setup the optional params
// IMPORTANT: FALSE doubles as the default CAPTURE value!
const passiveEvent = passiveEventSupported
  ? { capture: false, passive: true }
  : false;

/**
 * Check if `component` is visible in overflow container `parent`
 * @param  {node} component React component
 * @param  {node} parent    component's scroll parent
 * @return {bool}
 */
const checkOverflowVisible = function checkOverflowVisible(component, parent) {
  const node = component.ref;

  let parentTop;
  let parentLeft;
  let parentHeight;
  let parentWidth;

  try {
    ({
      top: parentTop,
      left: parentLeft,
      height: parentHeight,
      width: parentWidth,
    } = parent.getBoundingClientRect());
  } catch (e) {
    ({
      top: parentTop,
      left: parentLeft,
      height: parentHeight,
      width: parentWidth,
    } = defaultBoundingClientRect);
  }

  const windowInnerHeight =
    window.innerHeight || document.documentElement.clientHeight;
  const windowInnerWidth =
    window.innerWidth || document.documentElement.clientWidth;

  // calculate top and height of the intersection of the element's scrollParent and viewport
  const intersectionTop = Math.max(parentTop, 0); // intersection's top relative to viewport
  const intersectionLeft = Math.max(parentLeft, 0); // intersection's left relative to viewport
  const intersectionHeight =
    Math.min(windowInnerHeight, parentTop + parentHeight) - intersectionTop; // height
  const intersectionWidth =
    Math.min(windowInnerWidth, parentLeft + parentWidth) - intersectionLeft; // width

  // check whether the element is visible in the intersection
  let top;
  let left;
  let height;
  let width;

  try {
    ({ top, left, height, width } = node.getBoundingClientRect());
  } catch (e) {
    ({ top, left, height, width } = defaultBoundingClientRect);
  }

  const offsetTop = top - intersectionTop; // element's top relative to intersection
  const offsetLeft = left - intersectionLeft; // element's left relative to intersection

  const offsets = Array.isArray(component.props.offset)
    ? component.props.offset
    : [component.props.offset, component.props.offset]; // Be compatible with previous API

  return (
    offsetTop - offsets[0] <= intersectionHeight &&
    offsetTop + height + offsets[1] >= 0 &&
    offsetLeft - offsets[0] <= intersectionWidth &&
    offsetLeft + width + offsets[1] >= 0
  );
};

/**
 * Check if `component` is visible in document
 * @param  {node} component React component
 * @return {bool}
 */
const checkNormalVisible = function checkNormalVisible(component) {
  const node = component.ref;

  // If this element is hidden by css rules somehow, it's definitely invisible
  if (!(node.offsetWidth || node.offsetHeight || node.getClientRects().length)) return false;

  let top;
  let elementHeight;

  try {
    ({ top, height: elementHeight } = node.getBoundingClientRect());
  } catch (e) {
    ({ top, height: elementHeight } = defaultBoundingClientRect);
  }

  const windowInnerHeight =
    window.innerHeight || document.documentElement.clientHeight;

  const offsets = Array.isArray(component.props.offset)
    ? component.props.offset
    : [component.props.offset, component.props.offset]; // Be compatible with previous API

  return (
    top - offsets[0] <= windowInnerHeight &&
    top + elementHeight + offsets[1] >= 0
  );
};

/**
 * Detect if element is visible in viewport, if so, set `visible` state to true.
 * If `once` prop is provided true, remove component as listener after checkVisible
 *
 * @param  {React} component   React component that respond to scroll and resize
 */
const checkVisible = function checkVisible(component) {
  const node = component.ref;
  if (!(node instanceof HTMLElement)) {
    return;
  }

  const parent = scrollParent(node);
  const isOverflow =
    component.props.overflow &&
    parent !== node.ownerDocument &&
    parent !== document &&
    parent !== document.documentElement;
  const visible = isOverflow
    ? checkOverflowVisible(component, parent)
    : checkNormalVisible(component);
  if (visible) {
    // Avoid extra render if previously is visible
    if (!component.visible) {
      if (component.props.once) {
        pending.push(component);
      }

      component.visible = true;
      component.forceUpdate();
    }
  } else if (!(component.props.once && component.visible)) {
    component.visible = false;
    if (component.props.unmountIfInvisible) {
      component.forceUpdate();
    }
  }
};

const purgePending = function purgePending() {
  pending.forEach((component) => {
    const index = listeners.indexOf(component);
    if (index !== -1) {
      listeners.splice(index, 1);
    }
  });

  pending = [];
};

const lazyLoadHandler = () => {
  for (let i = 0; i < listeners.length; ++i) {
    const listener = listeners[i];
    checkVisible(listener);
  }
  // Remove `once` component in listeners
  purgePending();
};

/**
 * Forces the component to display regardless of whether the element is visible in the viewport.
 */
const forceVisible = () => {
  for (let i = 0; i < listeners.length; ++i) {
    const listener = listeners[i];
    listener.visible = true;
    listener.forceUpdate();
  }
  // Remove `once` component in listeners
  purgePending();
};

// Depending on component's props
let delayType;
let finalLazyLoadHandler = null;

const isString = (string) => typeof string === 'string';

class LazyLoad extends Component {
  constructor(props) {
    super(props);

    this.visible = false;
    this.setRef = this.setRef.bind(this);
  }

  componentDidMount() {
    // It's unlikely to change delay type on the fly, this is mainly
    // designed for tests
    let scrollport = window;
    const { scrollContainer } = this.props;
    if (scrollContainer) {
      if (isString(scrollContainer)) {
        scrollport = scrollport.document.querySelector(scrollContainer);
      }
    }
    const needResetFinalLazyLoadHandler =
      (this.props.debounce !== undefined && delayType === 'throttle') ||
      (delayType === 'debounce' && this.props.debounce === undefined);

    if (needResetFinalLazyLoadHandler) {
      off(scrollport, 'scroll', finalLazyLoadHandler, passiveEvent);
      off(window, 'resize', finalLazyLoadHandler, passiveEvent);
      finalLazyLoadHandler = null;
    }

    if (!finalLazyLoadHandler) {
      if (this.props.debounce !== undefined) {
        finalLazyLoadHandler = debounce(
          lazyLoadHandler,
          typeof this.props.debounce === 'number' ? this.props.debounce : 300,
        );
        delayType = 'debounce';
      } else if (this.props.throttle !== undefined) {
        finalLazyLoadHandler = throttle(
          lazyLoadHandler,
          typeof this.props.throttle === 'number' ? this.props.throttle : 300,
        );
        delayType = 'throttle';
      } else {
        finalLazyLoadHandler = lazyLoadHandler;
      }
    }

    if (this.props.overflow) {
      const parent = scrollParent(this.ref);
      if (parent && typeof parent.getAttribute === 'function') {
        const listenerCount = 1 + +parent.getAttribute(LISTEN_FLAG);
        if (listenerCount === 1) {
          parent.addEventListener('scroll', finalLazyLoadHandler, passiveEvent);
        }
        parent.setAttribute(LISTEN_FLAG, listenerCount);
      }
    } else if (listeners.length === 0 || needResetFinalLazyLoadHandler) {
      const { scroll, resize } = this.props;

      if (scroll) {
        on(scrollport, 'scroll', finalLazyLoadHandler, passiveEvent);
      }

      if (resize) {
        on(window, 'resize', finalLazyLoadHandler, passiveEvent);
      }
    }

    listeners.push(this);
    checkVisible(this);
  }

  shouldComponentUpdate() {
    return this.visible;
  }

  componentWillUnmount() {
    if (this.props.overflow) {
      const parent = scrollParent(this.ref);
      if (parent && typeof parent.getAttribute === 'function') {
        const listenerCount = +parent.getAttribute(LISTEN_FLAG) - 1;
        if (listenerCount === 0) {
          parent.removeEventListener(
            'scroll',
            finalLazyLoadHandler,
            passiveEvent,
          );
          parent.removeAttribute(LISTEN_FLAG);
        } else {
          parent.setAttribute(LISTEN_FLAG, listenerCount);
        }
      }
    }

    const index = listeners.indexOf(this);
    if (index !== -1) {
      listeners.splice(index, 1);
    }

    if (listeners.length === 0 && typeof window !== 'undefined') {
      off(window, 'resize', finalLazyLoadHandler, passiveEvent);
      off(window, 'scroll', finalLazyLoadHandler, passiveEvent);
    }
  }

  setRef(element) {
    if (element) {
      this.ref = element;
    }
  }

  render() {
    const {
      height,
      children,
      placeholder,
      className,
      style,
    } = this.props;

    return (
      <Root className={className} ref={this.setRef} style={style}>
        {
          isFunction(children)
            ? children(this.visible)
            : (
              this.visible
                ? children
                : (
                  placeholder || (
                    <Placeholder
                      style={{ height }}
                    />
                  )
                )
            )
        }
      </Root>
    );
  }
}

LazyLoad.propTypes = {
  className: PropTypes.string,
  classNamePrefix: PropTypes.string,
  once: PropTypes.bool,
  height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  offset: PropTypes.oneOfType([
    PropTypes.number,
    PropTypes.arrayOf(PropTypes.number),
  ]),
  overflow: PropTypes.bool,
  resize: PropTypes.bool,
  scroll: PropTypes.bool,
  children: [
    PropTypes.node,
    PropTypes.func,
  ],
  throttle: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
  debounce: PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),
  placeholder: PropTypes.node,
  scrollContainer: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
  unmountIfInvisible: PropTypes.bool,
  style: PropTypes.object,
};

LazyLoad.defaultProps = {
  className: '',
  classNamePrefix: 'lazyload',
  once: false,
  offset: 0,
  overflow: false,
  resize: false,
  scroll: true,
  unmountIfInvisible: false,
};

export default LazyLoad;
export { lazyLoadHandler as forceCheck };
export { forceVisible };

위 코드를 리팩토링 하는 작업을 했다

기존 소스는 class형 컴포넌트로 구성되어 있고, 클래스변수를 이용한 visible로 해당 컴포넌트에 접근하는 방식을 취한다.
functional 컴포넌트에선 위 클래스변수를 대체하기 위해 forwardRef hoc를 적용하고 추가로 다른 방법을 적용해야만 하며,
찾아낸 방법들은
1. useImperativeHandle hook
2. visibleRef prop
이 있다

1.을 적용시 컴포넌트 내부로 함수들을 갖고 들어올 수 있으며 prop을 사용하는 함수들이 컴포넌트 내부의 범위에서 prop을 사용하며 리팩토링이 가능하다.
다만 usage에 있어 함수에 접근하는 방법, visible에 접근하는 방법이 다르다

1.을 적용한 코드는 다음과 같다

import { debounce, throttle, isFunction } from 'lodash';
import PropTypes from 'prop-types';
import React, { useState, useRef, useEffect, useCallback, useImperativeHandle } from 'react';
import styled from 'styled-components';

import withForwardedRef from 'hocs/withForwardedRef';

import { on, off } from './utils/event';
import scrollParent from './utils/scrollParent';

const propTypes = {
  className: PropTypes.string,
  classNamePrefix: PropTypes.string,
  forwardedRef: PropTypes.object.isRequired,
  once: PropTypes.bool,
  height: PropTypes.oneOfType([
    PropTypes.number, PropTypes.string
  ]),
  offset: PropTypes.oneOfType([
    PropTypes.number,
    PropTypes.arrayOf(PropTypes.number),
  ]),
  overflow: PropTypes.bool,
  resize: PropTypes.bool,
  scroll: PropTypes.bool,
  children: [
    PropTypes.node,
    PropTypes.func,
  ],
  throttle: PropTypes.oneOfType([
    PropTypes.number,
    PropTypes.bool
  ]),
  debounce: PropTypes.oneOfType([
    PropTypes.number,
    PropTypes.bool
  ]),
  placeholder: PropTypes.node,
  scrollContainer: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.object
  ]),
  unmountIfInvisible: PropTypes.bool,
  style: PropTypes.object,
};

const defaultProps = {
  className: '',
  classNamePrefix: 'lazyload',
  forwardedRef: undefined,
  once: false,
  height: undefined,
  offset: 0,
  overflow: false,
  resize: false,
  scroll: true,
  children: undefined,
  throttle: undefined,
  debounce: undefined,
  placeholder: undefined,
  scrollContainer: undefined,
  unmountIfInvisible: false,
  style: undefined,
};

// ====

const Root = styled.div`
  &:empty {
    display: none;
  }
`;

const Placeholder = styled.div``;

const LISTEN_FLAG = 'data-lazyload-listened';
const listeners = [];
const pending = [];

/**
 * check whether `passive` is supported or not
 */
const checkIsPassive = () => {

  // try to handle passive events
  let passiveEventSupported = false;
  try {
    const opts = Object.defineProperty({}, 'passive', {
      get() {
        passiveEventSupported = true;
      },
    });
    window.addEventListener('test', null, opts);
  } catch (e) {}

  return (passiveEventSupported
    ? { capture: false, passive: true }
    : false);
}

// if they are supported, setup the optional params
// IMPORTANT: FALSE doubles as the default CAPTURE value!
const passiveEvent = checkIsPassive();
const isString = (string) => typeof string === 'string';

const LazyLoad = ({
  forwardedRef,
  ...props
}) => {
  const [, updateState] = useState();
  const forceUpdate = useCallback(() => updateState({}), []);
  const visibleRef = useRef(false);
  const rootRef = useRef(null);
  const delayTypeRef = useRef(null);
  const finalLazyLoadHandlerRef = useRef(null);
  const {
    height,
    children,
    placeholder,
    className,
    style,
  } = props;

  const purgePending = () => {
    pending.forEach((component) => {
      const index = listeners.indexOf(component);
      if (index !== -1) {
        listeners.splice(index, 1);
      }
    });
    pending = [];
  };

  const lazyLoadHandler = () => {
    listeners.forEach((listener) => {
      checkVisible(listener);
    })

    // Remove `once` component in listeners
    purgePending();
  };

  /**
  * Forces the component to display regardless of whether the element is visible in the viewport.
  */
  const forceVisible = () => {
    listeners.forEach((listener) => {
      visibleRef.current = true;
      listener.forceUpdate();
    })

    // Remove `once` component in listeners
    purgePending();
  };

  /**
   * Check if `component` is visible in overflow container `parent`
   * @param  {node} component React component
   * @param  {node} parent    component's scroll parent
   * @return {bool}
   */
  const checkOverflowVisible = (componentRootRef, parent) => {
    const node = componentRootRef.current;
    const {
      top: parentTop = 0,
      left: parentLeft = 0,
      height: parentHeight = 0,
      width: parentWidth = 0,
    } = parent.getBoundingClientRect();
    const windowInnerHeight =
      window.innerHeight || document.documentElement.clientHeight;
    const windowInnerWidth =
      window.innerWidth || document.documentElement.clientWidth;

    // calculate top and height of the intersection of the element's scrollParent and viewport
    const intersectionTop = parentTop > 0 ? parentTop : 0; // intersection's top relative to viewport
    const intersectionLeft = parentLeft > 0 ? parentLeft : 0; // intersection's left relative to viewport
    const intersectionHeight = ((parentTop + parentHeight >= windowInnerHeight)
      ? windowInnerHeight
      : (parentTop + parentHeight))
      - intersectionTop;
    const intersectionWidth = ((parentLeft + parentWidth >= windowInnerWidth)
      ? windowInnerWidth
      : (parentLeft + parentWidth))
      - intersectionLeft;

    // check whether the element is visible in the intersection
    const { top = 0, left = 0, height = 0, width = 0 } = node.getBoundingClientRect();
    const offsetTop = top - intersectionTop; // element's top relative to intersection
    const offsetLeft = left - intersectionLeft; // element's left relative to intersection
    const offsets = Array.isArray(props.offset)
      ? props.offset
      : [props.offset, props.offset]; // Be compatible with previous API

    return (
      offsetTop - offsets[0] <= intersectionHeight &&
      offsetTop + height + offsets[1] >= 0 &&
      offsetLeft - offsets[0] <= intersectionWidth &&
      offsetLeft + width + offsets[1] >= 0
    );
  };

  /**
  * Check if `component` is visible in document
  * @param  {node} componentRootRef React component
  * @return {bool}
  */
  const checkNormalVisible = (componentRootRef) => {
    const node = componentRootRef.current;

    // If this element is hidden by css rules somehow, it's definitely invisible
    if (!(node.offsetWidth || node.offsetHeight || node.getClientRects().length)) return false;

    const { top = 0, height: elementHeight = 0, } = node.getBoundingClientRect();
    const windowInnerHeight =
      window.innerHeight || document.documentElement.clientHeight;
    const offsets = Array.isArray(props.offset)
      ? props.offset
      : [props.offset, props.offset]; // Be compatible with previous API

    // offsets = [100,100] 까지 고려했을 때 해당 뷰의 top이 현재 화면의 하단보다 위에 있고(좌표값이 더 작고) && 
    // 뷰의 bottom부분이 화면의 상단보다 아래에 있으면(이미 화면에서 지나간 경우 처리) => true
    return (
      top - offsets[0] <= windowInnerHeight &&
      top + elementHeight + offsets[1] >= 0
    );
  };

  const checkVisible = (componentRootRef) => {
    const node = componentRootRef.current;
    if (!(node instanceof HTMLElement)) {
      return;
    }

    const parent = scrollParent(node);
    const isOverflow =
      props.overflow &&
      parent !== node.ownerDocument &&
      parent !== document &&
      parent !== document.documentElement;
    const visible = isOverflow
      ? checkOverflowVisible(componentRootRef, parent)
      : checkNormalVisible(componentRootRef);

    if (visible) {

      // Avoid extra render if previously is visible
      if (!visibleRef.current) {
        if (props.once) {
          pending.push(componentRootRef);
        }
        visibleRef.current = true;
        forceUpdate();
      }
    } else if (!(props.once && visibleRef.current)) {
      visibleRef.current = false;
      if (props.unmountIfInvisible) {
        forceUpdate();
      }
    }
  };

  useImperativeHandle(forwardedRef, () => ({
    forceVisible: () => forceVisible,
    lazyLoadHandlerRef: () => lazyLoadHandler,
    visibleRef,
  }));

  useEffect(() => {
    if (visibleRef.current) forceUpdate();
  }, [visibleRef.current]);

  useEffect(() => {
    const { scrollContainer } = props;
    const parent = scrollParent(rootRef.current);
    const scrollport = scrollContainer
      ? isString(scrollContainer)
      ? scrollport.document.querySelector(scrollContainer)
      : window
      : window;

    if ((props.debounce !== undefined && delayTypeRef.current === 'throttle') ||
      (delayTypeRef.current === 'debounce' && props.debounce === undefined)) {
      off(scrollport, 'scroll', finalLazyLoadHandlerRef.current, passiveEvent);
      off(window, 'resize', finalLazyLoadHandlerRef.current, passiveEvent);
      finalLazyLoadHandlerRef.current = null;
    }

    if (!finalLazyLoadHandlerRef.current) {
      delayTypeRef.current = (props.debounce !== undefined)
        ? 'debounce'
        : (props.throttle !== undefined)
          ? 'throttle'
          : undefined;

      if (delayTypeRef.current === 'debounce') {
        finalLazyLoadHandlerRef.current = debounce(
          lazyLoadHandler,
          typeof props.debounce === 'number' ? props.debounce : 300,
        );
      } else if (delayTypeRef.current === 'throttle') {
        finalLazyLoadHandlerRef.current = throttle(
          lazyLoadHandler,
          typeof props.throttle === 'number' ? props.throttle : 300,
        );
      } else {
        finalLazyLoadHandlerRef.current = () => lazyLoadHandler;
      }
    }

    if (props.overflow) {
      if (parent && typeof parent.getAttribute === 'function') {
        const listenerCount = 1 + +parent.getAttribute(LISTEN_FLAG);
        if (listenerCount === 1) {
          parent.addEventListener('scroll', finalLazyLoadHandlerRef.current, passiveEvent);
        }
        parent.setAttribute(LISTEN_FLAG, listenerCount);
      }
    } else if (listeners.length === 0 || needResetFinalLazyLoadHandler) {
      const { scroll, resize } = props;

      if (scroll) {
        on(scrollport, 'scroll', finalLazyLoadHandlerRef.current, passiveEvent);
      }

      if (resize) {
        on(window, 'resize', finalLazyLoadHandlerRef.current, passiveEvent);
      }
    }

    listeners.push(rootRef);
    checkVisible(rootRef);

    return () => {
      if (props.overflow) {
        if (parent && typeof parent.getAttribute === 'function') {
          const listenerCount = +parent.getAttribute(LISTEN_FLAG) - 1;
          if (listenerCount === 0) {
            parent.removeEventListener(
              'scroll',
              finalLazyLoadHandlerRef.current,
              passiveEvent,
            );
            parent.removeAttribute(LISTEN_FLAG);
          } else {
            parent.setAttribute(LISTEN_FLAG, listenerCount);
          }
        }
      }

      const index = listeners.indexOf(rootRef);
      if (index !== -1) {
        listeners.splice(index, 1);
      }
      if (listeners.length === 0 && typeof window !== 'undefined') {
        off(window, 'resize', finalLazyLoadHandlerRef.current, passiveEvent);
        off(window, 'scroll', finalLazyLoadHandlerRef.current, passiveEvent);
      }
    }
  }, [])

  return (
    <Root className={className} ref={rootRef} style={style}>
      {
        isFunction(children)
          ? children(visibleRef.current)
          : (
            visibleRef.current
              ? children
              : (
                placeholder || (
                  <Placeholder
                    style={{ height }}
                  />
                )
              )
          )
      }
    </Root>
  );
}

LazyLoad.propTypes = propTypes;
LazyLoad.defaultProps = defaultProps;

export default withForwardedRef(LazyLoad);
  1. 방법을 적용하게 되면
    기존의 오픈소스와 usage에 있어 다른 점이 크게 없어지며(rootRef.current.visible => visibleRef)
    visibleRef를 child와 parent가 함께 사용하며 좀 더 일관성 있는 코드 적용이 가능하다

*useImperativeHandle을 사용하며 visibleRef 적용이 안되는 이유는, 매개변수로 visible을 넘겨받을 수가 없어지기 때문이다.

  1. 적용 코드
import { debounce, throttle, isFunction } from 'lodash';
import PropTypes from 'prop-types';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import styled from 'styled-components';

import withForwardedRef from 'hocs/withForwardedRef';

import { on, off } from './utils/event';
import scrollParent from './utils/scrollParent';

const propTypes = {
  className: PropTypes.string,
  classNamePrefix: PropTypes.string,
  forwardedRef: PropTypes.object.isRequired,
  once: PropTypes.bool,
  height: PropTypes.oneOfType([
    PropTypes.number, PropTypes.string
  ]),
  offset: PropTypes.oneOfType([
    PropTypes.number,
    PropTypes.arrayOf(PropTypes.number),
  ]),
  overflow: PropTypes.bool,
  resize: PropTypes.bool,
  scroll: PropTypes.bool,
  children: [
    PropTypes.node,
    PropTypes.func,
  ],
  throttle: PropTypes.oneOfType([
    PropTypes.number,
    PropTypes.bool
  ]),
  debounce: PropTypes.oneOfType([
    PropTypes.number,
    PropTypes.bool
  ]),
  placeholder: PropTypes.node,
  scrollContainer: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.object
  ]),
  unmountIfInvisible: PropTypes.bool,
  style: PropTypes.object,
};

const defaultProps = {
  className: '',
  classNamePrefix: 'lazyload',
  forwardedRef: undefined,
  once: false,
  height: undefined,
  offset: 0,
  overflow: false,
  resize: false,
  scroll: true,
  children: undefined,
  throttle: undefined,
  debounce: undefined,
  placeholder: undefined,
  scrollContainer: undefined,
  unmountIfInvisible: false,
  style: undefined,
};

// ====

const Root = styled.div`
  &:empty {
    display: none;
  }
`;

const Placeholder = styled.div``;

const LISTEN_FLAG = 'data-lazyload-listened';
const listeners = [];
let pending = [];

//check whether `passive` is supported or not
const checkIsPassive = () => {

  // try to handle passive events
  let passiveEventSupported = false;
  try {
    const opts = Object.defineProperty({}, 'passive', {
      get() {
        passiveEventSupported = true;
      },
    });
    window.addEventListener('passiveEventTest', null, opts);
  } catch (e) {}

  return (
    passiveEventSupported
    ? { capture: false, passive: true }
    : false
  );
};

/**
 * if they are supported, setup the optional params
 * IMPORTANT: FALSE doubles as the default CAPTURE value!
 */
const passiveEvent = checkIsPassive();
const isString = (string) => typeof string === 'string';

const purgePending = () => {
  pending.forEach((component) => {
    const index = listeners.indexOf(component);
    if (index !== -1) {
      listeners.splice(index, 1);
    }
  });
  pending = [];
};

const lazyLoadHandler = (
  props,
  forceUpdate,
  visibleRef,
) => {
  listeners.forEach((listener) => {
    checkVisible(listener, props, forceUpdate, visibleRef);
  });

  // Remove `once` component in listeners
  purgePending();
};

const checkVisible = (
  componentEle,
  props,
  forceUpdate,
  visibleRef,
) => {
  const node = componentEle.current;
  if (!(node instanceof HTMLElement)) {
    return;
  }

  const parentEle = scrollParent(node);
  const isOverflow = (
    props.overflow
    && (parentEle !== node.ownerDocument)
    && (parentEle !== document)
    && (parentEle !== document.documentElement)
  );
  const visible = isOverflow
    ? checkOverflowVisible(componentEle, parentEle, props)
    : checkNormalVisible(componentEle, props);

  if (visible) {

    // Avoid extra render if previously is visible
    if (!visibleRef.current) {
      if (props.once) {
        pending.push(componentEle);
      }
      visibleRef.current = true;
      forceUpdate();
    }
  } else if (!(props.once && visibleRef.current)) {
    visibleRef.current = false;

    if (props.unmountIfInvisible) {
      forceUpdate();
    }
  }
};

/**
 * Check if `componentEle` is visible in overflow container `parent`
 * @param  {node} componentEle React component
 * @param  {node} parentEle    component's scroll parent
 * @return {bool}
 */
const checkOverflowVisible = (
  componentEle,
  parentEle,
  props,
) => {
  const node = componentEle.current;
  const {
    top: parentTop = 0,
    left: parentLeft = 0,
    height: parentHeight = 0,
    width: parentWidth = 0,
  } = parentEle.getBoundingClientRect();
  const windowInnerHeight = (
    window.innerHeight
    || document.documentElement.clientHeight
  );
  const windowInnerWidth = (
    window.innerWidth
    || document.documentElement.clientWidth
  );

  // calculate top and height of the intersection of the element's scrollParent and viewport

  // intersection's top relative to viewport
  const intersectionTop = parentTop > 0 ? parentTop : 0;

  // intersection's left relative to viewport
  const intersectionLeft = parentLeft > 0 ? parentLeft : 0;
  const intersectionHeight = (
      (parentTop + parentHeight >= windowInnerHeight)
      ? windowInnerHeight
      : (parentTop + parentHeight)
    )
    - intersectionTop;
  const intersectionWidth = (
      (parentLeft + parentWidth >= windowInnerWidth)
      ? windowInnerWidth
      : (parentLeft + parentWidth)
    )
    - intersectionLeft;

  // check whether the element is visible in the intersection
  const {
    top = 0,
    left = 0,
    height = 0,
    width = 0,
  } = node.getBoundingClientRect();

  // element's top relative to intersection
  const offsetTop = top - intersectionTop;

  // element's left relative to intersection
  const offsetLeft = left - intersectionLeft;

  // Be compatible with previous API
  const [leftEdgeOffset, topEdgeOffset] = (
    Array.isArray(props.offset)
    ? props.offset
    : [props.offset, props.offset]
  );

  return (
    offsetTop - leftEdgeOffset <= intersectionHeight &&
    offsetTop + height + topEdgeOffset >= 0 &&
    offsetLeft - leftEdgeOffset <= intersectionWidth &&
    offsetLeft + width + topEdgeOffset >= 0
  );
};

/**
* Check if `component` is visible in document
* @param  {node} componentEle React component
* @return {bool}
*/
const checkNormalVisible = (
  componentEle,
  props
) => {
  const node = componentEle.current;

  // If this element is hidden by css rules somehow, it's definitely invisible
  if (
    !(node.offsetWidth
    || node.offsetHeight
    || node.getClientRects().length)
  ) return false;

  const { top = 0, height: elementHeight = 0, } = node.getBoundingClientRect();
  const windowInnerHeight = (
    window.innerHeight
    || document.documentElement.clientHeight
  );

  // Be compatible with previous API
  const [leftEdgeOffset, topEdgeOffset] = (
    Array.isArray(props.offset)
    ? props.offset
    : [props.offset, props.offset]
  );

  /**
   * offsets = [100,100] as a default value, 고려했을 때 해당 뷰의 top이 현재 화면의 하단보다 위에 있고(좌표값이 더 작고) &&
   * 뷰의 bottom부분이 화면의 상단보다 아래에 있으면(이미 화면에서 지나간 경우 처리) => true
   */
  return (
    top - leftEdgeOffset <= windowInnerHeight &&
    top + elementHeight + topEdgeOffset >= 0
  );
};


// Forces the component to display regardless of whether the element is visible in the viewport.
const forceVisible = (visibleRef) => {
  listeners.forEach((listener) => {
    visibleRef.current = true;
    listener.forceUpdate();
  });

  // Remove `once` component in listeners
  purgePending();
};

const BaseLazyLoad = ({
  visibleRef,
  forwardedRef: rootRef,
  ...props
}) => {
  const [, updateState] = useState();
  const forceUpdate = useCallback(() => updateState({}), []);
  const delayTypeRef = useRef(null);
  const finalLazyLoadHandlerRef = useRef(null);
  const {
    height,
    children,
    placeholder,
    className,
    style,
  } = props;

  useEffect(() => {
    const { scrollContainer } = props;
    const parentEle = scrollParent(rootRef.current);
    const scrollport = (
      scrollContainer
      ? isString(scrollContainer)
      ? scrollport.document.querySelector(scrollContainer)
      : window
      : window
    );
    const needResetFinalLazyLoadHandler =(
      (props.debounce !== undefined && delayTypeRef.current === 'throttle')
      || (delayTypeRef.current === 'debounce' && props.debounce === undefined)
    );

    if (needResetFinalLazyLoadHandler) {
      off(scrollport, 'scroll', finalLazyLoadHandlerRef.current, passiveEvent);
      off(window, 'resize', finalLazyLoadHandlerRef.current, passiveEvent);
      finalLazyLoadHandlerRef.current = null;
    }
    if (!finalLazyLoadHandlerRef.current) {
      delayTypeRef.current = (
        (props.debounce !== undefined)
        ? 'debounce'
        : (props.throttle !== undefined)
        ? 'throttle'
        : undefined
      );

      if (delayTypeRef.current === 'debounce') {
        finalLazyLoadHandlerRef.current = debounce(
          () => lazyLoadHandler(rootRef, props, forceUpdate, visibleRef),
          typeof props.debounce === 'number' ? props.debounce : 300,
        );
      } else if (delayTypeRef.current === 'throttle') {
        finalLazyLoadHandlerRef.current = throttle(
          () => lazyLoadHandler(rootRef, props, forceUpdate, visibleRef),
          typeof props.throttle === 'number' ? props.throttle : 300,
        );
      } else {
        finalLazyLoadHandlerRef.current = () => (
          lazyLoadHandler(
            rootRef,
            props,
            forceUpdate,
            visibleRef,
          )
        );
      }
    }
    if (props.overflow) {
      if (parentEle && typeof parentEle.getAttribute === 'function') {
        const listenerCount = 1 + +parentEle.getAttribute(LISTEN_FLAG);

        if (listenerCount === 1) {
          parentEle.addEventListener(
            'scroll',
            finalLazyLoadHandlerRef.current,
            passiveEvent,
          );
        }
        parentEle.setAttribute(LISTEN_FLAG, listenerCount);
      }
    } else if (listeners.length === 0 || needResetFinalLazyLoadHandler) {
      const { scroll, resize } = props;

      if (scroll) {
        on(scrollport, 'scroll', finalLazyLoadHandlerRef.current, passiveEvent);
      }
      if (resize) {
        on(window, 'resize', finalLazyLoadHandlerRef.current, passiveEvent);
      }
    }

    listeners.push(rootRef);
    checkVisible(rootRef, props, forceUpdate, visibleRef);

    return () => {
      if (props.overflow) {
        if (parentEle && typeof parentEle.getAttribute === 'function') {
          const listenerCount = +parentEle.getAttribute(LISTEN_FLAG) - 1;

          if (listenerCount === 0) {
            parentEle.removeEventListener(
              'scroll',
              finalLazyLoadHandlerRef.current,
              passiveEvent,
            );
            parentEle.removeAttribute(LISTEN_FLAG);
          } else {
            parentEle.setAttribute(LISTEN_FLAG, listenerCount);
          }
        }
      }

      const index = listeners.indexOf(rootRef);

      if (index !== -1) {
        listeners.splice(index, 1);
      }
      if (listeners.length === 0 && typeof window !== 'undefined') {
        off(window, 'resize', finalLazyLoadHandlerRef.current, passiveEvent);
        off(window, 'scroll', finalLazyLoadHandlerRef.current, passiveEvent);
      }
    }
  }, [])

  return (
    <Root className={className} ref={rootRef} style={style}>
      {
        isFunction(children)
          ? children(visibleRef.current)
          : (
            visibleRef.current
              ? children
              : (
                placeholder || (
                  <Placeholder
                    style={{ height }}
                  />
                )
              )
          )
      }
    </Root>
  );
};

BaseLazyLoad.propTypes = propTypes;
BaseLazyLoad.defaultProps = defaultProps;

export default withForwardedRef(BaseLazyLoad);
export { lazyLoadHandler as forceCheck };
export { forceVisible };
profile
https://github.com/jsw4215

0개의 댓글