ReactSVG Class Component 리팩토링하기 1 - 최초 리팩토링

FGPRJS·2021년 11월 29일
0

이전까지 작성하였던 항목들의 테스트가 완료되었으므로, 리팩토링과 함께 코드를 정리한다.

코드는 현재 기능만 수행되지, 실제로 유효한 수준이 아니다.

실제로 코드를 유효한 수준으로 만듦과 동시에 이후에 수정하기 편하게 리팩토링한다.


현재 리팩토링의 목적

  • state를 적극 활용해보기.
    (React가 의도하는 바를 지키기 위함)

유의할 점

  • setState()는 비동기적이므로(내부 큐가 존재하고, 이 큐를 순회하는 방식), 실행즉시 변수가 변경되지 않는다.
    -> 실시간 애니메이션에 영향을 줄수도 있는가?

특이사항

의외로 다음과 같은 문법

  • 이벤트를 객체 내의 함수로 선언하고 참조하여 사용하는 방식
  • 상기 이벤트에서 this 키워드를 사용하기 위해 bind로 묶는 방식

은 기이하고 딱히 권장되는 방법이 아닐줄 알았지만, 공식 문서에서도 사용하는 방식이다.

svg.addEventListener('pointerdown', this.onPointerDown.bind(this));

//...중략

// Function called by the event listeners when user start pressing/touching
onPointerDown(event) {
  this.isPointerDown = true; // We set the pointer as down

  // We get the pointer position on click/touchdown so we can get the value once the user starts to drag
  var pointerPosition = getPointFromEvent(event);
  this.pointerOrigin.x = pointerPosition.x;
  this.pointerOrigin.y = pointerPosition.y;
}

물론 이것이 무조건 맞다고는 장담할 수 없다.
하지만 뾰족한 대안이 생각나지 않으므로 현재는 이 방식을 계속 유지한다.

단, var는 let으로 바꾼다.


첫번째 리팩토링 시도

다음은 기동 확인을 위한 리팩토링 시도이다.

import kr_map from './kr.svg'
import React from 'react'
import { ReactSVG } from 'react-svg'
import gsap from 'gsap/all';

//이 함수는 class와 아무 관계 없이 독립적이므로 독립시킨다.
function getPointFromEvent (event) {
    let point = {x:0, y:0};
    // If event is triggered by a touch event, we get the position of the first finger
    if (event.targetTouches) {
        point.x = event.targetTouches[0].clientX;
        point.y = event.targetTouches[0].clientY;
    } else {
        point.x = event.clientX;
        point.y = event.clientY;
    }
    
    return point;
}

function animatingViewBox(target, x, y, width, height){
    gsap.to(target, {
        duration: .5,
        attr: {viewBox: `${x} ${y} ${width} ${height}`},
        ease: "power3.inOut"
    });
}


export default class map extends React.Component {
    constructor(props){
        super(props);

        this.state = {
            current_viewbox : {
                x:0,
                y:0,
                width:1200,
                height:1080
            },
            new_viewbox : {
                x:0,
                y:0,
                width:1200,
                height:1080
            },
            pointerOrigin : {
                x:0,
                y:0
            },
            isPointerDown : false,
            svg : null
        }
    }

    // This function returns an object with X & Y values from the pointer event
    
    onPointerUp() {
        this.setState(prevState => ({
            // The pointer is no longer considered as down
            isPointerDown : false,
            // We save the viewBox coordinates based on the last pointer offsets
            current_viewbox : {
                x : prevState.new_viewbox.x,
                y : prevState.new_viewbox.y,
                width: prevState.current_viewbox.width,
                height: prevState.current_viewbox.height
            }
        }));
    }

    // Function called by the event listeners when user start pressing/touching
    onPointerDown(event) {
        let pointerPosition = getPointFromEvent(event);

        this.setState({
            isPointerDown : true, // We set the pointer as down
            pointerOrigin : {
            // We get the pointer position on click/touchdown so we can get the value once the user starts to drag
                x : pointerPosition.x,
                y : pointerPosition.y
            }
        });
    }

     // Function called by the event listeners when user start moving/dragging
    onPointerMove (event) {
        // Only run this function if the pointer is down
        if (!this.state.isPointerDown) {
            return;
        }
        // This prevent user to do a selection on the page
        event.preventDefault();

        // Get the pointer position
        let pointerPosition = getPointFromEvent(event);

        // We calculate the distance between the pointer origin and the current position
        // The viewBox x & y values must be calculated from the original values and the distances
        this.setState(prevState=> ({
            new_viewbox : {
                x: prevState.current_viewbox.x - (pointerPosition.x - prevState.pointerOrigin.x),
                y: prevState.current_viewbox.y - (pointerPosition.y - prevState.pointerOrigin.y)
            }
        }));

        
    }

    onZoom(event){

        if(event.deltaY > 0){
            this.setState(prevState => ({
                current_viewbox : {
                    width : prevState.current_viewbox.width / 9,
                    height: prevState.current_viewbox.height / 9
                }
            }))
        }
        else if(event.deltaY < 0){
            this.setState(prevState => ({
                current_viewbox : {
                    width : prevState.current_viewbox.width / 1.1,
                    height: prevState.current_viewbox.height / 1.1
                }
            }))
        }
    }

    onClick(event){
        if(event.target.getAttribute('name'))
        {
            let rect = event.target.getBoundingClientRect();

            this.setState({
                current_viewbox : {
                    x : rect.top,
                    y : rect.bottom,
                    width : rect.width,
                    height: rect.height
                }
            },
            () => {
                animatingViewBox(this.svg, this.current_viewbox.x, this.current_viewbox.y, this.current_viewbox.width, this.current_viewbox.height);
            })

        }
    }

    render(){
       return <ReactSVG
            beforeInjection = {(inject_svg) => {
                inject_svg.setAttribute('width',`${this.state.current_viewbox.width}`);
                inject_svg.setAttribute('height',`${this.state.current_viewbox.height}`);
                inject_svg.setAttribute('viewBox',`${this.state.current_viewbox.x} ${this.state.current_viewbox.y} ${this.state.current_viewbox.height} ${this.state.current_viewbox.height}`);

                if (window.PointerEvent) {
                    inject_svg.addEventListener('pointerdown', this.onPointerDown.bind(this)); // Pointer is pressed
                    inject_svg.addEventListener('pointerup', this.onPointerUp.bind(this)); // Releasing the pointer
                    inject_svg.addEventListener('pointerleave', this.onPointerUp.bind(this)); // Pointer gets out of the SVG area
                    inject_svg.addEventListener('pointermove', this.onPointerMove.bind(this)); // Pointer is moving
                    inject_svg.addEventListener('wheel', this.onZoom.bind(this));
                    inject_svg.addEventListener('click', this.onClick.bind(this));
                } else {
                    // Add all mouse events listeners fallback
                    inject_svg.addEventListener('mousedown', this.onPointerDown.bind(this)); // Pressing the mouse
                    inject_svg.addEventListener('mouseup', this.onPointerUp.bind(this)); // Releasing the mouse
                    inject_svg.addEventListener('mouseleave', this.onPointerUp.bind(this)); // Mouse gets out of the SVG area
                    inject_svg.addEventListener('mousemove', this.onPointerMove.bind(this)); // Mouse is moving

                    // Add all touch events listeners fallback
                    inject_svg.addEventListener('touchstart', this.onPointerDown.bind(this)); // Finger is touching the screen
                    inject_svg.addEventListener('touchend', this.onPointerUp.bind(this)); // Finger is no longer touching the screen
                    inject_svg.addEventListener('touchmove', this.onPointerMove.bind(this)); // Finger is moving
                }
                
                //this.setState({svg : inject_svg})
        }
        }

        afterInjection = {(error, svg) => {
            if (error) {
                console.error(error);
                return;
            }
            svg.classList.add('region');
        }
        }

        src = {kr_map}
       ></ReactSVG>
    }
}

아직 prevState에 대한 작업, 그리고 setState가 완료되었을때의 콜백의 this처리가 애매하여 오류가 날 코드이다. 하지만 예상과는 다른 오류가 발생하여 기재한다.

해당 코드에서 오류가 발생한다.

  162 | render(){
  163 |    return <ReactSVG
  164 |         beforeInjection = {(inject_svg) => {
> 165 |             this.setState({svg : inject_svg});
    

오류 내용은 다음과 같다

Error: Maximum update depth exceeded.
This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
React limits the number of nested updates to prevent infinite loops.

위 코드에서 setState 과정 중에componentWillUpdate/componentDidUpdate 코드가 반복되어 불러지는것으로 무한 회귀한다고 한다.

오류를 자세히 보면 다음과 같은 코드를 보여준다.

//ReactSVG.componentDidUpdate
//.../Projects/compiled/ReactSVG.js:114

  112 | componentDidUpdate(prevProps) {
  113 |     if (shallowDiffers(prevProps, this.props)) {
> 114 |         this.setState(() => this.initialState, () => {
  115 | ^           this.removeSVG();
  116 |             this.renderSVG();
  117 |         });

ReactSVG에 할당한것은 따로 ReactComponent로서 관리되고 있는 것이다.
(ReactSVG의 값이 바뀌면, 기존의 SVG를 삭제하고, 새로운 SVG를 렌더하는 과정이 기재됨)

현재 문제가 되는 것은,

this.setState({svg : inject_svg});

부문으로, 실제로 이를 삭제하면 무한회귀문제는 발생하지 않는다.

왜 이렇게 발생하는지 파악해본다.

this.setState(..) 함수는 다음으로 진입한다.

Component.prototype.setState = function (partialState, callback) {
  if (!(typeof partialState === 'object' || typeof partialState === 'function' || partialState == null)) {
    {
      throw Error( "setState(...): takes an object of state variables to update or a function which returns an object of state variables." );
    }
  }

  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

설정할 State가 object, function, 혹은 null이 아니면, Error.
이를 넘어가면 SetState할 수 있는 큐에 넘긴다.

enqueueSetState(...)내부는 다음과 같이 생겼다.

var classComponentUpdater = {
  isMounted: isMounted,
  //여기서 inst는 this이다.
  enqueueSetState: function (inst, payload, callback) {
    var fiber = get(inst);
    var eventTime = requestEventTime();
    var lane = requestUpdateLane(fiber);
    var update = createUpdate(eventTime, lane);
    update.payload = payload;

    if (callback !== undefined && callback !== null) {
      {
        warnOnInvalidCallback(callback, 'setState');
      }

      update.callback = callback;
    }

    enqueueUpdate(fiber, update);
    scheduleUpdateOnFiber(fiber, lane, eventTime);
  },
  
//후략

콜백등을 설정하고, update를 만든 후, enqueueUpdate한다.

enqueueUpdate함수 내부는 다음과 같다.

function enqueueUpdate(fiber, update) {
  var updateQueue = fiber.updateQueue;

  if (updateQueue === null) {
    // Only occurs if the fiber has been unmounted.
    return;
  }

  var sharedQueue = updateQueue.shared;
  var pending = sharedQueue.pending;

  if (pending === null) {
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    update.next = pending.next;
    pending.next = update;
  }

  sharedQueue.pending = update;

  {
    if (currentlyProcessingQueue === sharedQueue && !didWarnUpdateInsideUpdate) {
      error('An update (setState, replaceState, or forceUpdate) was scheduled ' + 'from inside an update function. Update functions should be pure, ' + 'with zero side-effects. Consider using componentDidUpdate or a ' + 'callback.');

      didWarnUpdateInsideUpdate = true;
    }
  }
}

updateQueue에는 shared인 부분이 있고,
이 shared한 Queue가 pending중인지 확인하는 부문이 있다.
pending중인게 없다면, update의 next를 즉시 방금 큐로 넣고
pending중이라면, pending의 대기열에 선다.

이 과정 자체에서는 딱히 반복될것이 없고, 중요한 것은 그 큐의 내용으로 보인다.


상기 링크에서는 다음과 같은 코드를 제시한다.

inputDigit(digit){
  this.setState({
    displayValue: String(digit)
  })

<button type="button" onClick={this.inputDigit(0)}>0</button>;

상기 코드는 아까와 같은 무한회귀 오류를 발생시킨다고 한다.

그 답변으로 다음과 같은 글이 있었다.

The function onDigit sets the state, which causes a rerender, which causes onDigit to fire because that’s the value you’re setting as onClick which causes the state to be set which causes a rerender, which causes onDigit to fire because that’s the value you’re… Etc
(번역)
onDigit 기능은 상태를 설정하여 다시 렌더링을 발생시키며, 이 값이 onClick으로 설정하기 때문에 onDigit가 실행됩니다. 이하 반복.

값을 바꾸면 밑의 button 컴포넌트가 다시 render된다. 그것때문에 다시 button컴포넌트 안에 있는 onClick을 정의하기 위해 inputDigit을 발동시킨다. 그것이 다시 setState로 값을 바꾸면 다시 render된다. 이하 무한반복.

다음과 같은 질의응답이 있었다.

요약하면, render중에 setState를 호출하지 말라는 이야기이다.

궁극적으로는, 상기 코드는 건너건너 재순환한다.

  1. 컴포넌트는 ReactSVG꼴이며, 이것을 render시도하고 있다.
  2. render시도중에 svg는 setState를 통하여 svg를 beforeInjection의 함수 내부에서 정의하려고 한다.
  3. 2번의 시도로 setState가 호출되었으므로 render는 다시 일어난다.
  4. 이하 무한 반복

따라서, svg는 state이면 안되고, setStatus()로 호출하면 안된다는 이야기이다.
svg는 단순한 내부 변수로서만 존재해야 한다.


두번째 리팩토링

import kr_map from './kr.svg'
import React from 'react'
import { ReactSVG } from 'react-svg'
import gsap from 'gsap/all';

//이 함수는 class와 아무 관계 없이 독립적이므로 독립시킨다.
function getPointFromEvent (event) {
    let point = {x:0, y:0};
    // If event is triggered by a touch event, we get the position of the first finger
    if (event.targetTouches) {
        point.x = event.targetTouches[0].clientX;
        point.y = event.targetTouches[0].clientY;
    } else {
        point.x = event.clientX;
        point.y = event.clientY;
    }
    
    return point;
}

function animatingViewBox(target, x, y, width, height){
    gsap.to(target, {
        duration: .5,
        attr: {viewBox: `${x} ${y} ${width} ${height}`},
        ease: "power3.inOut"
    });
}


export default class map extends React.Component {
    constructor(props){
        super(props);

        this.svg = null

        this.state = {
            current_viewbox : {
                x:0,
                y:0,
                width:1200,
                height:1080
            },
            new_viewbox : {
                x:0,
                y:0,
                width:1200,
                height:1080
            },
            pointerOrigin : {
                x:0,
                y:0
            },
            isPointerDown : false,
        }
    }


    // This function returns an object with X & Y values from the pointer event
    
    onPointerUp() {
        this.setState(prevState => ({
            // The pointer is no longer considered as down
            isPointerDown : false,
            // We save the viewBox coordinates based on the last pointer offsets
            current_viewbox : {
                x : prevState.new_viewbox.x,
                y : prevState.new_viewbox.y,
                width: prevState.current_viewbox.width,
                height: prevState.current_viewbox.height
            }
        }));
    }

    // Function called by the event listeners when user start pressing/touching
    onPointerDown(event) {
        let pointerPosition = getPointFromEvent(event);

        this.setState({
            isPointerDown : true, // We set the pointer as down
            pointerOrigin : {
            // We get the pointer position on click/touchdown so we can get the value once the user starts to drag
                x : pointerPosition.x,
                y : pointerPosition.y
            }
        });
    }

     // Function called by the event listeners when user start moving/dragging
    onPointerMove (event) {
        // Only run this function if the pointer is down
        if (!this.state.isPointerDown) {
            return;
        }
        // This prevent user to do a selection on the page
        event.preventDefault();

        // Get the pointer position
        let pointerPosition = getPointFromEvent(event);

        // We calculate the distance between the pointer origin and the current position
        // The viewBox x & y values must be calculated from the original values and the distances
        this.setState(prevState=> ({
            new_viewbox : {
                x: prevState.current_viewbox.x - (pointerPosition.x - prevState.pointerOrigin.x),
                y: prevState.current_viewbox.y - (pointerPosition.y - prevState.pointerOrigin.y)
            }
        }));

        
    }

    onZoom(event){

        if(event.deltaY > 0){
            this.setState(prevState => ({
                current_viewbox : {
                    width : prevState.current_viewbox.width / 9,
                    height: prevState.current_viewbox.height / 9
                }
            }))
        }
        else if(event.deltaY < 0){
            this.setState(prevState => ({
                current_viewbox : {
                    width : prevState.current_viewbox.width / 1.1,
                    height: prevState.current_viewbox.height / 1.1
                }
            }))
        }
    }

    onClick(event){
        if(event.target.getAttribute('name'))
        {
            let rect = event.target.getBoundingClientRect();

            this.setState({
                current_viewbox : {
                    x : rect.top,
                    y : rect.bottom,
                    width : rect.width,
                    height: rect.height
                }
            },
            () => {
                animatingViewBox(this.svg, this.current_viewbox.x, this.current_viewbox.y, this.current_viewbox.width, this.current_viewbox.height);
            })

        }
    }

    render(){
       return <ReactSVG
            beforeInjection = {(inject_svg) => {
                inject_svg.setAttribute('width',`${this.state.current_viewbox.width}`);
                inject_svg.setAttribute('height',`${this.state.current_viewbox.height}`);
                inject_svg.setAttribute('viewBox',`${this.state.current_viewbox.x} ${this.state.current_viewbox.y} ${this.state.current_viewbox.height} ${this.state.current_viewbox.height}`);

                if (window.PointerEvent) {
                    inject_svg.addEventListener('pointerdown', this.onPointerDown.bind(this)); // Pointer is pressed
                    inject_svg.addEventListener('pointerup', this.onPointerUp.bind(this)); // Releasing the pointer
                    inject_svg.addEventListener('pointerleave', this.onPointerUp.bind(this)); // Pointer gets out of the SVG area
                    inject_svg.addEventListener('pointermove', this.onPointerMove.bind(this)); // Pointer is moving
                    inject_svg.addEventListener('wheel', this.onZoom.bind(this));
                    inject_svg.addEventListener('click', this.onClick.bind(this));
                } else {
                    // Add all mouse events listeners fallback
                    inject_svg.addEventListener('mousedown', this.onPointerDown.bind(this)); // Pressing the mouse
                    inject_svg.addEventListener('mouseup', this.onPointerUp.bind(this)); // Releasing the mouse
                    inject_svg.addEventListener('mouseleave', this.onPointerUp.bind(this)); // Mouse gets out of the SVG area
                    inject_svg.addEventListener('mousemove', this.onPointerMove.bind(this)); // Mouse is moving

                    // Add all touch events listeners fallback
                    inject_svg.addEventListener('touchstart', this.onPointerDown.bind(this)); // Finger is touching the screen
                    inject_svg.addEventListener('touchend', this.onPointerUp.bind(this)); // Finger is no longer touching the screen
                    inject_svg.addEventListener('touchmove', this.onPointerMove.bind(this)); // Finger is moving
                }
                
                this.svg = inject_svg
        }
        }

        afterInjection = {(error, inject_svg) => {
            if (error) {
                console.error(error);
                return;
            }
            inject_svg.classList.add('region');
        }
        }

        src = {kr_map}
       ></ReactSVG>
    }
}

svg를 독립시켰다.
svg는 더이상 state의 일원이 아니며, setState의 최적화의 범위에서 벗어난다.

더 이상 오류는 발생하지 않는다.

하지만 이전에 구현해 놓았던 모든 기능이 동작하지 않아 더 많은 수정이 필요하다.

profile
FGPRJS

0개의 댓글