캐싱 및 리플로우 여부에 따른 렌더링 성능 시뮬레이터 만들기 (Batch DOM Manipulation)

ChoiYongHyeun·2024년 2월 12일
0

망가뜨린 장난감들

목록 보기
14/19
post-thumbnail

완성본 페이지
전체 코드 링크

미리보는 성능 비교

우와!코드펜으로 공유도 되는구나 !!

프로젝트를 시작한 이유

설 연휴동안 브라우저 렌더링 관련 공부를 엄청나게 했다.

설 연휴간 공부한 목록

브라우저는 어떻게 렌더링 되는가 (DOM , CSSOM , Render Tree)
Reflow 와 Repaint - 성능 최적화
requestAnimationFrame , 실험과 폴리필을 통해 살펴보기
브라우저 렌더링 파이프라인 딥다이브

왜냐면 최적화 하는 방법에 대한 개념을 미리 공부해놓는다면

새로운 것을 공부 할 때 만들 때 부터 최적화가 가능한 코드에 대한 고민을 할 수 있을 것 같았기 때문이다.

어떻게 렌더링 되는지에 대한 깊은 이해가 없으니 최적화를 어떻게 할지에 대한 생각조차 떠오르지 않더라

사실 아직 가벼운 사이드프로젝트들만 해봤을 뿐이지만 말이다. 😂

이런식으로 공부하고 구글 개발자 도구에서 시뮬레이터로 제공하는 사이트가 있어

와 ! 바로 이거지 ~~ 이러고 코드를 살펴봤었다.

도대체 Optimize 버튼에 등록된 이벤트 핸들러는 얼마나 대단한 기능이 있어서 이런걸 가능하게 할까 ? 하는 궁금증과 함께 말이다.

전체 코드는 위 사이트의 개발자도구에서 network -> app.js 에서 볼 수 있다.
이번 게시글에서는 모든 코드를 리뷰하기 보다 움직이는 부분만 가볍게 다루도록 하겠다.

movers 배열은 각 움직이는 노드들을 담은 배열이다 (document.querySelectAll 을 이용했더라)

  app.update = function (timestamp) {
    for (var i = 0; i < app.count; i++) {
      var m = movers[i];
      if (!app.optimize) {
        // Un-optimize 일 때는 offsetTop 프로퍼티로 TOP 위치를 불러와 
        // 지연평가를 불가능하게 함 
        var pos = m.classList.contains('down') ?
            m.offsetTop + distance : m.offsetTop - distance;
        if (pos < 0) pos = 0;
        if (pos > maxHeight) pos = maxHeight;
        m.style.top = pos + 'px';
        if (m.offsetTop === 0) {
          m.classList.remove('up');
          m.classList.add('down');
        }
        if (m.offsetTop === maxHeight) {
          m.classList.remove('down');
          m.classList.add('up');
        }
      } else {
        // Optimize 일 때는 현재의 offsetTop 을 ComputedStyle의 프로퍼티에 직접 접근해 
        // reflow를 일으키지 않고 지연평가를 가능하게 함  
        var pos = parseInt(m.style.top.slice(0, m.style.top.indexOf('px')));
        m.classList.contains('down') ? pos += distance : pos -= distance;
        if (pos < 0) pos = 0;
        if (pos > maxHeight) pos = maxHeight;
        m.style.top = pos + 'px';
        if (pos === 0) {
          m.classList.remove('up');
          m.classList.add('down');
        }
        if (pos === maxHeight) {
          m.classList.remove('down');
          m.classList.add('up');
        }
      }
    }
    frame = window.requestAnimationFrame(app.update);
  }

Optimize 를 설정했을 때

Optimize 를 설정하지 않았을 때

처음 나는 페이지를 보고 아 ~ Un-Optimize 일 때는 top 을 이용해서 애니메이션을 구현하고

Optimize 일 때는 translateY 를 이용했나보구나 이랬는데

그것이 아니라 단순히 브라우저 렌더링 엔진의 지연평가를 이용한 것 뿐이였다.

두 방법 모두 top 을 이용해서 애니메이션이 구현되어 있다.

지연평가란 블록문 내에서 리플로우를 일으키는 코드가 반복될 때, 리플로우를 반복 횟수만큼 실행하는 것이 아니라, 리플로우의 결과값을 블록문내에서 조회하지 않는 경우 리플로우를 지연해뒀다가 블록문이 종료되면 단 한번의 리플로우로 실행하는 것을 의미한다.

Optimize 일 때는 offsetTop 과 같이 평가의 결과값을 조회하지 않고 있기 때문에 toppx 을 변화시키는 행위들을 최대한 지연하다가 블록문이 모두 종료되면 한 번에 처리하는 것이 가능했다.

내가 알고 싶었던 것은 top 을 이용할 때와 transform 을 이용했을 때 GPU 가속 여부에 따른 렌더링 성능이였다고 ~!!!

그리고 어차피 지연평가 할거면 그냥 현재의 top 위치를 다른 자료구조에 캐싱만 해두면 optimize 일 때랑 똑같은거 아냐 ?!?!?!!?!?!?!

하는 분노가 찼었다.

그!래!서!

말 나온김에 내가 궁금해하는 내용들을 직접 구현해보기로 했다.

4가지 경우의 수를 가지고 테스트 해보자

  • 노드의 위치를 캐싱하지 않고 offsetTop 으로 계산 후 top 속성을 변경하기
  • 노드의 위치를 캐싱해두고 캐싱한 자료를 이용한 후 top 속성을 변경하기
  • 노드의 위치를 캐싱하지 않고 offsetTop 으로 계산 후 translateY 속성을 변경하기
  • 노드의 위치를 캐싱해두고 캐싱한 자료를 이용한 후 translateY 속성을 변경하기

애니메이션을 위해 필요한 기능

  • 사용할 이미지

나는 귀엽게 이 뻐끔거리는 고양이를 이용하기로 했다.

뻐끔뻐끔

  • 초기 렌더링 위치

해당 이미지를 브라우저에 렌더링 할 때 수평적으로, 수직적으로 렌더링 될 위치를 계산해주어야 했다.

그래서 수평적인 위치는 렌더링 할 이미지 개수 /뷰포트의 너비 를 기준으로 Math.random 을 이용해 조금의 무작위성을 주었고

수직적인 위치는 뷰포트의 높이 * Math.random() 을 이용해 무작위적으로 렌더링 하도록 하였다.

  • 애니메이션에서 사용할 기능

내가 궁금했던 것은 top 을 이용한 애니메이션 때와 transform 을 이용할 때의 애니메이션이 궁금했던 것이니

애니메이션 기능에 넣을 때 top 으로 할 때와 transform 을 이용해야 할 것이다.

그리고 현재의 위치들을 캐싱해둔다면 더 렌더링 성능이 좋아지지 않을까 ? 라는 생각이 들었으니

캐싱 여부에 따른 top , transform 애니메이션 을 구현해야 겠다.


Cat 객체 생성

export default class Cat {
  constructor(locationX) {
    this.root = document.querySelector('#root');
    this.maxHeight = document.querySelector('#root').clientHeight;
    this.imgSize = 50;
    this.caching = {};
    this.createCat(locationX);
    this.render();
  }

  /**
   * This function creates a node that moves up and down within the viewport.
   * The node's class and locationX are randomly choosen.
   * All location infromation is cached.
   * @param {Number} locationX - Indicates the horizontal X coordinate where the node will be placed.
   */
  createCat(locationX) {
    const { maxHeight, imgSize, caching } = this;
    const movingState = Math.random() > 0.5 ? 'up' : 'down';
    const node = document.createElement('img');
    const locationY = Math.max(Math.random() * maxHeight - imgSize, 0);

    node.src = 'cat.gif';
    node.className = `cat ${movingState}`;
    node.style.cssText = `left: ${locationX}px; top: ${locationY}px; transform : translateY(0px);`;

    caching.curTop = locationY;
    caching.curTranslateY = 0;
    caching.curLocation = locationY - 0;

    this.node = node;
  }

  render() {
    const { node, root } = this;
    root.appendChild(node);
  }

  /**
   * Parsing the style.transform text to calculate the translateY value
   * @constant {number} start - index of character follwing '('
   * @constant {number} end - index of character preceding 'px'
   * @returns {number} - The current translateY value of the node.
   */
  translateParsing = () => {
    const { node } = this;
    const translateText = node.style.transform;
    const start = translateText.indexOf('(') + 1;
    const end = translateText.indexOf('px');
    return parseInt(translateText.slice(start, end));
  };

  /**
   *
   * @param {boolean} isCaching - Indicates whether to use cached location values
   * @returns {Object} - infromation of the location of the node.
   * @property {number} curTop - The current top position of the node.
   * @property {number} curTranslateY - The current translateY value of the node.
   * @property {number} curLocation - The current calculated location of the node
   *
   */
  getLocation = (isCaching) => {
    const { node, caching } = this;
    const { translateParsing } = this;
    const amountTranslate = translateParsing();

    if (isCaching) return caching;
    return {
      curTop: node.offsetTop,
      curTranslateY: amountTranslate,
      curLocation: node.offsetTop + amountTranslate,
    };
  };
  /**
   * Calculate the offset to going next step depending on whether it is cached or not
   * offset is the distance to move depending on the current location
   * if the distnace after moving by offset is beyond the viewport , the offset is adjusted to the
   * distance from the current position to viewport.
   * @param {boolean} isCaching - Indicates whether caching for animation if enabled.
   * @returns {number} - Indicates the distance the node will move next.
   */
  calcaulateOffset = (isGoingUp, isCaching) => {
    const { maxHeight, imgSize } = this;
    const { getLocation } = this;
    const { curLocation } = getLocation(isCaching);
    const offset = isGoingUp ? -3 : 3;
    const nextLocation = curLocation + offset;

    if (isGoingUp && nextLocation <= 0) return -curLocation;
    if (!isGoingUp && nextLocation >= maxHeight - imgSize)
      return maxHeight - imgSize - curLocation;

    return offset;
  };

  /**
   * function to change the node's class based on the next direction to move,
   * wheter the next direction to go is up or down
   * this is depending on nextLocation is beyond the viewport
   * @param {boolean} isGoingUp - Indicates the node's class contains up
   * @param {boolean} nextLocation - Indicates the location where the next node will be placed
   */
  changeState = (isGoingUp, nextLocation) => {
    const { node, maxHeight, imgSize } = this;
    if (nextLocation !== 0 && nextLocation !== maxHeight - imgSize) return;

    if (isGoingUp) {
      node.classList.remove('up');
      node.classList.add('down');
    } else {
      node.classList.remove('down');
      node.classList.add('up');
    }
  };

  /**
   * This function update the caching data using Object spread syntax
   * @param {Object} newData - The Object used to update the caching.
   */
  updateCache = (newData) => {
    this.caching = { ...this.caching, ...newData };
  };

  /**
   * This function makes the node movable within the viewport.
   * This calculates the offset indicating next step using calculateOffset function ,
   * changes the style of node depending on isTranslate.
   * and this function updates caching data if isCaching is true
   * @param {Object} optimizeState
   * @property {boolean} isCaching - Indicates whether to use cached location values
   * @property {boolean} isTranslate - Indicates whether to use translateY or not
   */
  move = (optimizeState) => {
    const { isCaching, isTranslate } = optimizeState;
    const { node } = this;
    const { getLocation, calcaulateOffset, changeState, updateCache } = this;
    const isGoingUp = node.classList.contains('up');
    let { curTop, curTranslateY, curLocation } = getLocation(isCaching);
    const offset = calcaulateOffset(isGoingUp, isCaching);
    const nextLocation = curLocation + offset;

    if (isTranslate) {
      node.style.transform = `translateY(${curTranslateY + offset}px)`;
      curTranslateY += offset;
    } else {
      node.style.top = `${curTop + offset}px`;
      curTop += offset;
    }
    curLocation += offset;

    updateCache({ curTop, curTranslateY, curLocation });
    changeState(isGoingUp, nextLocation);
  };
}

와우 ! 이번에 처음으로 JSDOC 을 이용해봤는데 되게 깔끔한 것 같다.

아직 타입스크립트를 배우지 않아서 저런 기능들이 매우 필요했는데 가뭄의 단비처럼 너무 반가웠다.

타입스크립트만큼의 강제성은 존재하지 않더라도 클래스에서 여러 함수들을 사용 할 때 도움이 많이 되었다.

요즘 영어를 잘하고 싶어서 팟캐스트를 듣고 있는데, 기왕 하는거 영어로 JSDOC 도 작성해보자 하고 해봤다.
내 짧은 영어로 먼저 만들어보고 , 챗지피티한테 물어보고 수정해달라고 했다.

전체 코드들을 설명하지는 않겠으나 가장 코어가 되는 로직은 다음과 같다.

  1. Cat 인스턴스들은 각자 초기 위치값인 locationX 에 따라 렌더링 되며 , 수직적인 위치는 뷰포트의 높이 안에서 랜덤하게 렌더링 된다.

  2. 인스턴스들은 각자 클래스로 up 혹은 down 만 가지고 있으며 클래스명을 이용해 객체가 위로 이동할지, 아래로 이동할지를 결정한다.

  3. 인스턴스가 다음 프레임에서 이동할 거리는 calculateOffset 함수를 통해 offset 을 계산하며 offset은 인스턴스가 뷰포트를 넘어가지 않을 정도로 조절한다.

  4. 계산된 offset 을 이용해 프레임별로 노드들이 렌더링 될 위치를 조절한다.

    3.1 이 때 애니메이션에 사용할 방법이 변경되었을 때를 대비하여 노드의 실제 위치를 계산 할 수 있는 getLocation 함수를 생성한다.

    top 을 사용하다가 transform 으로 변경했을 때 혹은 그 반대를 대비하여 노드의 위치를 가져올 수 있도록 해야 한다.

    offsetTop 프로퍼티는 ComputedStyle 에서 top 속성의 값만 가져오더라
    그래서 top 의 속성값과 translateY 의 속성값을 이용하여 현재 노드의 위치값을 따로 계산해줘야 한다.

    3.2 getLocation 메소드에서는 인수로 받은 isCaching 값에 따라 직접 계산하거나, 캐싱한 값을 가져오도록 한다.

  5. 노드 이동 방법은 인수로 받은 isTranslate 값에 따라 변경되며 top 속성을 변경하거나 translateY 를 이용하도록 한다.

  6. 인스턴스가 뷰포트의 상단이나 하단에 닿으면 클래스 명을 changeState 함수를 이용해 변경한다.

프로토타입 살펴보기

import Cat from './cat.js';

const $body = document.querySelector('body');
const maxWidth = $body.clientWidth;
const maxCats = 100;

const cats = [];
for (let index = 0; index < maxCats; index += 1) {
  const locationX = Math.random() * 10 + index * (maxWidth / maxCats);
  cats[index] = new Cat(locationX);
}

const catMoving = () => {
  cats.forEach((cat) => cat.move({ isCaching: true, isTranslate: false }));
  requestAnimationFrame(catMoving);
};
requestAnimationFrame(catMoving);

가볍게 다음과 같은 결과물이 나왔다 :)

물론 아직은 페이지를 로드 할 때 마다 렌더링 할 고양이의 개수를 지정해주고 isCaching , isTranslate 의 값을 변경해줘야 하지만

이벤트 핸들러가 등록된 버튼들을 추가해 동적으로 렌더링 하고 애니메이션 방법을 변경해주도록 하자


메인 페이지 컴포넌트 생성하기

HTML , CSS

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Moving Cat</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main id="root"></main>
  </body>
  <script src="main.js" type="module"></script>
</html>
* {
  padding: 0px;
  margin: 0px;
  user-select: none;
}

body {
  overflow: hidden;
}

#root {
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}

.cat {
  position: absolute;
  width: 50px;
  border-radius: 50%;
}

.button-wrapper {
  position: absolute;
  top: 0px;
  display: flex;
  flex-direction: column;
  gap: 30px;
  padding: 30px;
  z-index: 999;
}

button {
  padding: 10px;
  font-size: 30px;
  border: 10px solid white;
  border-radius: 20vh;
}

초기 프로토타입

저번에 isCaching , isTranslate 인수를 받아 이동하는 Cat 컴포넌트를 생성했으니

Cat 컴포넌트들을 관리 할 App 컴포넌트를 생성해주자

import Cat from './cat.js';

/**
 * Main application class representing the entire application.
 */
export default class App {
  constructor() {
    this.body = document.querySelector('body');
    this.delta = 10;
    this.init();
    this.setUp();
    this.render();
  }
  /**
   * This function creates component's initial state.
   */
  setUp() {
    this.state = {
      numCats: 10,
      isCaching: true,
      isTranslate: true,
      backgroundColor: '#f2cb05',
    };
  }

  /**
   * This function manages component's state.
   * If states were changed, It occurs re-rendering using changed state.
   * @param {object} newState - The new state to be merged with the current state.
   */
  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.render();
  }

  /**
   * Intilizes the appliceation.
   * It sets up the DOM structure and mounts event listeners on buttons.
   */
  init() {
    this.body.innerHTML = `
    <div id = 'root'></div>
    <div class="button-wrapper">
    <button class="add">add ${this.delta}</button>
    <button class="subtract">subtract ${this.delta}</button>
    <button class="uncaching notransalte" style = "background-color : #f2529d;">Un-caching top</button>
    <button class="caching notranslate" style = "background-color: #a99cd9;">Caching top</button>
    <button class="uncaching translate" style = "background-color: #05f2f2;">Un-caching translate</button>
    <button class="caching translate" style = "background-color: #f2cb05;">Caching translate</button>
  </div>
  `;
    this.root = document.querySelector('#root');
    this.mounted();
  }

  /**
   * This function is excuted after excuted init method,
   * It sets up event listeners for buttons.
   * @constant addButton - Increase numCats by delta
   * @constant subButton - Decrease numCats by delta,Deactivated when numCats becomes 10 or less.
   * @constant activateButtons - Array containing all buttons except add , substract button
   */
  mounted() {
    const allButtons = Array.from(document.querySelectorAll('button'));
    const [addButton, subButton] = allButtons.slice(0, 2);
    const activateButtons = allButtons.slice(2);

    addButton.addEventListener('click', () => {
      const numCats = this.state.numCats;
      this.setState({ numCats: numCats + this.delta });
    });

    subButton.addEventListener('click', () => {
      const numCats = this.state.numCats;
      if (numCats <= 10) return;
      this.setState({ numCats: numCats - this.delta });
    });

    activateButtons.forEach((button) => {
      button.addEventListener('click', ({ target }) => {
        const { backgroundColor } = target.style;
        const isCaching = target.classList.contains('caching');
        const isTranslate = target.classList.contains('translate');
        this.setState({ backgroundColor, isCaching, isTranslate });
      });
    });
  }

  /**
   * Creates a debounced version of a function ,
   * delaying its excution untill after a certain time period has elapsed since the last call.
   * @param {Function} callbackFn - The function to debounce.
   * @param {Number} delay - The delay in milliseconds before the debounced function is called after the last invocation.
   * @returns {Function} - A debounced version of the input function
   */
  debounce = (callbackFn, delay = 500) => {
    let timer;

    return (...args) => {
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        callbackFn(...args);
      }, delay);
    };
  };

  /**
   * This function render all Components on browser using debouncing.
   * Using debounce seperates state changes and rendering.
   */
  render() {
    this.debounce(console.log)(this.state);
  }
}

예전에 상태 관리를 이용하여 컴포넌트를 생성해보는 공부를 해봤으니

해당 방법을 이용해서 컴포넌트를 작성해보았다.

맨 처음 해당 컴포넌트가 실행되면 브라우저에 기본적인 버튼들이 렌더링 되도록 하였다.

아직 render 메소드는 완성된 것이 아니라 state 를 로그하기로만 하였다.

이벤트 핸들러들은 모두 직접적으로 DOM 노드에 접근하여 속성을 변경하게 하는 것이 아니라

컴포넌트의 state 를 변경하고 state 가 변경되면 변경된 state 를 이용하여 재 렌더링 하도록 하였다.

   this.state = {
      numCats: 10,
      isCaching: true,
      isTranslate: true,
      backgroundColor: '#f2cb05',
    };

관리하는 state 는 다음과 같이 생겼으며 add , subtract 버튼이 눌리면 numCats 가 변경되고

state 에서 Cat 인스턴스의 move 메소드에 필요한 인수인 isCaching ,isTranslate 는 메소드 별 버튼이 눌리면 변경되도록 하였다.

또 현재 이용중인 method 가 무엇인지 어떻게 표현할까 생각하다가 그냥 전체 backgroundColor 를 변경하자고 생각했다.

이번에 코드를 작성 할 때는 메소드 들에서 프로퍼티나 메소드를 불러올 때
{불러올 프로퍼티나 메소드}= this 이런식으로 디스트럭처링을 안하고 그냥 this 로 바로 사용해봤다.

예전에는 this 가 보기에 깔끔해보이지 않아서 디스트럭처링을 했었는데 코드 줄 수가 늘어나기도 하고 사용하고 있는 변수나 메소드가 인스턴스가 가지고 있는 것인지, 지역변수인 것인지에 대한게 명확하지 않아보였기 때문이다.

⭐ 상태 변경과 렌더링을 독립적으로 유지시키자

{
..
  setState(newState) {
    this.state = { ...this.state, ...newState };
    // 1. 상태가 변경될 때 마다 re rendering 시키는데
    this.render();
  }
..
  debounce = (callbackFn, delay = 500) => {
    let timer;
    return (...args) => {
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        callbackFn(...args);
      }, delay);
    };
  };
..
  render() {
    // 2. 렌더링은 상태가 변경될 때 마다 실행되는 것이 아니라 마지막 상태 변경일 때만 
    // 호출되도록 함 
    this.debounce(console.log)(this.state);
  }
}

이번 컴포넌트를 구성하면서 가장 염두에 뒀던 것은 state 가 변경될 때 마다 렌더링을 시키는 것이 아니라 state 의 변경과 re-rendering 을 독립적으로 유지시키는 것이였다.

수 많은 고양이들이 뷰포트를 수직적으로 움직이고 있는 상황에서 렌더링도 반복적으로 일어나면 자원을 비효율적으로 쓸 뿐더러 시간도 오래 걸릴 것 같았기 때문이다.

add , subtract 버튼들은 모두 10만큼 증가시키거나 감소시키는데

증가 버튼을 5번 누를 때 5번씩 새롭게 렌더링 되는 것 보다

마지막 버튼에서만 렌더링이 새롭게 되기를 원했다.

그래서 해당 기능을 구현하기 위해 debouncing 메소드를 클래스 내에서 정의해주고

render 자체를 debouncing 을 이용해서 구현해줬다.

🤔 그런데 debouncing 이 왜 안되지 ?

debounce 메소드를 잘 작성해준 것 같은데 막상 메소드들을 실행해주니

상태변경은 잘 되지만 debouncing 이 일어나지 않았다.

  debounce = (callbackFn, delay = 500) => {
    let timer;
    return (...args) => {
      if (timer) clearTimeout(timer); // 애초에 해당 조건을 만족하지 못하고 있음
      timer = setTimeout(() => {
        callbackFn(...args);
      }, delay);
    };
  };

  render() {
    this.debounce(console.log)(this.state);
  }

왜 그럴까 곰곰히 생각해봤는데 debounce 에서 반환하는 클로저 함수가 참조하고 있는 timer 의 실행 컨텍스트가 달라 생존주기가 짧기 때문이란 것을 깨달았다.

{
..
  setState(newState) {
    this.state = { ...this.state, ...newState };
    // 1. 상태가 변경되면 render 함수를 실행하도록 함 
    this.render();
  }
..
  debounce = (callbackFn, delay = 500) => {
    // 3. 지역변수 timer 생성
    let timer; // 4. timer = undefined
    return (...args) => {
      if (timer) clearTimeout(timer); // 5. undefined 이니 해당 조건문에 해당 안됨
      timer = setTimeout(() => { // 6. 비동기 함수로 콜백함수 실행
        callbackFn(...args);
      }, delay);
    };
  };
..
  render() {
    // 2. 렌더 함수가 실행되면서 debounce 메소드를 실행 시킴 
    this.debounce(console.log)(this.state);
  }
}

현재 timerdebounce 메소드 내부의 지역 변수로 존재하여

render 메소드가 한 번 호출되고 나면 debounce 내부의 지역변수인 timer 는 메모리 상에서 제거 된다.

debounce 자체가 메소드들이 공유하고 있는 하나의 변수를 통해

여기서는 timer , setTimeoutid 를 의미한다.

setTimeout 가 이미 존재하면 해당 타이머를 제거하고 본인의 타이머를 설정해야 하는데

현재 timer 라는 변수는 render 메소드 안에서 호출된 this.debounce(..) 함수의 실행 컨텍스트 내에 존재하니

각 타이머들이 서로의 타이머를 공유하지 못하고 , 자기 자신의 타이머만 참조했기에 이런 일이 발생했다.

이전에 호출한 timer 는 이전에 반환된 콜백함수에서만 참조 할 수 있다.

또한 본인이 호출된 실행 컨텍스트 내에 timer 변수가 이미 존재하니 상위 컨텍스트까지 보지 않는다.

🤩 timer 의 생존주기를 늘리자

그래서 ! 여러 콜백함수들이 참조 할 수 있도록 timer 의 생존주기를 늘리기 위해

debounce 내의 지역 변수가 아닌, 인스턴스의 프로퍼티로 변경해주었다.

그로 인해 새롭게 반환되는 debounced function 들이 모두 같은 timer 를 공유 할 수 있게 만들었다.

  constructor() {
    ..
    this.timer = null; // 1. 상위 컨텍스트로 옮겨줌
    ..
  }

  debounce = (callbackFn, delay = 500) => {
    return (...args) => {
      if (this.timer) clearTimeout(this.timer); // 2. 상위 컨텍스트의 timer 를 참조하도록 함
      console.log(this.timer)
      this.timer = setTimeout(() => {
        callbackFn(...args);
        this.timer = null;
      }, delay);
    };
  };
..
  render() {
    // 3. render 가 호출 될 때 마다 새롭게 반환되는 콜백함수들은 모두 같은 timer 를 참조한다.
    this.debounce(console.log)(this.state);
  }
}

야호 ~~~


기능 추가

Resize 에 따른 state , 이벤트 핸들러 추가

페이지가 최초로 렌더링 된 후 뷰포트가 렌더링 되어 너비와 높이가 달라지면

고양이들이 렌더링 되어야 할 위치와 이동할 거리가 변경되어야 하며 새롭게 렌더링 되어야 한다.

그렇기에 관리할 상태에 현재 뷰포트의 너비를 추가해주고

뷰포트의 너비가 변경되면 상태를 변경하도록 하자

	...
  setUp() {
    this.state = {
      maxWidth: this.root.clientWidth, // 1. 새로 관리할 state 추가 
      numCats: 10,
      isCaching: true,
      isTranslate: true,
      backgroundColor: '#f2cb05',
    };
  }
	...
  mounted() {
	...
    window.addEventListener('resize', () => { // 2. resize 일어나면 maxWidth 변경
      this.setState({ maxWidth: this.root.clientWidth });
    });
  }

변경된 상태 종류에 따라 다르게 행동할 필요가 있다.

  setUp() {
    this.state = {
      maxWidth: this.root.clientWidth,
      numCats: 10,
      isCaching: true,
      isTranslate: true,
      backgroundColor: '#f2cb05',
    };
  }

  setState(newState) {
    this.state = { ...this.state, ...newState };
    this.render();
  }

현재는 해당 state 중 어떤 프로퍼티가 변하든 상관없이 render 메소드를 실행한다.

아직 render 메소드를 제대로 구현하지 않았지만 render 메소드에서는 root 태그 내 모든 컴포넌트를 지우고 새로운 Cat 컴포넌트들을 추가 할 예정이다.

하지만 state 에서 maxWidth , numCats 가 변경되면 컴포넌트들을 모두 새롭게 렌더링 해 줄 필요가 있지만

isCaching , isTranslate , backgroundColor 가 변경된다고 해서 모든 컴포넌트들을 새로 그릴 필요가 없다.

그저 Cat.move() 에 들어가는 인수만 변경해서 새롭게 애니메이션을 구성하면 된다.

그러니 상태 변경에 따른 방식을 다르게 구현하자

..
  setState(newState) {
    this.state = { ...this.state, ...newState };

    if (newState.maxWidth || newState.numCats) this.render(); // 리렌더링이 필요할 때만 리렌더링
    else this.somethingFunc(); // Cat 컴포넌트들의 move 방식만 변경할 함수 
  } // somethingFunc 은 나중에 메소드를 만든 후 변경해주도록 하자 
..

render 메소드 변경

render 메소드가 할 일은 두 가지다.

  1. 새롭게 컴포넌트들을 렌더링 해야 하니 기존 렌더링 된 컴포넌트들을 모두 지우기
  2. 새로운 컴포넌트들을 렌더링 하기
..
  constructor() {
	..
    this.cats = []; // 새로운 프로퍼티 추가 
	..
  }
..
  /**
   * This function render all Components on browser using debouncing.
   * Using debounce seperates state changes and rendering.
   * Excuting the animation method stimulates the rendering of all components.
   * @constant callbackFn - callback Function will be debounced function.
   * @constant interval - interval between adjacent cats depending on maxWidth and numCats.
   */
  render() {
    const callbackFn = () => {
      const { numCats, maxWidth } = this.state;
      const interval = maxWidth / numCats;
      this.root.innerHTML = '';
      this.cats = []; // 컴포넌트를 관리할 프로퍼티 추가 
      for (let index = 0; index < numCats; index += 1) {
        const locationX = interval * index + Math.random() * 10;
        this.cats[index] = new Cat(locationX);
      }
    };

    this.debounce(callbackFn)();
    this.animation();

  }

그래서 해야 할 일을 callbackFn 이란 변수에 담아준 후 이전에 했던 debounce 메소드를 이용해 debounced function 으로 변경해주었다.

렌더링과 동시에 animation 이 작동하도록 해주었다.

잘 작동한다 야호 ~~

animation 기능 메소드 추가

..
  constructor() {
	..
    this.frame = undefined; // 새로운 프로퍼티 추가 
	..
  }
..
..
  setState(newState) {
    this.state = { ...this.state, ...newState };

    if (newState.maxWidth || newState.numCats) this.render();
    else this.animation(); // 1. 애니메이션 기능이 변경되면 animation 메소드 호출
  }
..
/**
   * This function excutes requestAnimationFrame with recurrsion.
   * change backgroundColor depending on the  currently pressed buttton.
   * @constant catMoving - Callback function that uses requestAnimationFrame , causing recurssion.
   */
  animation = () => {
    const { isCaching, isTranslate, backgroundColor } = this.state;
    this.root.style.backgroundColor = backgroundColor;
    this.cats.forEach((cat) => cat.move({ isCaching, isTranslate }));

    if (this.frame) cancelAnimationFrame(this.frame);
    this.frame = requestAnimationFrame(this.animation);
  };

재귀적으로 호출하면서 requnestAnimation 이 중첩적으로 쌓이는 것을 방지하기 위해

    if (this.frame) cancelAnimationFrame(this.frame);

이 부분을 추가해주었다.

만약 이 부분이 없었다면 여러번 누를 때 마다 requestAnimationFrame 이 중첩되면서

점점 빨라져용 ~~!!


최종 결과물

누르는 버튼에 따라 렌더링 방식을 다르게 하는 최종 결과물 페이지가 나왔다.

그럼 이제 개발자도구 -> performance 창을 보며 성능의 차이를 봐보자

분명하게 방법에 따라 렌더링 엔진의 활동 양상이 다른 모습을 볼 수 있다.

각 케이스 별 성능을 해석하기 전 가장 우선적으로 필요한 Batch DOM Manipulation 의 개념에 대해 먼저 짚고 가자

Batch DOM Manipulation

브라우저의 렌더링 엔진은 최대한 DOM 조작을 하나에 하나씩 하는 one by one 과정이 아닌

일괄적 (Batch) 으로 한 번에 DOM 조작을 하고자 한다.

이를 통해 렌더링 엔진은 최소한의 DOM 조작으로 더 효율적으러 리소스를 사용 할 수 있다.

Batch DOM Manipulation 의 과정

Cat.js 의 Cat 컴포넌트

  move = (optimizeState) => {
    const { isCaching, isTranslate } = optimizeState;
    const { node } = this;
    const { getLocation, calcaulateOffset, changeState, updateCache } = this;
    const isGoingUp = node.classList.contains('up');
    let { curTop, curTranslateY, curLocation } = getLocation(isCaching);
    const offset = calcaulateOffset(isGoingUp, isCaching);
    const nextLocation = curLocation + offset;

    // 인수로 받은 optimizeState 에 따라서 
    // Cat 인스턴스의 style attribute 를 변경한다
    if (isTranslate) {
      node.style.transform = `translateY(${curTranslateY + offset}px)`;
      curTranslateY += offset;
    } else {
      node.style.top = `${curTop + offset}px`;
      curTop += offset;
    }
    curLocation += offset;

    updateCache({ curTop, curTranslateY, curLocation });
    changeState(isGoingUp, nextLocation);
  };

App.js 의 App 컴포넌트

  animation = () => {
    const { isCaching, isTranslate, backgroundColor } = this.state;
    this.root.style.backgroundColor = backgroundColor;
    // Cat 인스턴스의 move 메소드를 호출하여 style attribute 를 변경한다.
    this.cats.forEach((cat) => cat.move({ isCaching, isTranslate }));

    if (this.frame) cancelAnimationFrame(this.frame);
    // 재귀적으로 move 메소드를 호출한다.
    this.frame = requestAnimationFrame(this.animation);
  };

어떤 렌더링 방법을 이용하든 결국에는 수 많은 Cat 인스턴스들의 CSS Attribute 를 수정하는 move 메소드를 재귀적으로 호출하는 해당 시뮬레이터를 실행할 때

Batch DOM Manipulation 이 어떻게 일어나는지 생각해보자

1. Collect DOM Operation

자바스크립트 엔진이 DOM 을 조작하는 DOM Operation 을 만나는 순간 즉각적으로 DOM Operation 을 시행하는 것이 아니라

Operation 들을 메모리에 저장한다.

이 때 저장하는 자료구조는 queue 형태로 먼저 들어온 Operation 이 먼저 실행 되도록 한다.

2. Apply changes in Bulk

DOM Operation 들이 충분히 쌓였거나 당장 DOM Operation 을 시행해야 하는 경우

성능 차이가 발생했던 것은 DOM Operation 을 당장 시행해야하는 경우때문에 발생했다.

자료구조에 있던 DOM Operation 들을 모두 일괄적으로 시행한다.

3. Minimize Reflow , Repaint

DOM Operation 들을 one by one 으로 하는 것이 아니라 일괄적으로 한 번에 시행하게 되니

reflow , repaintoperation 별로 일으키는 것이 아니라 한 번만의 reflow , repaint 로 진행 할 수 있으니 성능 향상을 도모 할 수 있다.

물론 일괄적으로 한 번에 진행하는 reflow , repaint 과정이 각 DOM operation 할 때 걸리는 reflow , repaint 시간보다 길 수 밖에 없으나 DOM Operation 의 각 과정을 모두 합한 것보다, 일괄적으로 시행하는 것이 더욱 빠르다.

DOM 을 조회하는 일도 한 번뿐이며 context swicth 에서 일어나는 delay 기간 또한 존재하지 않기 때문이다.


케이스 별 렌더링 과정 확인하기

Un-caching TOP

한 프레임을 구성하는데 걸린 시간

한 프레임을 구성하기 위해 렌더링 하는 시간이 138.63ms 가 걸렸다.

FPS 로 따지면 약 7FPS 도 되지 않는다.

이런일이 왜 발생했을까 ?

Batch DOM Manipulation 실행 여부 : ❌

하나의 Task 내부의 콜트리를 살펴보면 노드의 move 메소드가 실행될 때 내가 설정해둔 getLocation 메소드가 실행된다.

이 때 getLocation 메소드에서 isCachingfalse 인 경우에는 현재 자료의 위치를 offsetTop + trnaslateY(px) 값을 이용해 계산하도록 하였기 때문에

브라우저의 내장 함수인 get offsetTOP 이 실행된 모습을 볼 수 있다.

우선적으로 get offsetTOP 이 실행될 때 reflow 가 한 번 먼저 일어난다.

getLocation 이후 노드의 DOM Operation 이 호출되며 reflow 가 또 일어나는 모습을 볼 수 있다.

이러한 일들이 노드별로 지속적으로 반복된다.

노드별로 노드의 스타일 속성을 변경하는 Recalculate style -> layout 과정이 반복된다.

한 번의 layout 을 계산 할 때 마다 평균적으로 0.30ms 가 걸렸으니 layout 들이 모든 노드에 대해 일어나 한 프레임을 유발하는데 시간이 매우 오래 걸렸다.

??? : 위에서 브라우저 렌더링 엔진은 DOM Operationqueuing 해놨다가 일괄적으로 처리한다며

Batch DOM Manipulation 을 시행하지 못한 이유는 브라우저 엔진에게 노드의 현재 위치를 알아오라며

브라우저는 노드의 현재 위치를 알아오기 전 이전에 쌓인 DOM Operation 들이 존재한다면 해당 Operation 을 모두 시행한 후의 위치를 알려줘야 한다.

reflow 를 유발하는 DOM Operation 이 존재한다면 다른 노드들의 layout 도 변할 수 있기 때문이다.

이처럼 현재 노드의 Computed Style attribute 를 조회하는 행위는 즉각적인 DOM Manipulation 을 유발한다.

정리

Uncaching TOPUncaching 으로 인해 Batch DOM Manipulation 을 사용하지 못했기에 노드별로 one by one reflow 가 지속적으로 일어났다.

TOP 속성을 변경하는 행위 자체가 reflow 을 일으킨다

Caching TOP

한 프레임을 구성하는데 걸린 시간

하나의 프레임을 구성하는데 약 16ms60FPS 를 유지 할 수 있었다.

Batch DOM Manipulation 실행 여부 : ⭕

Caching TOP 에서도 getLocation 메소드가 실행되나 isCachingtrue 로 설정된 getLocation 메소드에선 offsetTOP 을 직접적으로 계산하는 것이 아닌

캐싱해둔 자료를 사용한다.

그로 인해 Batch DOM Manipulation 이 가능했으며 하나의 Task 에서 layout 이 단 한번만 일어나는 모습을 볼 수 있다.

여기서 포인트는

Un-caching TOP , Caching TOP 모두 TOP 속성을 변경하기에 reflow 가 일어나는데

Un-Caching TOPreflow 는 각 노드 하나의 평균 reflow 시간 0.3ms * 노드 수 였다면

Caching TOP 의 여러 노드의 reflow 를 일괄적으로 처리하여 reflow 를 하는데 고작 0.4ms 밖에 걸리지 않았다.

Un-Caching Translate


한 프레임을 구성하는데 걸린 시간

하나의 프레임을 만드는데 26.92ms 가 걸렸다.

프레임으로 따지면 35FPS 정도가 된다.

나는 translate 가 항상 top 속성을 변경하는 것보다 월등히 좋을줄 알았는데 아녔다.

Batch DOM Manipulation 실행 여부 : ❌

Un=Caching Translate 에서도 get OffsetTOP 이 시행되다보니

DOM Operation 을 즉각적으로 실행 할 수 밖에 없다.

그로인해 각 노드별로 translateY 속성값을 변화시키는 Recalculated Style 이 지속적으로 일어날 수 밖에 없다.

물론 Recalculated Style 과정은 layout 과정에 비해 매우 짧은 시간만 소비됐지만 말이다.

translate 를 이용했기 때문에 Recalculate Style 이후 layout 은 일어나지 않는 모습을 볼 수 있다.

Recalculate Style 이후 변형된 스타일들을 일괄적으로 렌더링 할 수 있도록 스케쥴링 하는 모습을 볼 수 있다. :)

렌더링을 일괄적으로 하도록 스케쥴링 하는것은 4가지 경우의 수 모두 동일했다.
렌더링은 모두 일괄적으로 마지막에 한 번 일어난다.
하지만 계산하는 과정도 일괄적으로 처리하느냐, 개별적으로 처리하느냐가 문제인 것이다.

Caching Translate

한 프레임을 구성하는데 걸린 시간

한 프레임을 구성하는데 걸린 시간은 약 16ms60FPS 를 유지한다.

Batch DOM Manipulation 실행 여부 : ⭕

Caching Translate 에서 getLocation 메소드를 실행 할 때 캐싱해둔 자료를 사용하기 때문에

Batch DOM Manipulation 이 가능하다.

그로 인해 한 Task 에서 한 번의 Recalculate Style , pre-paint , paint , commit 등의 과정이

일괄적으로 일어나는 모습을 볼 수 있다.

특징적인 모습은 Commit 시간이 다른 방법들에 비해서 긴 시간을 차지했다.

다른 경우의 수들은 모두 Commit 시간이 0.4ms 정도밖에 안되는데 Caching Translate 에서는 10ms 정도가 된다.

60FPS 를 유지하기 위해 Commit 에서 과정이 모두 종료 되어도 그 자리에서 대기하고 있기 때문인건지

일괄적으로 Commit 과정에서 레이어를 이동시키는 것이 오래걸렸기 때문인지는 잘 모르겠다.

어차피 어떤 과정이든 Commit 과정은 단 한번만 일어나기 때문에 꼭 Caching translate 라고 해서 Commit 과정이 오래 걸릴 이유는 없다고 생각하기 때문이다.


회고

후!하!

처음 프로젝트를 시작 할 때는

사실 translate 를 이용한것이 가장 성능이 좋고, 그 다음엔 캐싱 여부 , 그 다음엔 top 이 제일 구릴거야

왜냐면 topreflow 를 일으키고 translatepre-paint 과정 이후 GPU 에서 처리하니까~!! 이렇게 생각했었는데

그것보다 중요한 것은 Batch DOM Manipuliation 을 이용하느냐 마느냐였다.

4일간 모든걸 불태워따 ..

그리고 느낀점은 .. 스택 오버플로우는 모든 답을 알고 있다 ..,.,. ,

DOM Manipulation 에 대해 도움을 준 아티클들
https://gist.github.com/faressoft/36cdd64faae21ed22948b458e6bf04d5
https://stackoverflow.com/questions/37039667/executing-multiple-dom-updates-with-javascript-efficiently

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글