🐯

Next.js14 + ReactでSSR/SSG対応のメイソンリーレイアウトを実装する

2024/08/13に公開

こんにちは。音楽ディレクターの村上といいます。

なつかしの「メイソンリーレイアウト」

一時期(たぶんjQuery時代)、「メイソンリーレイアウト」って流行りましたよね。
Pintarestのように複数カラムを隙間なく上から埋めていくレイアウトのことです。
https://www.pinterest.jp/

利点はスペースの節約になることで、画像ギャラリーなどでよく使われると思います。
ただしこのレイアウトはCSSGridだけでは不可能で、クライアントサイドで一度高さを算出しないといけないので、SSRやSSGと相性が悪いです。また、モバイルファーストだと関係ないのと、
パフォーマンスの問題もあり、今は割と敬遠されている気がします。

(ただし、CSSの機能にMasonry Layoutを実装しようという動きもあるようです。これが取り込まれれば、Javascript不要になります)
https://developer.mozilla.org/ja/docs/Web/CSS/CSS_grid_layout/Masonry_layout

令和でも使いたい!

今回、佐々木恵梨という担当アーティストの公式サイトをリニューアルしたのですが、
これはTumblrというAPIが叩けるブログシステムで10年近く前に作ったもので、
当時の流行りもあって各記事をメイソンリーでレイアウトしていました。
リニューアルにあたり辞めてもよかったんですが、「大きな見た目の変更なしにパフォーマンス、UXを極限までチューニングする」がテーマだったので、意地で実装しました。(これはまた別記事する予定です)
こちらが完成品です。
https://erisasaki.net/

ライブラリを探してみたが・・・

一応、僕が普段使っているUIライブラリのMUI(Material-UI)では、Lab(実験的コンポーネント)の中に「Masonry」コンポーネントがあるのですが、これは現状ちょっと不安定です。
右に不要なギャップが出来たり、ロード時に一度1カラムで表示されてしまい画面がちらつくなど、イマイチな状況です。(放置気味なので、おそらくそれほどニーズが無いのでしょう)

色々と調べていく中で、MUIのissueで独自のstyled-component実装例を紹介している人をみつけました。
https://github.com/mui/material-ui/issues/36673#issuecomment-1692785487
試してみたら、MUIのよりずっとスムーズです。

ただ、現在のMUIのデフォルトはemotionなので、そこだけ自分でemotion/styledに改変したのが下記のコードになります。

実装コード

/** @jsxImportSource @emotion/react */
'use client';
import { useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef, useLayoutEffect } from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled';

/**
 * This code is based on the implementation by tangye1234 shared in the MUI GitHub Issues:
 * https://github.com/mui/material-ui/issues/36673#issuecomment-1692785487
 *
 * Original implementation used styled-components.
 * Adapted to use Emotion by Jun Murakami.
 */

export type Breakpoint<T> = {
  [key: number]: T;
  default?: T;
};

interface MasonryBaseProps {
  columns?: number | Breakpoint<number>;
  spacing?: number | Breakpoint<number>;
  defaultHeight?: number;
  disableSSR?: boolean;
}

type MasonryInnerProps = MasonryBaseProps & React.ComponentProps<'div'> & { as: any };
type MasonryRootType = ReturnType<typeof styled.div<MasonryBaseProps>>;

type MasonryRootState = {
  spacing: (readonly [number, number])[];
  columns: (readonly [number, number])[];
  ssr: boolean;
};

const LineBreaks = styled.span<{ $order: number }>`
  flex-basis: 100%;
  width: 0;
  margin: 0;
  padding: 0;
  order: ${(props) => props.$order || 'unset'};
`;

const masonryRootStyles = (state: MasonryRootState) => css`
  display: flex;
  flex-flow: column wrap;
  align-content: flex-start;
  contain: ${state.ssr ? 'none' : 'strict'};
  height: var(--masonry-height, 'auto');

  ${state.spacing.map(([breakpoint, spacing]) =>
    breakpoint === -1
      ? css`
          --masonry-spacing: ${spacing}px;
        `
      : css`
          @media screen and (max-width: ${breakpoint}px) {
            --masonry-spacing: ${spacing}px;
          }
        `
  )}

  ${state.columns.map(([breakpoint, column]) =>
    breakpoint === -1
      ? css`
          --masonry-column: ${column};
        `
      : css`
          @media screen and (max-width: ${breakpoint}px) {
            --masonry-column: ${column};
          }
        `
  )}

  margin: calc(var(--masonry-spacing, 0px) / -2);

  & > :not(template, ${LineBreaks}, [hidden]) {
    margin: calc(var(--masonry-spacing, 0px) / 2);
    width: calc((1 / var(--masonry-column, 1)) * 100% - var(--masonry-spacing, 0px));
    order: calc(1 + var(--masonry-column, 1));
    contain: layout style paint;
  }

  ${state.ssr &&
  css`
    & > :not(template, ${LineBreaks}, [hidden]) {
      display: none;
    }
  `}
`;

function ptn(val: string) {
  return Number(val.replace('px', ''));
}

export const Masonry = forwardRef<HTMLElement, MasonryInnerProps>(function Masonry(
  { children, className, as = 'div', columns = 1, spacing = 0, defaultHeight = 0, disableSSR = false, ...rest },
  ref
) {
  const masonryRef = useRef<HTMLElement>(null!);
  useImperativeHandle(ref, () => masonryRef.current);

  const [isSSR, setSSR] = useState(!disableSSR);
  useEffect(() => () => setSSR(false), []);

  const maxColumnHeightRef = useRef(defaultHeight);
  const maxColumnHeight = maxColumnHeightRef.current;

  const maxColumnCount = typeof columns === 'number' ? columns : Math.max(...Object.values(columns));

  const state = useMemo<MasonryRootState>(
    () => ({
      ssr: isSSR,
      columns: _entries(columns),
      spacing: _entries(spacing),
    }),
    [isSSR, columns, spacing]
  );

  useLayoutEffect(() => {
    if (typeof ResizeObserver === 'undefined') {
      return;
    }

    if (typeof MutationObserver === 'undefined') {
      return;
    }

    /**
     * FIXME safari will trigger `ResizeObserver loop completed
     * with undelivered notifications` error in console
     **/
    const resizeObserver = new ResizeObserver(() => {
      resizeObserver.unobserve(masonryRef.current);
      const result = handleResize(masonryRef.current, true);
      const { height = 0 } = result || {};
      maxColumnHeightRef.current = height;
      masonryRef.current.style.setProperty('--masonry-height', height ? `${height}px` : 'auto');
    });

    if (masonryRef.current) {
      masonryRef.current.childNodes.forEach((child) => {
        if (child instanceof HTMLElement && child.dataset.class !== 'line-break') {
          resizeObserver.observe(child);
        }
      });
    }

    const mutationObserver = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type !== 'childList') {
          return;
        }
        mutation.addedNodes.forEach((node) => {
          if (node instanceof HTMLElement && node.dataset.class !== 'line-break') {
            resizeObserver.observe(node);
          }
        });
        mutation.removedNodes.forEach((node) => {
          if (node instanceof HTMLElement && node.dataset.class !== 'line-break') {
            resizeObserver.unobserve(node);
          }
        });
        if (mutation.addedNodes.length === 0 && mutation.removedNodes.length > 0) {
          // this situation won't trigger resizeObserver callback, so
          // manually trigger it here
          resizeObserver.observe(masonryRef.current);
        }
      });
    });

    mutationObserver.observe(masonryRef.current, {
      childList: true,
      subtree: false,
      attributes: false,
      characterData: false,
    });

    return () => {
      resizeObserver.disconnect();
      mutationObserver.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div
      {...rest}
      ref={masonryRef as React.Ref<HTMLDivElement>}
      className={className}
      css={masonryRootStyles(state)}
      style={
        {
          ...rest.style,
          '--masonry-height': maxColumnHeight ? `${maxColumnHeight}px` : 'auto',
        } as React.CSSProperties
      }
    >
      {children}
      {new Array(maxColumnCount - 1).fill('').map((_, index) => (
        <LineBreaks key={index} data-class='line-break' $order={index + 1} />
      ))}
    </div>
  );
}) as unknown as MasonryRootType;

function handleResize(masonry: HTMLElement | undefined, isResize = false) {
  if (!masonry || masonry.childElementCount === 0) {
    return;
  }

  const masonryFirstChild = masonry.firstElementChild;
  const parentWidth = masonry.clientWidth;
  const firstChildWidth = masonryFirstChild?.clientWidth || 0;

  if (parentWidth === 0 || firstChildWidth === 0 || !masonryFirstChild) {
    return;
  }

  const firstChildComputedStyle = getComputedStyle(masonryFirstChild);
  const firstChildMarginLeft = ptn(firstChildComputedStyle.marginLeft);
  const firstChildMarginRight = ptn(firstChildComputedStyle.marginRight);

  const currentNumberOfColumns = Math.round(parentWidth / (firstChildWidth + firstChildMarginLeft + firstChildMarginRight));

  const columnHeights = new Array(currentNumberOfColumns).fill(0) as number[];
  let skip = false;

  masonry.childNodes;

  masonry.childNodes.forEach((child) => {
    if (!(child instanceof HTMLElement) || child.dataset.class === 'line-break' || skip) {
      return;
    }

    const childComputedStyle = getComputedStyle(child);
    if (childComputedStyle.display === 'none') {
      return; // display: noneのアイテムをスキップ
    }

    const childMarginTop = ptn(childComputedStyle.marginTop);
    const childMarginBottom = ptn(childComputedStyle.marginBottom);
    const parsedChildHeight = ptn(childComputedStyle.height);
    const childHeight = parsedChildHeight ? Math.ceil(parsedChildHeight) + childMarginTop + childMarginBottom : 0;

    if (childHeight === 0) {
      // if any one of children isn't rendered yet, masonry's height shouldn't be computed yet
      skip = true;
      return;
    }

    // if there is a nested image that isn't rendered yet, masonry's height shouldn't be computed yet
    for (let i = 0; i < child.childNodes.length; i += 1) {
      const nestedChild = child.childNodes[i] as Element;
      if (nestedChild.tagName === 'IMG' && nestedChild.clientHeight === 0) {
        skip = true;
        break;
      }
    }

    if (!skip) {
      // find the current shortest column (where the current item will be placed)
      const currentMinColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));

      if (isResize) {
        const oldOrder = Number(child.style.order);
        const newOrder = currentMinColumnIndex + 1;
        if (isFinite(oldOrder) && oldOrder !== newOrder) {
          /** debounce order change for 5px difference */
          if (Math.abs(columnHeights[oldOrder - 1] - columnHeights[newOrder - 1]) < 5) {
            columnHeights[oldOrder - 1] += childHeight;
            return;
          }
        }
      }

      columnHeights[currentMinColumnIndex] += childHeight;
      const order = currentMinColumnIndex + 1;
      child.style.order = String(order);
    }
  });

  if (!skip) {
    const numOfLineBreaks = currentNumberOfColumns > 0 ? currentNumberOfColumns - 1 : 0;
    return {
      height: Math.max(...columnHeights),
      numOfLineBreaks,
    };
  }
}

export default Masonry;

function _entries(values: Breakpoint<number> | number) {
  return Object.entries(typeof values === 'number' ? { default: values } : values)
    .reverse()
    .map(([breakpoint, column]) => [breakpoint === 'default' ? -1 : parseInt(breakpoint) - 1, column] as const);
}

使用例

下記のように、カラム数(動的に複数指定可能)、各要素間のスペース、デフォルト高さ(SSRで必要)を指定して、並べたいコンポーネントをラップするだけです。
複数の種類のコンポーネントをごちゃごちゃに配置しても大丈夫です。

import Masonry from '@/components/common/MasonryLayout';

  <Masonry columns={{ 1200: 1, default: 2 }} spacing={16} defaultHeight={1920} disableSSR>
    <RecentPosts recentPosts={recentPosts} />
    {posts10.map((post) => (
      <PostArticle post={post} key={post.id} isHome={true} />
    ))}
    <LoadMoreArticle isHome={true} />
  </Masonry>

一応、Next.js14環境で、中にSSGコンテンツやサーバコンポーネントを入れても問題ありません。
もし、 令和のメイソンリーをReactで実装してみたい! という方がいたら試してみてね。

おしまい。

Discussion