🐧

JS汎用クラスとかを作って貯めていきたい

2022/11/19に公開

記事の意図

汎用的に使えそうなクラスを作って、貯めていこうと思います。
自分のメモ用なので、もし活用される方がいらっしゃれば
その点御理解ください。

クラス

IntersectionObserverで画面内交差検知

JavaScript
class ScrollObserver {
  constructor(els, cb,rootMargin,options) {
    this.els = els;//NodeListを渡す
    const defaultOptions = {
      root: null, //交差対象
      rootMargin: rootMargin, //交差判定境界線
      threshold: 0,//targetのどこで交差判定するか
      once:true
    };
    this.cb = cb;
    this.options = Object.assign(defaultOptions, options); //オブジェクトを合体させる
    this.once = this.options.once;
    this._init();
  }
  
  //初期化
  _init() {
    const callback = function (entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          //画面内に入った時
          this.cb(entry.target, true);
          if (this.once) {
            observer.unobserve(entry.target); //監視を終了する
          }
        } else {
          //画面外に出た時
          this.cb(entry.target, false);
        }
      });
    };
    
    this.io = new IntersectionObserver(callback.bind(this), this.options);
    this.els.forEach(el => this.io.observe(el));
  }

  destroy() {
    this.io.disconnect();//IOの監視を終了する
  }
}

//使い方
//1.コールバック関数を定義する
// const cb = function (el, isIntersecting) {
//   if (isIntersecting) {
//     ここに画面内に入ったら行いたい処理をかく
//   }
// }
//※上記のelにはセレクタではなくElement(entry.target)が渡ることに注意する
//2.インスタンス化する(第一引数にNodeListを渡す)
// const so = new ScrollObserver(document.querySelectorAll('.監視したい要素'), cb, rootMargin,options:あってもなくても良い,{once:false});
//once:falseだと何度も監視をする。デフォルトはtrueで画面内に入った時に一度だけ処理を実行する

テキスト分割アニメーション

JavaScript
class SplitTextAnimation {
  constructor(el) {
    this.el = el;//Elementを渡す
    this.chars = this.el.innerText.trim();
    this.concatStr = "";
    this.el.innerHTML = this._splitText(); //クラスに渡された引数が分割された状態のDOM
    this.animations = [];
    this.chars = '';
    this.transY = "170px";//transformY
    this.outer = document.createElement('div');//対象の親にdivを追加
    this._init();
  }

  //テキストがcharクラスをもつspanで1文字ずつ分割される関数
  _splitText() {
    for (let c of this.chars) {
      c = c.replace(" ", " ");
      this.concatStr += `<span class="char">${c}</span>`;
    }
    return this.concatStr;
  }
 
  //分割したテキストにデフォルトスタイルを付与
  _init() {
    this.chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    this.chars.forEach(char => {
      char.style.display = 'inline-block';
      // char.style.opacity = 0;
      char.style.transform = `translateY(${this.transY})`;
    })
  }

  //アニメーションの対象をdivで囲みclip-path
  _clip() {
    this.outer.classList.add('js-outer');//囲むdivにクラスを付与
    this.el.parentNode.insertBefore(this.outer, this.el);//対象の親要素の子要素にdivを挿入
    this.outer.appendChild(this.el);//生成したdivの中に対象を入れる
    this.outer.style.clipPath = "polygon(0 0,100% 0,100% 100%,0 100%)";//divより外側をclip-pathで切り取る
  }

  //1文字ずつfadeUpする
  fadeUpText() {
    this._clip();//isIntersectingになったら実行される
    //タイミング制御用オブジェクトを定義
    let timings = {
      easing: "ease-in-out",
      fill: "forwards",
    };
    let x,easing;

    this.chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    this.chars.forEach((char, i) => {
      x = i / (this.chars.length - 1);//0 ~ 1
      const maxDelay = 170;//delay最大値
      //イージング関数
      function ease(x){
        return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2;
        }
      easing = ease(x);
      timings.delay = easing * maxDelay;
      timings.duration = 550;
      const animation1 = char.animate(
        [{ transform: `translateY(${this.transY}) rotateZ(20deg)` }, { transform: "translateY(0px) rotateZ(0)" }],
        timings
      );
      animation1.cancel();
      this.animations.push(animation1);
  
      const animation2 = char.animate([{ opacity: 0 }, { opacity: 1 }], timings);
      animation2.cancel();
      // this.animations.push(animation2);
    });
    this.animations.forEach((anim) => {
      anim.play();
    });
  }
}

//使い方
//1.インスタンス化
// const ta = new SplitTextAnimation(ここにアニメーションさせたい要素のセレクタを渡す)
//2.アニメーション関数の実行
// ta.fadeUpText()

スクロール量、率、正規化値の取得

JavaScript
//スクロール量、率、正規化した値を取得するクラス
class GetScrollNum {
  constructor() {
    this.scrollY = "";
    this.normalizeScrollY = "";
    this.scrollRate = "";
    this.viewH = document.documentElement.scrollHeight;
    this.totalScrollY = this.viewH - innerHeight;
  }

  getResult() {
    this.scrollY = window.pageYOffset; //スクロール量
    this.normalizeScrollY = this.scrollY / this.totalScrollY; //スクロール量正規化
    this.scrollRate = Math.floor(this.normalizeScrollY * 100); //スクロール率
  }
}

//使い方
//インスタンス化して、スクロールイベント内でgetResultを実行する。
//欲しい値を引っ張り出す

任意のスクロール量で、任意の範囲変化させられる数値を得る

JavaScript
class ScrollFunction {
  constructor(target, scrollMax, rootMargin, ease, min, max) {
    this.target = target; //変化させたいターゲット
    this.scrollMax = scrollMax; //任意のスクロール量の最大値
    this.rootMargin = rootMargin; //画面のどこにtargetが来たら検知開始するか
    this.ease = ease; //イージング関数を入れる
    this.min = min; //任意の範囲の最小値
    this.max = max; //任意の範囲の最大値
    this.scrollY = window.pageYOffset; //画面上部からのスクロール量
    this.targetPos = this.target.getBoundingClientRect().top + this.scrollY; //targetのページ上部からの位置
    this.targetScroll = ""; ///任意の要素がrootMarginの位置に来たときにスクロール量を0としてscrollMaxまで変化する
    this.winH = innerHeight;
    this.resultNormal = 0; //linearでmin~maxの間を変化する変数
    this.resultEase = 0; //イージングを適応してmin~maxまで変化する変数
    this._getResultNum(); //スクロール量を取得して値を更新する
  }

  //正規化
  _norm(v, a, b) {
    return (v - a) / (b - a);
  }

  //線形補完
  _lerp(a, b, t) {
    return a + (b - a) * t;
  }

  _getResultNum() {
    //要素が画面内に最初からいる時(FV内にある時)スクロール量をそのまま正規化に使用する
    if (this.targetPos < this.winH + this.target.clientHeight) {
      window.addEventListener(
        "scroll",
        function () {
          this.scrollY = window.pageYOffset;
          if (this.scrollY > this.scrollMax) {
            this.scrollY = this.scrollMax;
          }
          const normalizeNum = this._norm(this.scrollY, 0, this.scrollMax); //0~1
          const easeNum = this.ease(normalizeNum); //正規化した数値にイージングを効かせる
          this.resultNormal = this._lerp(
            this.min,
            this.max,
            normalizeNum
          ).toFixed(2); //イージングなし
          this.resultEase = this._lerp(this.min, this.max, easeNum).toFixed(2); //イージングが効いた値
        }.bind(this)
      );
    } else {
      window.addEventListener(
        "scroll",
        function () {
          this.scrollY = window.pageYOffset;
          this.targetScroll =
            this.scrollY - this.targetPos + this.winH + this.rootMargin;
          //要素が画面内に入ってからのスクロール量を0~this.scrollMaxに留める
          if (this.targetScroll !== undefined && this.targetScroll < 0) {
            this.targetScroll = 0;
          } else if (
            this.targetScroll !== undefined &&
            this.targetScroll > this.scrollMax
          ) {
            this.targetScroll = this.scrollMax;
          }
          const normalizeNum = this._norm(this.targetScroll, 0, this.scrollMax); //0~1
          const easeNum = this.ease(normalizeNum); //正規化した数値にイージングを効かせる
          this.resultNormal = this._lerp(
            this.min,
            this.max,
            normalizeNum
          ).toFixed(2); //イージングなし
          this.resultEase = this._lerp(this.min, this.max, easeNum).toFixed(2); //イージングが効いた値
        }.bind(this)
      );
    }
  }
}

//使い方
//target(第一引数)にはElementを入れる = document.getElementByID('任意の要素');
//使いたいイージング関数を変数に格納して用意
//スクロールイベント外でインスタンス化
//スクロールイベント内で欲しい値を取得(resultEase or resultNormal)
//targetの変更したいスタイルに取得した数値を適応する
//--------------------------------------------------------

テキスト分割

JavaScript
//classNameに任意のクラス名を渡すことで、テキスト分割時のspanのクラスを指定できる
//クラスは指定しなくても可
class SplitText{
  constructor(targetClassName,generateClassName) {
    this.els = document.querySelectorAll(targetClassName);//NodeListを渡す
    if (generateClassName) {
      this.generateClassName = generateClassName;
    }
    if (this.els) {
      this.els.forEach((el) => {
        this.chars = el.innerText.trim();
        this.concatStr = "";
        el.innerHTML = this._splitText();
      })
    }
  }
  //テキストが[this.classnme]クラスをもつspanで1文字ずつ分割される関数
  _splitText() {
    for (let c of this.chars) {
      c = c.replace(" ", "&nbsp;");
      if (this.generateClassName) {
        this.concatStr += `<span class="${this.generateClassName}">${c}</span>`;
      } else {
        this.concatStr += `<span>${c}</span>`;
      }
    }
    return this.concatStr;
  }
}

//targetClassName:テキスト分割したい対象
//generateClassName:分割したspanに付与するクラス(任意)

ホバーした時のマウス座標を取得して、カーソルに吸い付く動き

JavaScript

class StickAnime {
  constructor(els,range) {
    this.els = els;
    if (range !== undefined) {
    this.range = range;
    }
    this.mouseX = 0;//マウス座標
    this.mouseY = 0;
    this.normX = 0;//マウス座標を-1~+1の数値へ変換
    this.normY = 0;
    this.getHoverMousePos();
  }

  //ホバーした時のマウス座標と、それを−1~1へ変換した数値を取得
  getHoverMousePos() {
    this.els.forEach((el) => {
      let elW = el.clientWidth;
      let elH = el.clientHeight;
      let elPosX, elPosY;
      window.addEventListener("resize", function () {
        elW = el.clientWidth;
        elH = el.clientHeight;
      });
      //ホバーした時に要素の位置を取得
      el.addEventListener("mouseover", function () {
        elPosX = el.getBoundingClientRect().left;
        elPosY = el.getBoundingClientRect().top;
      });


      el.addEventListener("mousemove", function (e) {
        //ホバーしているときにマウス座標を取得
        this.mouseX = e.clientX;
        this.mouseY = e.clientY;

        this.normX = (((this.mouseX - elPosX) / elW - 0.5) * 2).toFixed(2); //-1~1
        this.normY = (((this.mouseY - elPosY) / elH - 0.5) * 2).toFixed(2); //-1~1
      }.bind(this));
    });
  }

  //stickTargetに吸い付くアニメーションを付与
  //translateを%指定
  stickyMovePar() {
    this.els.forEach((el) => {
      const target = el.querySelector(".stickTarget");
      el.addEventListener("mousemove", function () {
        target.setAttribute(
          "style",
          `transform:translate(${this.normX * 10}%,${this.normY * 80}%);`//Y座標の方が動きが小さくなるため適宜調整
        );
      }.bind(this));
      //mouseoutでリセット
      el.addEventListener("mouseout", function () {
        target.setAttribute("style", `transform:translate(0%,0%)`);
        this.mouseX = 0;
        this.mouseY = 0;
        this.normX = 0;
        this.normY = 0;
      });
    });
  }
  //translateをpx指定
  stickyMovePx() {
    this.els.forEach((el) => {
      const target = el.querySelector(".stickTarget");
      let transX = (this.normX * this.range).toFixed(2);
      let transY = (this.normY * this.range).toFixed(2);
      el.addEventListener("mousemove", function () {
      transX = (this.normX * this.range).toFixed(2);
      transY = (this.normY * this.range).toFixed(2);
        target.setAttribute(
          "style",
          `transform:translate(${transX}px,${transY}px);`
          //上下[this.range]px四方の範囲で追従する
        );
      }.bind(this));
      //mouseoutでリセット
      el.addEventListener("mouseout", function () {
        target.setAttribute("style", `transform:translate(0%,0%)`);
        this.mouseX = 0;
        this.mouseY = 0;
        this.normX = 0;
        this.normY = 0;
      });
    });
  }
}

//できること
//任意のクラスを持つ要素にホバーした時にカーソル座標の取得、また座標の正規化(0~1へ変換)
//任意のクラスを持つ要素の中にある「stickTarge」クラスを持つ要素のtranslateを正規化したカーソル座標と連動させて、吸い付く動きをつける。
//任意のクラスを持つ要素は複数でも可能。

//使い方
//HTML側で、ホバーした時にカーソル座標を取得したい要素を用意
//吸い付くテキストを生成したい場合は、上記の中に[stickTarget]クラスを持つ要素を作る
//引数にNodeListと、px指定で移動させたい時は、要素を追従させたい範囲を渡してインスタンス化
//stickyMovePar(%指定)stickyMovePx(px指定、追従範囲指定可能)かを実行。
//cssで任意のtransitionを付与

const sa = new StickAnime(document.querySelectorAll(".wrapper"),50);
sa.stickyMovePx();
// const sa = new StickAnime(document.querySelectorAll(".wrapper"));
// sa.stickyMovePar();

車窓タイプの画像パララックスを実装するためのクラス

JavaScript

class Parallax{
  constructor(trigger,speed) {
    this.trigger = trigger;
    this.triggerEls = document.querySelectorAll(this.trigger);//(.js-parallax-trigger)
    this.triggerEls.forEach(triggerEl => {
      //js-parallax-triggerとimgにデフォルトスタイルを当てる
      triggerEl.setAttribute('style', 'position:relative;overflow:hidden');
      const targetImg = triggerEl.querySelector('img');
      targetImg.setAttribute('style', 'height:100%;');
      this.target = triggerEl.querySelector('.js-parallax-target');
      this.target.setAttribute('style', 'width:100%;height:200%;position:absolute;top:0%;left:0;');
    });
    this.speed = speed;//パララックス速度調整用変数
    this.transY = 0;
    this.triggerPosY = 0;//要素のページ上部からの距離からinnerHeighを引いた数値
    this.scrollY = window.scrollY;//スクロール量
    this.triggerArray = [];//js-parallax-trigger格納用配列
    this.targetArray = [];//js-parallax-target格納用配列
    this.getScrollNum = () => {
      this.scrollY = window.scrollY + window.innerHeight;//window下部からのスクロール量を取得
    if (this.targetArray.length !== 0) {
      this.targetArray.forEach((targetEl) => {
        this.transY = (-(this.scrollY - this.triggerPosY) * speed).toFixed(2);//ターゲットが画面内に入ってきた時のスクロール量を0として、translateの適応するように、数値を調整
        targetEl.style.transform = `translateY(${this.transY}px)`;
      });
    }
    }
    new ScrollObserver(this.triggerEls, this._cb, '0%',{once:false});
  }

  _cb = (el, isIntersecting) => {
    if (isIntersecting) {
      // 画面内の時の処理
      this.triggerArray.push(el);
      this.triggerArray.forEach(triggerEl => {
        this.triggerPosY = triggerEl.getBoundingClientRect().top + window.scrollY;
        this.target = triggerEl.querySelector('.js-parallax-target');
        this.targetArray.push(this.target);
      });
      this.getScrollNum();//ページ読み込み時に一度実行
      window.addEventListener('scroll', this.getScrollNum.bind(this));
    } else {
      //画面外の時の処理
      this.triggerArray.splice();//画面外にでた要素を配列から削除
      this.targetArray.splice();//画面外にでた要素を配列から削除
      window.removeEventListener('scroll',this.getScrollNum.bind(this));
    }
  }
}

const pi = new Parallax('.js-parallax-trigger',0.1);
const pi2 = new Parallax('.js-parallax-trigger2',0.1);
const pi3 = new Parallax('.js-parallax-trigger3',0.1);

//★使い方
//必要なDOM構造は以下
/* <div class="js-parallax-trigger">
      <div class="js-parallax-target">
        <img src="images/image1.jpg" alt="">
      </div>
</div> */
//第一引数 : 画面内に入ったらパララックスを開始して欲しい要素のクラス名を文字列で与える
//第二引数 : パララックスの速度(0.4より大きいと早すぎるので適宜調整)

//★出来ること
//監視対象要素が画面内に入ったら、パララックスを開始
//画面外にでたらパララックス終了(監視は継続)
//ページ際読み込み時もパララックス位置を維持

//★注意点
//一つのインスタンスでできるのは、画面内に一つの監視対象の時、もしくは監視対象が複数の時は全て完全に横並びである必要がある。
//画面内に複数の監視対象があり、位置が異なる場合には、複数のインスタンス化が必要。

シェーダー対応のPlaneを生成するだけのクラス(Three.js)

JavaScript
import * as THREE from 'three';
import vertex from '../shader/vertex.glsl';
import fragment from '../shader/fragment.glsl';

//シェーダー対応のPlaneを生成するクラス
class CreatePlane {
  constructor(container) {
    this.container = container; //レンダリング領域
    if (!this.container) return; //定義されていなければreturn
    this.setup();
    this.render();
  }

  setup() {
    // リサイズ(負荷軽減のためリサイズが完了してから発火する)
    let timeoutId = 0;
    window.addEventListener('resize', () => {
      if (timeoutId) clearTimeout(timeoutId);
      timeoutId = setTimeout(this.onWindowResize.bind(this), 200);
    });
    
    //シーンを定義
    this.scene = new THREE.Scene();

    //カメラを定義
    this.camera = new THREE.PerspectiveCamera(40, this.viewport.aspectRatio, 0.1, 1000);
    this.camera.position.set(0, 0, 3);

    //レンダラーを定義
    this.renderer = new THREE.WebGLRenderer({
      antialias: true,
      // alpha: true,
    });
    // this.renderer.setClearColor('#EBF0F2', 1);
    this.renderer.setSize(this.viewport.width, this.viewport.height);
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.container.appendChild(this.renderer.domElement); //body直下にcanvasを追加

    //ジオメトリを定義
    this.geometry = new THREE.PlaneGeometry(1, 1, 32, 32);

    //uniformsを定義
    let uniforms = {};

    //マテリアルを定義
    this.material = new THREE.ShaderMaterial({
      // uniforms,
      vertexShader: vertex,
      fragmentShader: fragment,
    });

    //Planeを生成
    this.plane = new THREE.Mesh(this.geometry, this.material);
    this.scene.add(this.plane);
  }

  //レンダリング
  render() {
    this.renderer.render(this.scene, this.camera);
  }

  //viewportサイズとアスペクト比を取得
  get viewport() {
    let width = this.container.clientWidth;
    let height = this.container.clientHeight;
    let aspectRatio = width / height;
    return {
      width,
      height,
      aspectRatio,
    };
  }

  //リサイズ対応
  onWindowResize() {
    this.camera.aspect = this.viewport.aspectRatio;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(this.viewport.width, this.viewport.height);
    this.renderer.render(this.scene, this.camera);
  }
}

const container = document.querySelector('.wrapper');
const planeCanvas = new CreatePlane(container);
vertex.glsl
varying vec2 vUv;

void main() {
  vUv = uv;
  vec3 pos = position;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
fragment.glsl
varying vec2 vUv;

void main() {
  gl_FragColor =vec4(vUv,1.,1.);
}

描画結果

ファイルロード時間を計測する

SourcePerformance.ts
/**
 * 画像のパフォーマンス測定に関するオプション
 */
interface SourcePerformanceOptions {
  /** タイムアウト時間(ミリ秒) */
  timeout?: number;
  /** 同時に測定する画像の数 */
  concurrency?: number;
  /** キャッシュをバイパスするかどうか */
  bypassCache?: boolean;
}

/**
 * パフォーマンス測定の結果
 */
interface PerformanceMetrics {
  /** 総ロード時間(ミリ秒) */
  totalLoadTime: number;
  /** DNS ルックアップ時間(ミリ秒) */
  dnsLookupTime?: number;
  /** 接続時間(ミリ秒) */
  connectionTime?: number;
  /** Time to First Byte(ミリ秒) */
  ttfb?: number;
  /** ダウンロード時間(ミリ秒) */
  downloadTime?: number;
  /** 手動測定かどうか */
  manualMeasurement?: boolean;
  /** パフォーマンスエントリを使用したかどうか */
  performanceEntry?: boolean;
}

/**
 * ウェブページ上の画像のパフォーマンスを測定するクラス
 */
class SourcePerformance {
  private options: Required<SourcePerformanceOptions>;
  private performanceEntries: Map<string, PerformanceResourceTiming>;
  private measurements: Map<string, PerformanceMetrics>;
  private observer: PerformanceObserver | null;

  /**
   * SourcePerformance クラスのコンストラクタ
   * @param options - パフォーマンス測定のオプション
   */
  constructor(options: SourcePerformanceOptions = {}) {
    this.options = {
      timeout: options.timeout || 20000,
      concurrency: options.concurrency || 5,
      bypassCache: options.bypassCache || false
    };
    this.performanceEntries = new Map();
    this.measurements = new Map();
    this.observer = null;
    this.initializePerformanceAPI();
  }

  /**
   * Performance API の初期化を行う
   * ブラウザがAPI をサポートしている場合、パフォーマンス測定の準備を行う
   */
  private initializePerformanceAPI(): void {
    if (!this.isPerformanceSupported()) {
      console.error('お使いのブラウザは高度なパフォーマンス測定をサポートしていません。基本的な測定のみ行います。');
      return;
    }

    performance.setResourceTimingBufferSize(1000);
    console.log('パフォーマンス測定の準備が完了しました。');

    this.observer = new PerformanceObserver(this.handlePerformanceEntries.bind(this));
    this.observer.observe({ entryTypes: ['resource'] });
    console.log('画像の読み込み状況の監視を開始しました。');
  }

  /**
   * ブラウザが Performance API をサポートしているかチェックする
   * @returns Performance API がサポートされているかどうか
   */
  private isPerformanceSupported(): boolean {
    return typeof performance !== 'undefined' && typeof PerformanceObserver !== 'undefined';
  }

  /**
   * PerformanceObserver によって収集されたエントリを処理する
   * @param list - PerformanceObserverEntryList オブジェクト
   */
  private handlePerformanceEntries(list: PerformanceObserverEntryList): void {
    list.getEntries().forEach(entry => {
      if (entry.entryType === 'resource' && entry instanceof PerformanceResourceTiming) {
        this.performanceEntries.set(entry.name, entry);
        console.log(`画像の読み込み情報を記録しました: ${entry.name}`);
      }
    });
  }

  /**
   * 単一の画像のパフォーマンスを測定する
   * @param imageUrl - 測定する画像の URL
   * @returns 測定結果の Promise
   */
  async measureImagePerformance(imageUrl: string): Promise<PerformanceMetrics> {
    console.log(`画像の読み込み速度の測定を開始します: ${imageUrl}`);
    const urlWithCacheBuster = this.options.bypassCache ? this.addCacheBuster(imageUrl) : imageUrl;

    try {
      await this.preloadImage(urlWithCacheBuster);
      const entry = await this.getPerformanceEntry(urlWithCacheBuster);
      const result = entry ? this.calculateMetrics(entry) : await this.manualTimeMeasurement(urlWithCacheBuster);
      this.measurements.set(imageUrl, result);
      return result;
    } catch (error) {
      console.error(`画像の測定中に問題が発生しました: ${imageUrl}`, error);
      return { totalLoadTime: 0, error: (error as Error).message };
    }
  }

  /**
   * URL にキャッシュバスターを追加する
   * @param url - 元の URL
   * @returns キャッシュバスターが追加された URL
   */
  private addCacheBuster(url: string): string {
    const separator = url.includes('?') ? '&' : '?';
    return `${url}${separator}cacheBuster=${Date.now()}`;
  }

  /**
   * 画像をプリロードする
   * @param url - プリロードする画像の URL
   * @returns プリロード完了の Promise
   */
  private preloadImage(url: string): Promise<void> {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => {
        console.log(`画像の読み込みが完了しました: ${url}`);
        resolve();
      };
      img.onerror = (e) => {
        console.error(`画像の読み込みに失敗しました: ${url}`, e);
        resolve();
      };
      img.src = url;
    });
  }

  /**
   * パフォーマンスエントリを取得する
   * @param url - エントリを取得する画像の URL
   * @returns パフォーマンスエントリの Promise
   */
  private async getPerformanceEntry(url: string): Promise<PerformanceResourceTiming | null> {
    const startTime = performance.now();
    while (performance.now() - startTime < this.options.timeout) {
      const entry = this.performanceEntries.get(url) || performance.getEntriesByName(url)[0] as PerformanceResourceTiming;
      if (entry) return entry;
      await this.delay(100);
    }
    console.log(`${url} の詳細な読み込み情報を取得できませんでした。基本的な測定結果のみ使用します。`);
    return null;
  }

  /**
   * 指定したミリ秒だけ待機する
   * @param ms - 待機するミリ秒
   * @returns 待機完了の Promise
   */
  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  /**
   * 手動で画像の読み込み時間を測定する
   * @param imageUrl - 測定する画像の URL
   * @returns 測定結果の Promise
   */
  private async manualTimeMeasurement(imageUrl: string): Promise<PerformanceMetrics> {
    const startTime = performance.now();
    try {
      await this.preloadImage(imageUrl);
      const endTime = performance.now();
      const result: PerformanceMetrics = {
        totalLoadTime: endTime - startTime,
        manualMeasurement: true
      };
      console.log('基本的な測定結果:', this.formatMetrics(result));
      return result;
    } catch (error) {
      console.error('画像の読み込み時間の測定中に問題が発生しました:', error);
      return {
        totalLoadTime: 0,
        error: '画像の読み込みに失敗しました',
        manualMeasurement: true
      };
    }
  }

  /**
   * パフォーマンスエントリからメトリクスを計算する
   * @param resourceTiming - PerformanceResourceTiming オブジェクト
   * @returns 計算されたパフォーマンスメトリクス
   */
  private calculateMetrics(resourceTiming: PerformanceResourceTiming): PerformanceMetrics {
    return {
      totalLoadTime: resourceTiming.responseEnd - resourceTiming.startTime,
      dnsLookupTime: resourceTiming.domainLookupEnd - resourceTiming.domainLookupStart,
      connectionTime: resourceTiming.connectEnd - resourceTiming.connectStart,
      ttfb: resourceTiming.responseStart - resourceTiming.requestStart,
      downloadTime: resourceTiming.responseEnd - resourceTiming.responseStart,
      performanceEntry: true
    };
  }

  /**
   * メトリクスを読みやすい形式にフォーマットする
   * @param metrics - フォーマットするメトリクス
   * @returns フォーマットされたメトリクス
   */
  private formatMetrics(metrics: PerformanceMetrics): Record<string, string | boolean> {
    return Object.entries(metrics).reduce((acc, [key, value]) => {
      acc[key] = typeof value === 'number' ? `${value.toFixed(2)}ミリ秒` : value;
      return acc;
    }, {} as Record<string, string | boolean>);
  }

  /**
   * メトリクスをログに出力する
   * @param url - 測定した画像の URL
   * @param metrics - ログ出力するメトリクス
   */
  private logMetrics(url: string, metrics: PerformanceMetrics): void {
    console.log(`${url} の読み込み速度測定結果:`, this.formatMetrics(metrics));
  }

  /**
   * 複数の画像のパフォーマンスを測定する
   * @param imageUrls - 測定する画像 URL の配列
   * @returns 測定結果の Map の Promise
   */
  async measureMultipleImages(imageUrls: string[]): Promise<Map<string, PerformanceMetrics>> {
    console.log(`${imageUrls.length}枚の画像の読み込み速度を測定します。`);
    const chunks = this.chunkArray(imageUrls, this.options.concurrency);
    const results = new Map<string, PerformanceMetrics>();

    for (const [index, chunk] of chunks.entries()) {
      console.log(`${chunk.length}枚の画像を同時に測定します(${index + 1}組目/${chunks.length}組)`);
      const chunkResults = await Promise.all(chunk.map(this.measureImagePerformance.bind(this)));
      chunkResults.forEach((result, i) => {
        const url = chunk[i];
        results.set(url, result);
        this.logMetrics(url, result);
      });
    }

    return results;
  }

  /**
   * 配列を指定サイズのチャンクに分割する
   * @param array - 分割する配列
   * @param size - チャンクのサイズ
   * @returns 分割されたチャンクの配列
   */
  private chunkArray<T>(array: T[], size: number): T[][] {
    return Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
      array.slice(i * size, i * size + size)
    );
  }
}

export default SourcePerformance;


// 使用方法
// const performance = new SourcePerformance({
//   timeout: 10000,      // タイムアウトを10秒に設定
//   concurrency: 2,      // 同時に2つの画像を処理
//   bypassCache: true    // キャッシュを削除
// });

// // 測定したい画像URLのリスト
// const imageUrls = [
//   '/assets/images/large-image.jpg',
//   '/assets/images/large-image.webp',
// ];

処理時間計測

Performance.ts
/**
 * パフォーマンス測定を行うクラス。
 * 指定された関数の実行時間を測定し、結果を記録・表示する機能を提供します。
 * @template T 測定対象の関数の戻り値の型
 */
class Performance<T> {
  private name: string;
  private measurements: number[] = [];

  /**
   * Performance クラスのインスタンスを作成します。
   * @param {string} name 測定対象の名前または説明
   */
  constructor(name: string) {
    this.name = name;
  }

  /**
   * 指定された関数のパフォーマンスを測定します。
   * @param {() => T} fn 測定対象の関数
   * @param {number} [iterations=1] 測定を繰り返す回数
   * @returns {T} 測定対象の関数の最後の実行結果
   */
  measure(fn: () => T, iterations: number = 1): T {
    let lastResult: T;
    for (let i = 0; i < iterations; i++) {
      const start = performance.now();
      lastResult = fn();
      const end = performance.now();
      this.measurements.push(end - start);
    }
    return lastResult!;
  }

  /**
   * 測定結果をコンソールに出力します。
   * 平均実行時間、最短実行時間、最長実行時間を表示します。
   */
  printResults(): void {
    if (this.measurements.length === 0) {
      console.log(`"${this.name}" の測定結果はありません。`);
      return;
    }

    const average = this.measurements.reduce((acc, time) => acc + time, 0) / this.measurements.length;
    const min = Math.min(...this.measurements);
    const max = Math.max(...this.measurements);

    console.log(`"${this.name}" のパフォーマンス測定結果:`);
    console.log(`  測定回数: ${this.measurements.length}`);
    console.log(`  平均実行時間: ${average.toFixed(2)}ミリ秒`);
    console.log(`  最短実行時間: ${min.toFixed(2)}ミリ秒`);
    console.log(`  最長実行時間: ${max.toFixed(2)}ミリ秒`);
  }

  /**
   * 保存されている測定結果をクリアします。
   */
  clearMeasurements(): void {
    this.measurements = [];
  }
}

export default Performance;

/**
 * Performance クラスの使用例:
 * 
 * // 1. Performance クラスのインスタンスを作成
 * const primePerformance = new Performance("素数生成");
 * 
 * // 2. 測定対象の関数を定義
 * function generatePrimes(count: number): number[] {
 *   // 素数生成のロジック(省略)
 * }
 * 
 * // 3. パフォーマンスを測定(5回繰り返し)
 * primePerformance.measure(() => generatePrimes(10000), 5);
 * 
 * // 4. 結果を出力
 * primePerformance.printResults();
 * 
 * // 結果の例:
 * // "素数生成" のパフォーマンス測定結果:
 * //   測定回数: 5回
 * //   平均実行時間: 25.60ミリ秒
 * //   最短実行時間: 21.80ミリ秒
 * //   最長実行時間: 29.20ミリ秒
 * 
 * // 5. 測定結果をクリア(必要な場合)
 * primePerformance.clearMeasurements();
 */

活用例

ScrollObserver + ScrollFunction

JavaScript
//活用例
//ID:target1,target2をつけた要素を用意
//target1は500pxスクロールする間にtransformX 300px => 1000px
//target2は500pxスクロールする間にtransformX 100px => 500px,scale(1) => 2
//要素が画面内にある時のみ処理を実行
//fps制御,スクロールイベントではなくrAFで制御,rAFはスクロールしている時のみループ

//requestAnimationFrameをリセットするためのIDを格納
let frameId,frameId2;
//直前のスクロール位置を格納
let lastPosition = -1;
//フレームレート設定
const framesPerSecond = 60;
const interval = Math.floor(1000 / framesPerSecond);
const startTime = performance.now();
let previousTime = startTime;
let currentTime = 0;
let elapsed = 0;

//ScrollFunctionの引数定義
const target1 = document.getElementById('target1');
const target2 = document.getElementById('target2');
const scrollMax = 500;
const rootMargin = -200;//画面下から100pxの位置に要素が来たらアニメーション開始
const ease = function easeInOutBack(x) {
  const c1 = 1.70158;
  const c2 = c1 * 1.525;

  return x < 0.5
    ? (Math.pow(2 * x, 2) * ((c2 + 1) * 2 * x - c2)) / 2
    : (Math.pow(2 * x - 2, 2) * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2;
};


const cb = function (el, isIntersecting) {
  const sf = new ScrollFunction(target1, 500,rootMargin, ease, 300, 1000);
  if (isIntersecting) {
    //要素が画面内の時の処理
    const raf = (timestamp) => {
      //スクロール位置に変化があった時のみループを実行
      if (lastPosition === sf.scrollY) {
        frameId = requestAnimationFrame(raf);
        return;
      }
      lastPosition = sf.scrollY;

      //fps制御
      currentTime = timestamp;
      if (currentTime) {
        elapsed = currentTime - previousTime;
      }
      if (elapsed <= interval) {
        frameId = requestAnimationFrame(raf);
        return;
      }
      previousTime = currentTime - (elapsed % interval);

      //ここからアニメーションさせたいコード書く
      target1.setAttribute(
        "style",
        `transform:translateX(${sf.resultEase}px)`
      );
      frameId = requestAnimationFrame(raf);
    };
    raf();
  } else {
    elapsed = 0;
    cancelAnimationFrame(frameId);
  }
};

const cb2 = function (el, isIntersecting) {
  const sf2 = new ScrollFunction(target2, 300, rootMargin, ease, 100, 500);
  const sf3 = new ScrollFunction(target2, 300, rootMargin, ease, 1, 3);
  if (isIntersecting) {
    //要素が画面内の時の処理
    const raf = (timestamp) => {
      //スクロール位置に変化があった時のみループを実行
      if (lastPosition === sf2.scrollY) {
        frameId2 = requestAnimationFrame(raf);
        return;
      }
      lastPosition = sf2.scrollY;

      //fps制御
      currentTime = timestamp;
      if (currentTime) {
        elapsed = currentTime - previousTime;
      }
      if (elapsed <= interval) {
        frameId2 = requestAnimationFrame(raf);
        return;
      }
      previousTime = currentTime - (elapsed % interval);

      //ここからアニメーションさせたいコード書く
      target2.setAttribute(
        "style",
        `transform:translateX(${sf2.resultNormal}px) scale(${sf3.resultNormal})`
      );
      frameId2 = requestAnimationFrame(raf);
    };
    raf();
  } else {
    elapsed = 0;
    cancelAnimationFrame(frameId2);
  }
};

const so = new ScrollObserver(
  document.querySelectorAll(".target"),
  cb,
  `0px 0px ${rootMargin}px 0px`,
  { once: false }
);
const so2 = new ScrollObserver(
  document.querySelectorAll(".target2"),
  cb2,
  `0px 0px ${rootMargin}px 0px`,
  { once: false }
);

課題

  • ScrollObserberのcbで画面内にある時はrAFを回して、画面外でrAFを止める処理をしているが
    複数の要素を同時に監視した時、一つの要素が最初から画面内にあってその他が画面外にあるという条件下では
    isIntersectingがtrue->falseとなるため、最初から画面内の要素のcbが実行されずにcancelされてしまう。
  • 上記理由より、動かしたい要素が複数ある場合は、別々にScrollObserverで監視する必要があり
    その度に、cbとcancel用のid格納変数を用意する必要がある。

Discussion