Web production note

 【更新日 :

【JavaScript】アニメーションの処理負荷を軽減する

Category:
JavaScript

単体のアニメーションだとそれほど気にならない場合でも、表示範囲の中で複数のアニメーションが同時に走ったりする場合、負荷が増大して処理落ちなどを起こすことがあります。
レンダリング負荷を軽減するため試行錯誤をしたので、備忘録もかねて実施した内容をまとめました。

可読性や保守性など、よりクリーンなコードを目指す場合は不適切な書き方があるかもしれません。
コードは参考程度に見ていただき、各自の環境でデバッグのうえより適切なコードを選択していただければと思います。

パフォーマンスパネルでデバッグ

今回はChromeのパフォーマンスパネルで検証をしました。
パフォーマンス計測後の詳細からJSファイルを参照すると、具体的な実行時間まで表示されるため重たい処理の特定に非常に重宝しました。

パフォーマンスパネルの使い方の詳細は以下をご参照ください。

FPSとGPUメモリの使用率をモニタリングする

Chromeの場合はレンダリングタブのFrame Rendering Statsも利用すると、レンダリングのFPSとGPUメモリの使用率が画面上でモニタリングできるようになるのでこちらも役に立つと思います。

レンダリングタブの使い方の詳細は以下をご参照ください。

リフローを起こす処理はスクロール中に実行しない

リフローとは

リフローはブラウザのレンダリング過程におけるLayout(レイアウト)フェーズの別名です。

ブラウザがWebサイトを描画する時は段階があり、Parse(パース) > Style(スタイル) > Layout(レイアウト)(リフロー) > Paint(ペイント) > Composite(コンポジット)というフェーズに分かれています。

JavaScriptの処理によってはリフローが発生するものがあり、発生するとそこからの処理「Layout(レイアウト) > Paint(ペイント) > Composite(コンポジット)」を再実行する必要があるため負荷が非常に大きい処理です。

レンダリングの詳細は以下をご参照ください。

リフローを起こす処理について

よく利用しそうな処理(位置、width、height、styleなどの取得・反映)の多くでリフローが発生しますので注意が必要です。
JavaScriptでリフローを起こす処理は以下のドキュメントにまとまっています。

実装例

スクロール処理では要素の現在位置を取得するため getBoundingClientRect() を使うことがあると思いますが、この処理はリフローを起こし非常に負荷が大きいため、スクロール処理とは独立させて実行するようにしました。

before

window.addEventListener('scroll',()=> {
  //スクロールする度にリフローが発生するため処理負荷が非常に高い
  if(0 < target.getBoundingClientRect().top) {
    //処理
  }
});

after

let offsetTop;
const getPosition = ()=> {
  /*
    getBoundingClientRect().topは画面上の相対値を返すので、
    「現在のスクロール量 + 画面内の相対値」で
    ページの一番上からの位置が取得できる(jQueryのoffset().topと同じ)
  */
  offsetTop = target.getBoundingClientRect().top + window.pageYOffset;
}
getPosition();
window.addEventListener('resize', getPosition);

window.addEventListener('scroll',()=> {
  const scrollTop = window.pageYOffset;
  if(scrollTop > offsetTop) {
    //処理
  }
});

style設定はできるの限りComposite(コンポジット)のみで済むtransform、opacityで対応する

transform、opacityの変更はComposite(コンポジット)フェーズからの描画になるため、非常に負荷が軽くアニメーション処理に適したstyleです。
実装の際にはまずこの2つで実現できないか検討するのが良いと思います。

Composite(コンポジット)の詳細やケーススタディは以下をご参照ください。

スクロールイベントの代わりにrequestAnimationFrame()を利用する

スクロールイベントの発火タイミングはアニメーションに最適化されていないため、
代わりに requestAnimationFrame() を利用するとよりアニメーション処理に適した形で処理を呼び出すことができます。

一定の効果は見込めますが停止中も断続的に呼び続ける必要があるため、一定時間ごとにメモリ消費が上がる現象が発生するとご指摘をいただきました。

改修した内容の概要は後述の「スクロールイベント + requestAnimationFrame()を利用する」にまとめました。

before

window.addEventListener('scroll',()=> {
  console.log('スクロールに連動した処理');
});

after

let frameId;
let lastPosition = -1;
const startAnime = () => {
  frameId = requestAnimationFrame(scrollAnime);
}
const scrollAnime = () => {
  const scrollTop = window.pageYOffset;
  if(lastPosition === scrollTop) {
    startAnime();
    return;
  }
  lastPosition = scrollTop;

  console.log('スクロールに連動した処理');

  startAnime();
}
scrollAnime();

特定の要素に対してスクロール連動の処理を実行する場合は IntersectionObserver() を追加して画面に表示された時だけ発火するよう制御するとより効果的です。
スクロール位置のチェックも関数化するとより見通しが良くなるかもしれません。

IntersectionObserver()を使って特定の要素に対してスクロール連動させる

//スクロール位置チェック関数
const isNoScroll = ()=> {
  const isScroll = lastPosition !== scrollTop;
  if(isScroll) {
    lastPosition = scrollTop;
  }
  return !isScroll;
};
let lastPosition = -1;

let frameId,isVisible;
const startAnime = () => {
  frameId = requestAnimationFrame(scrollAnime);
}
const scrollAnime = () => {
  const scrollTop = window.pageYOffset;
  if(isNoScroll()) {
    startAnime();
    return;
  }

  console.log('特定の要素に対してスクロール連動した処理');

  if(isVisible) {
    startAnime();
  }
}

//対象の要素が画面内に入った時のみ処理する
const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    isVisible = entry.isIntersecting;
    if(isVisible) {
      startAnime();

    } else {
      //画面外のときはアニメーションを削除
      cancelAnimationFrame(frameId);
    }
  });
});
observer.observe(target);

requestAnimationFrame()を利用したスクロールイベントの最適化については、以下のドキュメントに詳しくまとまっています。

スクロールイベント + requestAnimationFrame()を利用する

requestAnimationFrame() をスクロールイベントに置き換えると、停止中も断続的に呼び続ける必要があるため、一定時間ごとにメモリ消費が上がる現象が発生するようです。

この問題を回避する方法として createScrollManager.js のようにスクロールイベント + requestAnimationFrame()で書く方法があり、こちらの方がrequestAnimationFrame() を単独で利用するより適していそうです。

スクロールイベント + requestAnimationFrame()

let lastPosition = -1;
const startAnime = () => {
  requestAnimationFrame(onScroll);
}
const onScroll = () => {
  const scrollTop = window.pageYOffset;
  if (lastPosition !== scrollTop) {
    window.removeEventListener('scroll', startAnime);
    lastPosition = scrollTop;

    console.log('実行したい処理');

    startAnime();
  } else {
    window.addEventListener('scroll', startAnime);
  }
}
onScroll();

IntersectionObserver + スクロールイベント + requestAnimationFrame()

特定の要素に対してスクロールイベントを発火させたい場合は、IntersectionObserverを組み合わせて要素が画面に入ったら発火させるよう調整すると、要素が画面外の時に負荷を減らすことができます。
この場合、現在位置をチェックする必要がなくなるので、より処理負荷の軽減が期待できそうです。

IntersectionObserver + スクロールイベント + requestAnimationFrame()

const startAnime = () => {
  requestAnimationFrame(onScroll);
}
const onScroll = () => {
  console.log('実行したい処理');
}
//要素が画面内に入ったら処理開始
const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if(entry.isIntersecting) {
      window.addEventListener('scroll', startAnime);

    } else {
      //画面外のときは削除
      window.removeEventListener('scroll', startAnime);
    }
  });
});
//observer監視開始
observer.observe(target);

requestAnimationFrame()のフレームレート制限

requestAnimationFrame()は利用したからといって必ずアニメーションに適した状態になるとは限りません。
より最適化するためにフレームレート制限(実行タイミンの制御)も実施しました。
(setIntervalで制限している方法もヒットしますが、setIntervalはブラウザの再描画アクションと同期していないため採用を見送りました。)

フレームレートは多くの環境で60fpsなことが多いため、基本60fpsとして頻度を落としても問題ない処理なら30fpsにするなど適宜調整するのが良さそうです。

ブラウザのフレームレートについては以下をご参照ください。

フレームレート制限

//フレームレート設定
const framesPerSecond = 60;

const interval = Math.floor(1000 / frames_per_second);
const startTime = performance.now();
let previousTime = startTime;
let currentTime = 0;
let deltaTime = 0;

let frameId;
const startAnime = () => {
  frameId = requestAnimationFrame(animationLoop);
}
const animationLoop = timestamp => {
  //フレームレート制限
  currentTime = timestamp;
  deltaTime = currentTime - previousTime;
  if (deltaTime <= interval) {
    startAnime();
    return;
  }
  previousTime = currentTime - (deltaTime % interval);

  console.log('実行したい処理');

  startAnime();
}
startAnime();

フレームレート制限の処理を分離させて使い回す場合は
class化しておくと良さそうです。

フレームレート制限をclass化

class LimitFlames {
  constructor(framesPerSecond) {
    this.interval =  Math.floor(1000 / framesPerSecond);
    this.previousTime = performance.now();
  }
  isLimitFlames(timestamp) {
    const deltaTime = timestamp - this.previousTime;
    const isLimitOver = deltaTime <= this.interval;
    if(!isLimitOver) {
      this.previousTime = timestamp - (deltaTime % this.interval);
    }
    return isLimitOver;
  }
}

//フレームレート設定
const limitFlames = new LimitFlames(60);

let frameId;
const startAnime = () => {
  frameId = requestAnimationFrame(animationLoop);
}
const animationLoop = timestamp => {
  //フレームレート制限
  if (limitFlames.isLimitFlames(timestamp)) {
    startAnime();
    return;
  }

  console.log('実行したい処理');

  startAnime();
}
startAnime();

フレームレート制限の詳細は以下をご参照ください。

以下は別のエンジニアの方が教えてくださった補足内容です。

最近の環境では60fps以上出る場合もあるようです。(iPhone 13Proは最高120hz)

また環境によって requestAnimationFrame() の動作にも差があるようですので、フレームレート制限を明示しておくのは一定速度を保つうえでも有効な方法と言えそうです。

requestAnimationFrame()は予め変数に格納しておく

requestAnimationFrame を呼び出すたびに、window オブジェクトからメソッドを検索するコストが省けます。
windowオブジェクトからのプロパティアクセスは現代のブラウザでは非常に高速ですが、アニメーションで数十〜数百回呼び出す場合には多少効果があると思われます。

requestAnimationFrameは丁寧に書くとwindow.requestAnimationFrameです。
windowオブジェクトからの参照は省略可能なため省かれていることが多いです。
(window.console.log(); などが良い例だと思います。)

before

function animeFunc  () {

  //アニメーション処理

  requestAnimationFrame(animeFunc);

}
requestAnimationFrame(animeFunc);

after

const raf = requestAnimationFrame;
function animeFunc  () {

  //アニメーション処理

  raf(animeFunc);

}
raf(animeFunc);

classやstyle操作は変数に格納して利用する

毎回トップレベルから参照すると少し時間がかかるようなので何度も同じ参照が出てくる場合は、なるべく変数に格納して参照をスキップすると少し処理負荷が軽減します。

before

target.style.opacity = 0.1;
if(!target.classList.contains('is-active')) {
  target.classList.add('is-active')
  target.style.opacity = 1;
}

after

const targetStyle = target.style;
const targetClassList = target.classList;

targetStyle.opacity = 0.1;
if(!targetClassList.contains('is-active')) {
  targetClassList.add('is-active')
  targetStyle.opacity = 1;
}

boolean型(true,false)の判別処理は変数などに格納し出来る限り処理を軽くする

boolean型(true,false) を返す処理は沢山ありますが、例えば以下のようにclassの有無を調べる処理の場合、何度もclassList.contains()を呼び出すことになり、実行する度に要素を参照してclassの有無を調べる時間が発生してしまいます。
このような場合はアクティブ判別用のboolean値を格納する変数を用意して参照すると、毎回調べる処理を省くことができるため処理が少し軽くなります。

before

//最初にアクティブ化
if(!target.classList.contains('is-active')) {
  target.classList.add('is-active');
}

console.log('〜色々な処理が続く〜');

//最後にアクティブ化を解除
if(target.classList.contains('is-active')) {
  target.classList.remove('is-active');
}

after

let isActive = false;
//最初にアクティブ化
if(!isActive) {
  target.classList.add('is-active');
  isActive = true;
}

console.log('〜色々な処理が続く〜');

//最後にアクティブ化を解除
if(isActive) {
  target.classList.remove('is-active');
  isActive = false;
}

同じ文字列や処理が何度も出てくる場合は変数に格納する

これはMinify化する際の容量削減につながる内容なので、処理負荷軽減とは直結しないかもしれませんが、微々たるものでもファイル容量が軽量なほど読み込み速度も早くなるため、その分少し早く実行できるようになるはずです。

before

if(!target.classList.contains('is-active')) {
  target.classList.add('is-active');
}
if(target.classList.contains('is-active')) {
  target.classList.remove('is-active');
}

after

const activeClass = 'is-active';
if(!target.classList.contains(activeClass)) {
  target.classList.add(activeClass);
}
if(target.classList.contains(activeClass)) {
  target.classList.remove(activeClass);
}

上記例の場合はclassListも変数に格納し、条件分岐もelseでまとめるとより良くなりそうです。

after

const activeClass = 'is-active';
const targetClassList = target.classList;
if(!targetClassList.contains(activeClass)) {
  targetClassList.add(activeClass);
} else {
  targetClassList.remove(activeClass);
}

断続的に処理を実行する時は、同じ処理を何度も実行し続けないよう調整する

例えば「スクロール中に特定のエリアに入ったらclassとstyleを追加する」という処理を実装する時に以下のように書いたとします。
これは目的の動作は実現できていますが、裏側では特定のエリアに入ってスクロールしている間は同じ処理を実行し続けている状態になるので、ブラウザには大きく負荷をかけ続けてしまいます。

before

const activeFunction = () => {
  target.style.opacity = 1;
}
window.addEventListener('scroll',()=> {
  const scrollTop = window.pageYOffset;
  if(scrollTop > offsetTop) {
    activeFunction();
    target.classList.add('is-active');
  }
});

if文を追加して1度だけ実行されるようにしたり、関数であれば早期returnで終了させるなどの対応を入れて余分にコードが実行されないよう調整します。

修正中

const activeFunction = () => {
  if(target.classList.contains('is-active')) {
    return;
  }
  target.style.opacity = 1;
}
window.addEventListener('scroll',()=> {
  const scrollTop = window.pageYOffset;
  if(scrollTop > offsetTop) {
    activeFunction();
    if(!target.classList.contains('is-active')) {
      target.classList.add('is-active');
    }
  }
});

更にトッププレイヤーからの参照を変数に格納したり、boolean値の参照緩和など他で上げた内容と組み合わせるとより効果的かもしれません。

after

const isActive = false;
const targetClassList = target.classList;
const targetStyle = target.style;
const activeFunction = () => {
  if(isActive) {
    return;
  }
  targetStyle.opacity = 1;
}
window.addEventListener('scroll',()=> {
  const scrollTop = window.pageYOffset;
  if(scrollTop > offsetTop) {
    activeFunction();
    if(!isActive) {
      targetClassList.add('is-active');
      isActive = true;
    }
  }
});

styleはsetAttribute()で設定する方が早い

styleで設定する場合は、反映する前に値が有効かどうかチェックが入るため少し遅くなりますが、setAttribute() はチェック処理がないのでstyleよりも早く設定が可能です。

before

//追加
target.style.opacity = 1;

after

//追加
target.setAttribute('style','opacity:1;');

style追加はなるべくclass付与で対応する

直上でstyleよりsetAttribute()が早いと述べましたが、動的に変化しない固定のstyleであればJavaScriptで設定するよりもCSSに書いてclass追加する方が早いです。

before

//固定のstyle
target.setAttribute('style','opacity:1;position:absolute;top:0;left:0;');

after(JSファイル)

//固定のstyleをclass付与で反映
target.classList.add('is-active');

after(CSSファイル)

/* 固定のstyle */
.target.is-active {
  opacity: 1;
  position: absolute;
  top: 0;
  left: 0;
}

計算やstyle設定する前に小数点以下の桁数を制限する

スクロール量を取ったり、計算過程で正規化や線形補間を利用すると、値の小数点以下の桁数が過剰に多くなる(0.xxxxxxxxxxxxxxxxx)ことがよくありますが、このまま処理を続けると桁数が多いぶん負荷が上がり、計算スピードやstyle反映は遅くなります。
桁数が無駄に増えている場合は Math.round() などを利用して小数点を取ってあげることを検討すると良いと思います。

正規化や線形補間など、正確に値を出したい場合は値を丸めない方が良いこともあるので必ず検証をしてください。

before

window.addEventListener('scroll',()=> {
  const scrollTop = window.pageYOffset;
  const scrollValue  = clamp(scrollTop, min, max);
  const norm = norm(scrollValue, min, max);
  transformY = scrollValue * norm;
});

after

window.addEventListener('scroll',()=> {
  //小数点以下を削除する
  const scrollTop = Math.round(window.pageYOffset);
  const scrollValue  = clamp(scrollTop, min, max);
  const norm = norm(scrollValue, min, max);

  //小数点第1位まで残す
  transformY =  Math.round((scrollValue * norm) * 10) / 10;
});

小数点以下のコントロールは toFixed() でも可能ですが、こちらは返り値のデータ型が文字列に変換されるため計算の過程では少し扱いにくかったのと、検証環境では処理速度もやや遅かったため Math.round() を採用しました。

正規化・線形補間については以下をご参照ください。

リサイズイベントの頻度を減らす・横幅の変更のみ検知する

resizeイベントはそのままだと発火頻度が多いため、頻度を減らすことでパフォーマンス改善につながります。
また、resizeイベントは iOS Safariのアドレスバーの表示・非表示にも発火してしまうため(Windowの縦幅が変更されるため)、場合によっては横幅のみ検知するよう調整することも有効です。

高頻度で取得・処理する必要がある場合はレイアウト崩れの原因になる可能性もあります。

リサイズイベントの頻度を減らす

let timeoutId;
window.addEventListener('resize', ()=> {
  if(timeoutId) {
    return;
  }
  timeoutId = setTimeout(() => {
    timeoutId = 0;

    console.log('リサイズ処理');

  }, 500);
});

以下のコードを参考にさせていただきました。

横幅の変更のみ検知

let lastInnerWidth = window.innerWidth;
window.addEventListener('resize', ()=> {
  const winWidth = window.innerWidth;
  if(lastInnerWidth !== winWidth) {
    lastInnerWidth = winWidth;

    console.log('リサイズ処理');

  }
});

以下のコードを参考にさせていただきました。

リサイズイベントの代わりにResizeObserver()を利用する

ResizeObserver() は要素のサイズが変わった時に発火し、windowの情報を検知する必要がなければリサイズイベントよりも発火頻度が少なく済むため負荷軽減につながると考えられます。
使い方はIntersectionObserver()と似ています。

ResizeObserver()の利用

const resizeFunction = ()=> {
  console.log('tagretのサイズが変更されました');
}
const resizeObserver = new ResizeObserver(resizeFunction);
resizeObserver.observe(target);

ResizeObserver() の詳細は以下をご参照ください。

ブレイクポイントの切り替えにはmatchMediaを利用する

ブレイクポイント毎に1度だけ発火すれば良い処理なら、matchMediaを利用する方が負荷を軽減できます。

before

let isMobile;
const checkWinSize = ()=> {
  if(767 < window.innerWidth) {
    isMobile = false;
  } else {
    isMobile = true;
  }
}
window.addEventListener('resize', checkWinSize);
checkWinSize();

after

let isMobile;
const mediaQueryList = window.matchMedia('(min-width:768px)');
const checkWinSize = event => {
  isMobile = !event.matches;
};
mediaQueryList.addEventListener('change', checkWinSize);
checkWinSize(mediaQueryList);

以下のコードを参考にさせていただきました。

要素を複数同時に移動させる時はDocumentFragmentオブジェクトを経由させる

jQueryのunwrap()をJavaScriptで再現したコードで考えます。
beforeのコードはループ内で毎回要素を移動させているため、リフローが何度も発生し非常に高負荷な処理になっています。
複数の要素を移動させる場合は、createDocumentFragment() を用いて空のDocumentFragmentオブジェクトを生成し、そこへ一時的に格納してから最後にまとめて移動させる方法が有効です。
DocumentFragmentオブジェクトは、メモリ上にのみ存在しDOMツリーに登録されないためレンダリングエンジンの描画対象になりません。
一時的なDOMオブジェクトの格納先として利用できるため、処理負荷の軽減に非常に役立ちます。

befor

//jQueryのunwrap()を再現
while(target.firstChild) {
  target.before(fragment);
}
target.remove();

after

//jQueryのunwrap()を再現
const fragment = document.createDocumentFragment();
while(target.firstChild) {
  fragment.appendChild(target.firstChild);
}
target.before(fragment);

createDocumentFragment() については以下などをご参照ください。

ダイナミックな動きの場合は Will-Change を追加する

画面上で大きな画像を動かす場合などダイナミックな動きが発生する場合、Composite(コンポジット)フローのみで済むtransformなどであってもWill-Changeを加えることでアニメーションがより滑らかなになる場合があります。
Will-Changeは恒常的に設定するとパフォーマンスが落ちるとも言われているため、アニメーション発火前にJavaScriptで動的に設定するのが無難なようです。

多用しすぎてもパフォーマンスが落ちると言われているので注意が必要です。

画面内に入った時だけWill-Changeを追加するサンプル

//画面に入ったらWill-Changeを追加する
const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    if(entry.isIntersecting) {
      target.style.willChange = 'transform';
      startAnime();
    } else {
      resetAnime();
      target.style.willChange = '';
    }
  });
});
//observer監視開始
observer.observe(target);

style.willChangeでパフォーマンスが気になる場合は、CSS側にWill-Change用のclassを定義しておきclassList.add()を利用してclassの付け替えで設定する方法もあると思います。

実装規模に応じて検討すること

scroll、windowの高さ・幅などを一元管理する

複数のJSファイルをimport、exportで管理していて、かつ各ファイル内でwindowの情報など共通して扱う値を別々に参照している場合、一箇所にまとめることで処理負荷の軽減に繋がる可能性があります。

import { function1 } from './function1';
import { function2 } from './function2';

function1(); //内部でwindow.innerWidthを参照している(共通処理の重複)
function2(); //内部でwindow.innerWidthを参照している(共通処理の重複)

function1(before)

let winWidth = window.innerWidth;
console.log(winWidth);

function2(before)

let winWidth = window.innerWidth;
console.log(winWidth);

function1、2それぞれでwindow幅を取得しているので、上記は2重に処理が走っていることになります。
1回の処理で済むように別途取得用のファイルを用意してimportします。

get-window-size.js (情報取得を一箇所に集約しexportするファイル)

let winWidth;
const getWindowSize = ()=> {
  winWidth = window.innerWidth;
}
getWindowSize();
window.addEventListener('resize', getWindowSize);

export { winWidth };

function1(after)

import { winWidth } from './get-window-size';
console.log(winWidth);

function2(after)

import { winWidth } from './get-window-size';
console.log(winWidth);

同時実行する量を減らす(早期実行しなくて良いものは数ミリ秒程度遅らせて実行する)

ページアクセス時にはレンダリングのワークフローを最初から実行していて様々な処理が走っているので、このタイミングで処理が集中するとフレーム落ちなどが発生しやすくなります。
早期実行しなくても良いものは setTimeout() などで実行タイミングを少しずらしてあげるとスムーズに処理できる場合があります。

before

//ページアクセス時に全ての処理が同時に実行される
function1();
function2();
function3();
function4();

after

//遅らせても問題ない処理は実行タイミングを少しずらす
function1();
setTimeout(() => {
  function2();
  function3();
  function4();
}, 300);

【2/24追記】関数はアロー関数より通常のfunctionで書く

以下記事の途中にて検証内容が上がっていますが、アロー関数で書くよりも通常のfunction構文の方が若干パフォーマンスが良いようです。

アロー関数がパフォーマンス的に悪いと言われる理由ですが、
下記のことがよく指摘されているようです。

  • 関数式共通 → メモリ(ヒープ)の話
    関数宣言はグローバル領域に巻き上げられるが、関数式はたいてい匿名関数(アロー関数も匿名関数)として使用され、呼ばれるたびに内部的な新しい名前が付いて生成されるため、パフォーマンスに影響する。
  • アロー関数 → スクリプト解析の差
    暗黙のリターン(=> アロー関数の戻り値判定。 アロー関数は return の省略が可能なので、省略された場合は { } 内の最終行の結果が戻る。1行しか無ければその1文)がインタプリタ・コンパイラ視点で解析しづらいため、パフォーマンスに影響する。

※ついでに。暗黙のリターンで有名なのが、ダンさんのTwitter。
https://twitter.com/dan_abramov/status/1036270987380224005

const Hoge: React.FC=()=>って書いてたら思考停止系と言われたので調べた

効果があるかもしれない対応

裏付けが取れていない内容です。

複雑な計算は分離する

1行で何重にも入れ子で処理するより、分割してシンプルに実行する方が少しパフォーマンスが良かったです。
どこが1番外側でどの順番に処理すべきなのか探す時間が少しかかっているのかもしれません。
(以下は極端な例ですが、可読性の良いコードを書くうえでもある程度で分割する方が良い気がします。)

before

console.log(Math.round((clamp(Math.round(window.pageYOffset), min, max) * norm(scrollValue, min, max)) * 10) / 10);

after

const scrollTop = Math.round(window.pageYOffset);
const scrollValue = clamp(scrollTop, min, max);
const norm = norm(scrollValue, min, max);
//小数点第1位まで残す
const transformY = Math.round((scrollValue * norm) * 10) / 10;
console.log(transformY);

値を直接書き換えずCSS変数を変更する

AIチャットとのやり取りで発見したことです。自身でパフォーマンス比較まではできていないのですが、参考程度に記載をしておきます。

before

let value = 0; //断続的に変わる値
target.style.transform = 'translate3d(0, ' + value + 'px, 0)';

after : JavaScript

let value = 0; //断続的に変わる値
target.style.setProperty('--value', value)

after : CSS

/* 変数を参照する */
.target {
  transform: translate3d(0, var(--value), 0);
}

以下がAIの回答です。

CSS変数と直接スタイルを設定する2つの方法について、処理負荷の観点から比較するのは良い質問です。

概して、CSS変数を使う方が処理負荷が軽くなる傾向があります。

理由は以下の通りです:

  • CSS変数はCSS内で宣言と参照ができるので、スタイルの再計算が最小限に抑えられます。JavaScriptから変数の値を更新するだけで済みます。
  • 直接スタイルを設定する場合、ブラウザはそのたびに要素のレイアウト/描画を再計算する必要があります。
  • CSS変数は効率的にキャッシュされるのに対し、JavaScriptでの直接スタイル設定はキャッシュされません。
  • CSSはアニメーション/トランジションなどの最適化がされているのに対し、JavaScriptはそうした最適化がない。

ですので、頻繁に値が変更される場合はCSS変数の方がパフォーマンス面で有利です。 コストは設定時にかかるだけです。

ただし、複雑な計算が必要な場合はJavaScriptを使う方が扱いやすい場合もあります。 状況に応じてどちらが適しているか判断する必要があるでしょう。 頻繁な変更があるならCSS変数、複雑な計算が必要ならJavaScriptといった具合です。

参考リンク

検証の過程でもっと色々見たのですが、とりあえず手元に残っているものだけ貼っています。