かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React window リサイズ時にリスト要素の高さを揃えたい

ルーセルとか横並びの要素の高さをReactで揃えたいような場合 (display: flex が使えないようなケース)
要素のそれぞれのDOMにアクセスして高さを取得して最も高いものを取得しなければならないので、リストのような Ref が必要になります。
また、ウィンドウサイズが変わった時に再計算する必要があるので、 resize イベントで最大値の再計算をする必要もあります。

useRef で複数の要素に ref を渡す

DOMへのアクセスは RefObject を通じてアクセスできますが、リスト要素それぞれのDOMにアクセスしたいとなると、それぞれに ref を渡す必要があります。

import React, { useState, useRef, createRef } from 'react';

function Carousel({ initItems }) {
  const [items, setItems] = useState(initItems);
  // current に refObject の入った配列を作成する
  const itemsRef = useRef(
    [...Array(items.length)].map(() => createRef)
  );

  const carouselItems = items.map(({id, ...props}, i) => {
    return (
      <CarouselItem
        key={id}
        ref={itemsRef.current[i]}
        {...props}
      />
    );
  });
  
  return (
    <div className="carousel">
      {carouselItems}
    </div>
  );
}

ref で DOMにアクセスして最大値の高さを設定する

itemsRef.currentreduce で回して一番大きな値を取得してそれぞれのDOMにそれを設定すればOK

import React, { useState, useEffect, useCallback, useRef, createRef } from 'react';

function Carousel({ initItems }) {
  const [items, setItems] = useState(initItems);
  const itemsRef = useRef(
    [...Array(items.length)].map(() => createRef)
  );

  const getMaxHeight = useCallback((refList) => {
    return refList.reduce((maxHeight, ref) => {
      if ( !ref.current ) { return maxHeight; }
      const itemHeight = ref.current.offsetHeight;
      return Math.max(itemHeight, maxHeight);
    }, 0);
  }, []);
  
  const setMaxHeight = useCallback(() => {
    itemRefs.current.forEach((ref) => {
      if (ref.current) {
        ref.current.removeAttribute('style');
      }
    });
    
    const max = getMaxHeight(itemRefs.current);
    
    if ( max ) {
      itemRefs.current.forEach((ref) => {
        ref.current.style.height = `${max}px`;
      });
    }
  }, [itemRefs]);
  
  // 初回レンダー時に高さを揃える処理を実行する
  useEffect(() => {
    setMaxHeight();
  }, []);

  //...
  return (...);
}

window resize 時にも高さを揃える処理を実行する

resize イベントが起こる度に処理をしていると処理が重くなるので debounce で実行する
cf.

今回は関数を実行するだけで、値を返す必要がないので、useDebounceFn という custom hook を作成しました

// useDebounceFn.js
import { useRef, useCallback } from 'react';

const useDebounceFn = (fn, delay = 100) => {
  const timer = useRef(null);
  
  const dispatch = useCallback((_val) => {
    timer.current && clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      fn(_val);
    }, delay);
  }, [fn, delay, timer]);
  
  return [dispatch];
};

export useDebounceFn;

window resize イベントは useEffect 内で設定して、コンポーネントが unmount された時にイベントが外れるように clean up の処理を return するようにします。

import React, { useState, useEffect, useCallback, useRef, createRef } from 'react';
import { useDebounceFn } from './useDebounceFn';

function Carousel({ initItems }) {
  const [items, setItems] = useState(initItems);
  const itemsRef = useRef(
    [...Array(items.length)].map(() => createRef)
  );

  const getMaxHeight = useCallback((refList) => {
    return refList.reduce((maxHeight, ref) => {
      if ( !ref.current ) { return maxHeight; }
      const itemHeight = ref.current.offsetHeight;
      return Math.max(itemHeight, maxHeight);
    }, 0);
  }, []);
  
  const setMaxHeight = useCallback(() => {
    itemRefs.current.forEach((ref) => {
      if (ref.current) {
        ref.current.removeAttribute('style');
      }
    });
    
    const max = getMaxHeight(itemRefs.current);
    
    if ( max ) {
      itemRefs.current.forEach((ref) => {
        ref.current.style.height = `${max}px`;
      });
    }
  }, [itemRefs]);

  // resize 時に debounce で実行するイベント
  const [onResizeHandler] = useDebounceFn(setMaxHeight);
  
  useEffect(() => {
    window.addEventListener('resize', onResizeHandler);
    setMaxHeight();
    // unmount 時に実行する処理
    return () => window.removeEventListener('resize', onResizeHandler);
  }, []);

  //...
  return (...);
}

sample

https://codepen.io/kikiki_kiki/pen/povxxeN

 
useRef は色んなものを入れれる箱だと認識してたけど、その中に配列で ref を入れればループで出力するようなコンポーネントにも ref を渡せるってのに気づくのが難しかった


[参考]

アイカツ!シリーズ 5thフェスティバル!! Day1 Blu-ray

アイカツ!シリーズ 5thフェスティバル!! Day1 Blu-ray

START DASH SENSATION マジ神曲でモチベーション上がる曲。聴いて!そしてライブも観て!!

React イベントを間引いて処理を実行できる Debounce な Hook 作ってみた。

input タグの onChange イベントとか、入力の度に state 変更して再レンダリングするのなんだかなーと思ってたので、イベントの処理を間引いて実行したいと思ったので色々調べながら作ってみました。

要件としては、イベントが発生した後に同じイベントが発生しなかったらイベントで渡された値を使った処理を実行したい。という感じ

できたもの

See the Pen Debounce Hook by KIKIKI (@kikiki_kiki) on CodePen.

debounce と throttle

処理を間引く方法は debouncethrottle という方法があるっぽい。
違いがこんな感じ

debounce

指定時間内に何度イベントが発生しても、最後の1回だけが実行される。
指定時間内にイベントが発生すると、先のイベントをキャンセルして最後のイベントからまた指定時間待って、その間にイベントが発生しなければ処理を実行する感じ。

throttle

一定時間内に1回だけイベントを実行させる。
時間を区切って、指定時間内に何度イベントが発生しても最後の1回だけを実行する。インターバルのようなイメージ。

 
今回作成した Hook は input タグの入力を間引きたいようなイメージだったので、入力が終わったら処理を実行したい = 入力中に発生する onChange を間引いて最後の onChange イベントだけ拾いたい。という方向だったので debounce を採用しました。
コード👇

useDebounceCallback Hook

import { useState, useCallback, useRef } from 'react';

const useDebounceCallback = (callback, delay = 300) => {
  const [val, setVal] = useState();
  const decounceTimer = useRef(null);

  const dispatch = useCallback((_val) => {
    clearTimeout(decounceTimer.current);
    decounceTimer.current = setTimeout(() => {
      setVal( callback(_val) );
    }, delay);
  }, [callback, delay, decounceTimer]);

  return [val, dispatch];
};

export default useDebounceCallback;

使い方

import React, { useCallback } from 'react';
import useDebounceCallback from './useDebounceCallback.js';

function MyComponent() {
  const double = useCallback((val) => {
    return val * 2;
  }, []);
  const [doubleValue, setDoubleValue] = useDebounceCallback(double, 1000);
  const onChangeHandler = (e) => { setDoubleValue(e.target.value - 0) };
  
  return (
    <input type="number" onChange={onChangeHandler} />
    <span>Double: {doubleValue}</span>
  );
}

通常の実行したい関数と、間引きたい時間を渡すと、state と 値をセットする関数を呼び出す despatch が返ってくるので、後はいつもの useState のような感じで state の更新をすればOKという感じ。

useRef こんな使い方も出来るんだ〜と知れたので楽しかった。
雑に作ってあるので callback が関数でない時とか、useCallback の中で setTimeout するの実際どうなん?とか抜けや理解しきれてない部分があるけど、なんとなくいい感じに動作してる気がしてるので、今はこれが精一杯… という感じでよしとしています。
もっとこうした方が良いとかあれば教えて下さい!


[参考]

続・オブジェクトの入った配列からランキングをつくりたい

前回までのあらすじ、オブジェクト内の特定のキーから一意なランキングを作成してみました。

前回のコードでは、同じ得点の場合、配列の先にある方がランキングが上になるようにしていましたが、同位なランキングにしたい場合もあるな〜と思ったのでやってみました。

同位を許容する場合2通りの方法が考えられます

  1. 同位を許容して連続した順位付けをする
    1位 A, 2位 B, C, 3位 D … の様なランキング
  2. 同位があると同位の数分、次の順位をスキップする
    1位 A, 2位 B, C, 4位 D … の様なランキング

data
前回と同じ様なデータを使って scoreアイカツ!ランキングを作成します。

const idols = [
  {id: 1,  name: '星宮いちご',  score: 90},
  {id: 2,  name: '霧矢あおい',  score: 85},
  {id: 3,  name: '紫吹蘭',     score: 80},
  {id: 4,  name: '有栖川おとめ', score: 85},
  {id: 5,  name: '藤堂ユリカ',  score: 87},
  {id: 6,  name: '神崎美月',    score: 92},
  {id: 7,  name: '夏樹みくる',  score: 80},
  {id: 8,  name: '大空あかり',  score: 92},
  {id: 9,  name: '氷上すみれ',  score: 83},
  {id: 10, name: '新条ひなき',  score: 80},
];

共通の処理

データを特定のキーの順番に並べた配列を返したりする部分は前回と同様の関数を共通して使い回すことができます。(この処理のために最適化させることもできますが)

// `score` 順にソートした配列を返す関数
const sortByScoreAndID = (arr) => (orderby = 'DESC') => {
  return [...arr].sort((a, b) => {
    if ( orderby === 'ASC' ) {
      [a, b] = [b, a];
    }

    return (a.score > b.score || a.score === b.score && a.id < b.id) ? -1 : 1;
  });
};

1. 同位を許可して連続した順位になるランキング

ランキングをマッピングするために次のようなデータ形式の配列を作成して、配列のインデックスから順位を作成すれば良さそうです

[
  [A],
  [B, C],
  [D],
  // …
];

1-1. ランキングをマッピング用の配列を返す関数

const mapDataToRankingArray = (arr, id = 'id') => (key = 'score') => {
  return arr.reduce((accumulator, item, index, _list) => {
    if ( index > 0 ) {
      const prevValue = _list[index - 1][key];
      // 1つ前の要素の `key` の値が同じなら同じ配列に入れる
      if ( prevValue === item[key] ) {
        accumulator[accumulator.length - 1].push(item[id]);
        return accumulator;
      }
    }
    accumulator[accumulator.length] = [item[id]];
    return accumulator;
  }, []);
};

const sorted = sortByScoreAndID(idols)('DESC');
const mapedRanking = mapDataToRankingArray(idols, 'name')();
/* => 
[ [ '神崎美月', '大空あかり' ],
  [ '星宮いちご' ],
  [ '藤堂ユリカ' ],
  [ '霧矢あおい', '有栖川おとめ' ],
  [ '氷上すみれ' ],
  [ '紫吹蘭', '夏樹みくる', '新条ひなき' ] ]
*/

1-2. マッピングされたデータからインデックスを取得する関数

Array.finfIndex を使って該当するデータがあれば、順位に使う配列のインデックスを返す関数を作成します

const getIndexFromRankArray = (arr) => (id) => {
  return arr.findIndex((keys) => {
    return keys.some((_id) => {
      return _id === id;
    });
  });
};

// mapedRanking 配列から '藤堂ユリカ' のあるインデックスを探して返す
console.log( getIndexFromRankArray(mapedRanking)('藤堂ユリカ') );
// => 2

// データに存在しない値を探そうとした場合 -1 が返る (ランキングなし)
console.log( getIndexFromRankArray(mapedRanking)('ジョニー別府') );
// => -1 

1-3. 元の配列をループしてランキングをマッピングする

後は作成した関数を利用して元のデータにランキングをマッピングするだけです

const addRankingAllowSameRank = (list, id = 'id') => (sorted, key = 'score') => {
  sorted = sorted || list;
  // ランキング用に変換した配列
  const rankingList = mapDataToRankingArray(sorted, id)(key);
  return list.map((item) => {
    // ランキング要配列から値のあるインデックスを取得、ランキング用に +1 する
    const ranking = getIndexFromRankArray(rankingList)(item[id]) + 1;
    // ranking が 0 はランキングの配列にない場合 (-1) なのでランキングを付けない
    return {...item, ranking: ranking || null};
  });
};

const sorted = sortByScoreAndID(idols)('DESC');
addRankingAllowSameRank(idols)(sorted);
/* => 
[ { id: 1, name: '星宮いちご', score: 90, ranking: 2 },
  { id: 2, name: '霧矢あおい', score: 85, ranking: 4 },
  { id: 3, name: '紫吹蘭', score: 80, ranking: 6 },
  { id: 4, name: '有栖川おとめ', score: 85, ranking: 4 },
  { id: 5, name: '藤堂ユリカ', score: 87, ranking: 3 },
  { id: 6, name: '神崎美月', score: 92, ranking: 1 },
  { id: 7, name: '夏樹みくる', score: 80, ranking: 6 },
  { id: 8, name: '大空あかり', score: 92, ranking: 1 },
  { id: 9, name: '氷上すみれ', score: 83, ranking: 5 },
  { id: 10, name: '新条ひなき', score: 80, ranking: 6 } ]
*/

addRankingAllowSameRank(sorted)();
/* =>
[ { id: 6, name: '神崎美月', score: 92, ranking: 1 },
  { id: 8, name: '大空あかり', score: 92, ranking: 1 },
  { id: 1, name: '星宮いちご', score: 90, ranking: 2 },
  { id: 5, name: '藤堂ユリカ', score: 87, ranking: 3 },
  { id: 2, name: '霧矢あおい', score: 85, ranking: 4 },
  { id: 4, name: '有栖川おとめ', score: 85, ranking: 4 },
  { id: 9, name: '氷上すみれ', score: 83, ranking: 5 },
  { id: 3, name: '紫吹蘭', score: 80, ranking: 6 },
  { id: 7, name: '夏樹みくる', score: 80, ranking: 6 },
  { id: 10, name: '新条ひなき', score: 80, ranking: 6 } ]
*/

👍

2. 同位があると同位の数分、次の順位をスキップする

次のようなスキップする空配列が入った配列を作成すれば良さそう

[
  [A],
  [B, C, D],
  [],
  [],
  [E],
  // …
];

2-1. ランキングをマッピング用の配列を返す関数

同位の場合は、同じ値のある配列にキーを入れて空配列を末尾に追加すれば良さそうです。

// マッピングされたデータからインデックスを取得する関数
const getIndexFromRankArray = (arr) => (id) => {
  return arr.findIndex((keys) => {
    return keys.some((_id) => {
      return _id === id;
    });
  });
};

const mapDataToRankingArrayWithSkip = (arr, id = 'id') => (key = 'score') => {
  return arr.reduce((accumulator, item, index, _list) => {
    if ( index > 0 ) {
      const prevValue = _list[index - 1][key];
      // 1つ前の要素の `key` の値が同じなら同じ配列に入れる
      if ( prevValue === item[key] ) {
        const prevID = _list[index - 1][id];
        // 同じ値の要素が入っているインデックスを取得する
        const sameRankIndex = getIndexFromRankArray(accumulator)(prevID);
        accumulator[sameRankIndex].push(item[id]);
        // 末尾に空配列を追加する
        accumulator.push([]);
        return accumulator;
      }
    }
    accumulator[accumulator.length] = [item[id]];
    return accumulator;
  }, []);
};

const sorted = sortByScoreAndID(idols)('DESC');
const mapedRanking = mapDataToRankingArrayWithSkip(idols, 'name')();
/* =>
[ [ '神崎美月', '大空あかり' ],
  [],
  [ '星宮いちご' ],
  [ '藤堂ユリカ' ],
  [ '霧矢あおい', '有栖川おとめ' ],
  [],
  [ '氷上すみれ' ],
  [ '紫吹蘭', '夏樹みくる', '新条ひなき' ],
  [],
  [] ]
*/

同値の場合、空配列が追加されるので、必ずしも1つ前のインデックスにある配列に値を追加すれば良い訳ではないので、1-2. で作成した「マッピングされたデータからインデックスを取得する関数」を利用して同じ値の入っている配列があるインデックスを取得してキーを追加するようにしています。

2-2. マッピングされたデータからインデックスを取得する関数を改修する

この場合もランキングの番号を生成するためにランキング用にフォーマットしたデータからインデックスを取得する関数を使います。
関数自体は先に定義したものを使い回せますが、空配列の場合も Array.some で回すのは少しもったいないので、空配列の場合即時 false を返すように関数を改修します。

const getIndexFromRankArray = (arr) => (id) => {
  return arr.findIndex((keys) => {
    // 配列でなかったり、から配列の場合は即時 false を返す
    if ( !Array.isArray(keys) || !keys.length ) {
      return false;
    }

    return keys.some((_id) => {
      return _id === id;
    });
  });
};

// mapedRanking 配列から '星宮いちご' のあるインデックスを探して返す
console.log( getIndexFromRankArray(mapedRanking)('星宮いちご') );
// => 2 ※第2位(index: 1)がスキップされている

// データに存在しない値を探そうとした場合 -1 が返る (ランキングなし)
console.log( getIndexFromRankArray(mapedRanking)('星宮らいち') );
// => -1 

2-3. 元の配列をループしてランキングをマッピングする

1-3. との違いは ランキング用の配列を作る関数が異なるだけで他は同じです。

const addRankingAllowSameRank = (list, id = 'id') => (sorted, key = 'score') => {
  sorted = sorted || list;
  // ランキング用に変換した配列 (同値がある分次の順位をスキップ)
  const rankingList = mapDataToRankingArrayWithSkip(sorted, id)(key);
  return list.map((item) => {
    const ranking = getIndexFromRankArray(rankingList)(item[id]) + 1;
    return {...item, ranking: ranking || null};
  });
};

const sorted = sortByScoreAndID(idols)('DESC');
addRankingAllowSameRank(idols)(sorted);
/* => 
[ { id: 1, name: '星宮いちご', score: 90, ranking: 3 },
  { id: 2, name: '霧矢あおい', score: 85, ranking: 5 },
  { id: 3, name: '紫吹蘭', score: 80, ranking: 8 },
  { id: 4, name: '有栖川おとめ', score: 85, ranking: 5 },
  { id: 5, name: '藤堂ユリカ', score: 87, ranking: 4 },
  { id: 6, name: '神崎美月', score: 92, ranking: 1 },
  { id: 7, name: '夏樹みくる', score: 80, ranking: 8 },
  { id: 8, name: '大空あかり', score: 92, ranking: 1 },
  { id: 9, name: '氷上すみれ', score: 83, ranking: 7 },
  { id: 10, name: '新条ひなき', score: 80, ranking: 8 } ]
*/

addRankingAllowSameRank(sorted)();
/* =>
[ { id: 6, name: '神崎美月', score: 92, ranking: 1 },
  { id: 8, name: '大空あかり', score: 92, ranking: 1 },
  { id: 1, name: '星宮いちご', score: 90, ranking: 3 },
  { id: 5, name: '藤堂ユリカ', score: 87, ranking: 4 },
  { id: 2, name: '霧矢あおい', score: 85, ranking: 5 },
  { id: 4, name: '有栖川おとめ', score: 85, ranking: 5 },
  { id: 9, name: '氷上すみれ', score: 83, ranking: 7 },
  { id: 3, name: '紫吹蘭', score: 80, ranking: 8 },
  { id: 7, name: '夏樹みくる', score: 80, ranking: 8 },
  { id: 10, name: '新条ひなき', score: 80, ranking: 8 } ]
*/

👍

まとめ

ランキングを生成するための関数が異なるだけなので、オプションで変更できるようにしてまとめるとこんな感じ

// `score` 順にソートした配列を返す関数
const sortByScoreAndID = (arr) => (orderby = 'DESC') => {
  return [...arr].sort((a, b) => {
    if ( orderby === 'ASC' ) {
      [a, b] = [b, a];
    }

    return (a.score > b.score || a.score === b.score && a.id < b.id) ? -1 : 1;
  });
};

// マッピングされたデータから id のあるインデックスを返す関数
const getIndexFromRankArray = (arr) => (id) => {
  return arr.findIndex((keys) => {
    if ( !Array.isArray(keys) || !keys.length ) {
      return false;
    }

    return keys.some((_id) => {
      return _id === id;
    });
  });
};

// key が同じ値なら同位になるランキングの配列を返す関数
const mapDataToRankingArray = (arr, id = 'id') => (key = 'score') => {
  return arr.reduce((accumulator, item, index, _list) => {
    if ( index > 0 ) {
      const prevValue = _list[index - 1][key];
      if ( prevValue === item[key] ) {
        accumulator[accumulator.length - 1].push(item[id]);
        return accumulator;
      }
    }
    accumulator[accumulator.length] = [item[id]];
    return accumulator;
  }, []);
};

// key が同じ値なら同位になり次の順位をスキップしたランキングの配列を返す関数
const mapDataToRankingArrayWithSkip = (arr, id = 'id') => (key = 'score') => {
  return arr.reduce((accumulator, item, index, _list) => {
    if ( index > 0 ) {
      const prevValue = _list[index - 1][key];
      if ( prevValue === item[key] ) {
        const prevID = _list[index - 1][id];
        const sameRankIndex = getIndexFromRankArray(accumulator)(prevID);
        accumulator[sameRankIndex].push(item[id]);
        accumulator.push([]);
        return accumulator;
      }
    }
    accumulator[accumulator.length] = [item[id]];
    return accumulator;
  }, []);
};

// 同位になるランキングを付けた配列をを返す関数
const addRankingAllowSameRank = (list, id = 'id') => (sorted, key = 'score') => (skipRank = true) => {
  sorted = sorted || list;
  const rankingList = skipRank
    ? mapDataToRankingArrayWithSkip(sorted, id)(key)
    : mapDataToRankingArray(sorted, id)(key);

  return list.map((item) => {
    const ranking = getIndexFromRankArray(rankingList)(item[id]) + 1;
    return {...item, ranking: ranking || null};
  });
};

const sorted = sortByScoreAndID(idols)('DESC');
// 同位ありのランキング
addRankingAllowSameRank(idols)(idolsOrderByScoreDESC)(false)
/* =>
[ { id: 1, name: '星宮いちご', score: 90, ranking: 2 },
  { id: 2, name: '霧矢あおい', score: 85, ranking: 4 },
  { id: 3, name: '紫吹蘭', score: 80, ranking: 6 },
  { id: 4, name: '有栖川おとめ', score: 85, ranking: 4 },
  { id: 5, name: '藤堂ユリカ', score: 87, ranking: 3 },
  { id: 6, name: '神崎美月', score: 92, ranking: 1 },
  { id: 7, name: '夏樹みくる', score: 80, ranking: 6 },
  { id: 8, name: '大空あかり', score: 92, ranking: 1 },
  { id: 9, name: '氷上すみれ', score: 83, ranking: 5 },
  { id: 10, name: '新条ひなき', score: 80, ranking: 6 } ]
*/

// 同位あり・順位スキップありのランキング
addRankingAllowSameRank(idols)(idolsOrderByScoreDESC)(true)
/* =>
[ { id: 1, name: '星宮いちご', score: 90, ranking: 3 },
  { id: 2, name: '霧矢あおい', score: 85, ranking: 5 },
  { id: 3, name: '紫吹蘭', score: 80, ranking: 8 },
  { id: 4, name: '有栖川おとめ', score: 85, ranking: 5 },
  { id: 5, name: '藤堂ユリカ', score: 87, ranking: 4 },
  { id: 6, name: '神崎美月', score: 92, ranking: 1 },
  { id: 7, name: '夏樹みくる', score: 80, ranking: 8 },
  { id: 8, name: '大空あかり', score: 92, ranking: 1 },
  { id: 9, name: '氷上すみれ', score: 83, ranking: 7 },
  { id: 10, name: '新条ひなき', score: 80, ranking: 8 } ]
*/

👌👌👌
これでアイカツ!ランキング作りはバッチリ!
アイカツ!エンジニアに一歩近づけましたね🌟


[参考]

関数型プログラミングの基礎 JavaScriptを使って学ぶ

関数型プログラミングの基礎 JavaScriptを使って学ぶ

アイカツ!…みた?