functional-lit-element 분석해보기

Lybell·2024년 7월 13일
0

들어가며

작성자는 웹 컴포넌트에 관심이 많다. 웹 컴포넌트는 브라우저에서 컴포넌트 단위로 프론트엔드 개발을 할 수 있게 하는 웹 API의 집합이라고 할 수 있는데, 웹 컴포넌트를 잘 쓰면 어떠한 라이브러리 없이도 컴포넌트의 마운트, 언마운트 등 생명주기를 관리할 수 있으며, 다양한 웹 프레임워크에서 일관되게 동작할 수 있다는 이점이 있다.

하지만, 단점은 웹 컴포넌트의 기반이 되는 custom element API가 모던 리액트 생테계가 좋아하는 함수형 방식이 아닌, 클래스 기반으로 이루어져 있다는 것이다. 그래서 함수형 컴포넌트를 custom element로 바꿔서 등록시키는 라이브러리를 만들어보고 싶었는데, 감이 잡히지 않았다.

그래서, custom element 기반 웹 라이브러리인 lit을 함수형으로 쓸 수 있게 만드는, functional-lit-element의 구조를 분석해보기로 했다. lit은 HTMLElement를 상속하는 LitElement를 상속하는 클래스형 컴포넌트를 만들어서 컴포넌트 기반 프로그래밍을 할 수 있는 경량화된 라이브러리다. functional-lit-element는 함수형으로 짠 컴포넌트를 LitElement로 변경해주는데, 이 코드를 분석하면 작성자가 원하는 함수형 컴포넌트를 custom element로 바꿔서 등록하는 아이디어를 찾을 수 있을 것이라고 생각했다.

functional-lit-element가 뭔데?

functional-lit-element: A wrapper for LitElement which provides an API similar to React functional components.

import {LitElement, html} from "lit-html";
import functionalElementFactory from "functional-lit-element";

const functionalElement = functionalElementFactory(LitElement);

function MyComponent({className, caption}, {useState})
{
	const [counter, setCounter] = useState(0);
	return html`
		<div class=${className}>
			<div class="caption">${caption}</div>
			<div @onClick=${()=>setCounter(state=>state+1)}>${counter}</div>
		</div>
	`;
}

const todoItemComponent = functionalElement(MyComponent);
customElements.define("todo-item", todoItemComponent);

간단히 말하면, 위의 코드와 같이 리액트의 함수형 컴포넌트스럽게 짠 함수형 컴포넌트를 LitElement를 상속하는 클래스형 컴포넌트로 변환시키는 라이브러리다.

이 라이브러리는 functionalElement라는 함수를 제공하며, 해당 함수에 인자로 들어가는 함수형 컴포넌트는 다음의 규칙을 갖는 함수여야 한다.

  • 매개변수는 2개다.
    • 첫 번째 매개변수에는 props 객체가 들어간다. 객체 구조 분해 할당으로 각 props를 가져올 수 있다.
    • 두 번째 매개변수에는 hooks 객체가 들어간다. 객체 구조 분해 할당으로 실제로 사용하는 hooks를 가져올 수 있다. 리액트에서는 미리 라이브러리에서 export된 훅을 가져다가 썼지만, 여기서는 함수의 인자로 가져와야 한다는 것이 차이점이다.
  • 반환값은 Lit이 렌더링할 수 있는 값이어야 한다.
    • 주로 Lit이 제공하는 html 태그드 템플릿을 이용한 값이 들어간다.

이렇게 정의한 함수형 컴포넌트를 functionalElement 함수에 집어넣고, 반환된 LitElement를 customElements.define으로 브라우저에 등록시키면 된다.

엔트리포인트

이 라이브러리가 반환하는 메인 함수인 functionalElement는 functionalElementProvider라는 함수의 리턴값으로, 매개변수로 LitElement와 createUseState 등 useState의 생성 함수를 받는다.

이 라이브러리의 브라우저 버전은 functionalElementProvider를 사용하는 것은 동일하나, LitElement를 사용자가 외부에서 주입해야 한다는 점이 차이점이다. 브라우저에서 모듈식 프로그래밍을 하지 않는 경우를 대비하기 위해, FunctionalLitElement 라이브러리를 LitElement를 번들링하는 것을 피하도록 설계된 것으로 보인다.

functionalElementProvider는 dependency를 받아, render 함수를 받아 클래스형 Lit Component를 반환하는 함수를 리턴하는 고차함수다. 이것의 리턴값을 즉, 우리는 render 함수를 받아 클래스형 Lit Component를 반환하는 함수를 사용하게 되는 것이다.

functionalElementProvider

render, props, styles 매개변수를 받아서 LitComponent를 extends한 클래스를 반환한다. 이 중 props와 styles는 반환하는 클래스의 static 메소드를 정의하는 데 쓰인다.

getProps()

const getProps = (element) => Object.keys(props).reduce((renderProps, propName) => {
    renderProps[propName] = element[propName];
    return renderProps;
}, {});

getProps는 element 객체를 받아서 객체를 반환한다. 이 함수에서는 외부에 있는 props 객체의 key값을 통해 객체를 쌓아올리는 과정을 수행하는데, 새 객체에 기존 element 객체의 value를 그대로 대입하고 있다.

getProps 함수는 기존 엘리먼트에 존재하는 여러 프로퍼티 중 props의 key로 지정된 프로퍼티를 필터링하는 함수라고 보면 될 것 같다. 이 함수는 실제 커스텀 엘리먼트 객체 중 어떤 것이 의미 있는 props인지 정의하기 위해 필요하다.

static properties()

static get properties() {
    const dynamicState = {
        _dynamicState: {type: Object},
        _dynamicReducerState: {type: Object},
        _context: {type: Object}
    };
    return Object.assign({}, dynamicState, props);
}

어렵지 않다. 내부에서 정의하는 _dynamicState, _dynamicReducerState, _context 상태와 기존에 존재하던 상태 정의 객체인 props 객체를 합쳐서 새 객체로 만들어 준다.

스프레드 연산자로 더 쉽게 표현하면 이렇게 표현할 수 있다.

static get properties() {
    const dynamicState = {
        _dynamicState: {type: Object},
        _dynamicReducerState: {type: Object},
        _context: {type: Object}
    };
    return {...dynamicState, ...props};
}

constructor()

constructor() {
    super();
    this._dynamicReducerState = new Map();
    this._dynamicState = new Map();
    this._context = new Map();

    this._reducerStateKey = 0;
    this._stateKey = 0;
    this._effectKey = 0;
    this._effects = [];
    this._effectsState = new Map();

    this._contextListeners = new Map();
    this._contextParents = new Map();

    this._createHooks();
}

_createHooks() {
    this._hooks = {};
    this._hooks.useState = createUseState(this);
    this._hooks.useEffect = createUseEffect(this);
    this._hooks.useReducer = createUseReducer(this);
    this._hooks.useContext = createUseContext(this);
    this._hooks.provideContext = createProvideContext(this);
}

hook에서 쓰이는 내부 상태들을 초기화해서 클래스의 인스턴스에 할당한다.

바닐라 자바스크립트에서 리액트의 함수형 컴포넌트를 구현하는 데에 있어서 주안점은 다음과 같다.

  • 어떻게 하면 서로 다른 컴포넌트에서 useState를 호출했을 때 일관된 값을 가져올 수 있을까?
  • 어떻게 하면 여러 useState를 호출했을 때 일관된 값을 가져올 수 있을까?

functionalLitElement는 근본적으로 클래스인 LitElement를 반환하기 때문에, 클래스의 이점인 메소드의 실행 주체를 기억하는 것을 활용할 수 있다. functionalLitElement는 첫번째 문제를 createUseState에 this를 넘겨주는 방식으로 해결한다. createUseState가 반환하는 함수는 this, 즉 클래스의 인스턴스 자기 자신을 알고 있게 된다.

두 번째 문제는 전통적인 함수형 컴포넌트 구현 방식처럼, hook을 호출한 순서를 따로 기록해두는 방식으로 각각의 hook을 구분하는 방식으로 해결한다. (this._stateKey = 0;)

참고 : 바닐라 자바스크립트로 리액트 훅 구현 방법

1번의 경우는 여러 가지 방법이 있을 수 있다. 공통적으로는 함수형 컴포넌트를 평가할 때 렌더링하는 함수의 실행 순서와 useState의 호출 순서가 동일하다고 가정하고, 외부 변수에 저장하는 아이디어를 채택하고 있다.

가장 간단하게는, useState의 호출 순서를 기록해두고, 호출된 순서에 맞는 값을 가져오는 것이다. 하지만, 중간에 useState를 호출하는 컴포넌트가 제거되거나 추가될 경우 state가 꼬일 수 있다는 단점이 있다.

좀 더 복잡하게는, 컴포넌트를 렌더링할 때 자식 엘리먼트로 함수의 실행 값이 아닌 함수를 넘겨주고, 함수에 상태값을 바인딩하거나, 함수와 부모 함수를 기반으로 key값을 만들어서 그것을 기반으로 값을 가져오거나, 더 복잡하게는 현재 가상 dom의 렌더링 트리를 모사한 상태 트리를 만들어서 트리 상에서 일치하는 상태를 가져오는 방법도 있다.

2번의 경우 1번과 유사하게, 동일 컴포넌트에서 hook의 호출 순서가 동일하다고 보장하고(실제로 react에서는 조건부로 hook을 생성하는 조건부 hook을 금지하고 있다.), 호출 순서를 기록하는 방식으로 구현할 수 있다.

    function ReactMaker()
    {
    	const stateStore = new ReactStateMap();
    	let currentRenderKey = null;
    	let currentRenderStatePointer = 0;
    	let rootElement = null;
    	useState(initialKey)
    	{
    		const state = stateStore.get(currentRenderKey, currentRenderStatePointer);
    		currentRenderStatePointer++
    		function setState(newValue)
    		{
    			stateStore.set(currentRenderKey, currentRenderStatePointer, newValue);
    			render();
    		}
    		return [state, setState];
    	}
    	render()
    	{
    		currentRenderKey = makeKey(rootElement.renderFunction);
    		currentRenderStatePointer = 0;
    		rootElement.renderFunction();
    		// 자식 엘리먼트 렌더링
    	}
    	createReact(el, rootElement_)
    	{
    		rootElement = rootElement_;
    		// 초기화 함수
    	}
    	return {useState, createReact};
    }

대충 짭액트가 저렇게 구성된다고 보면 될 것 같다.(실제 리액트는 저렇게 안 생겼을 확률이 매우 높다.)

render()

render() {
    super.render();
    this._resetHooks();
    const hooks = {
        useState: this._hooks.useState,
        useEffect: this._hooks.useEffect,
        useReducer: this._hooks.useReducer,
        useContext: this._hooks.useContext,
        provideContext: this._hooks.provideContext,
    };
    const template = render(getProps(this), hooks);
    this._runEffects();
    return template;
}

render 함수는 state가 변경되면 실행되는 메소드다. 다음의 방식으로 실행된다.

  1. hook 상태를 초기화한다. useState, useEffect 등 훅을 구분하기 위한 포인터를 0으로 초기화한다.
  2. this._hooks에 저장된 훅을 별도의 hooks 객체로 복사한다. const hooks = {…this._hooks}와 동일하다.
  3. 함수형 컴포넌트를 실행한다. 이 때, getProps 함수로 반환된 props와 hooks를 인자로 넣는다. 이를 통해 내부에서 클래스의 인스턴스로 바인딩된 훅을 함수형 컴포넌트 내에서 사용할 수 있으며, hook이 호출되면 클래스 인스턴스의 상태를 변경시킨다.
  4. 함수형 컴포넌트가 호출되면서 useEffect로 등록된 이펙트를 실행한다.
  5. 함수형 컴포넌트가 반환한 템플릿을 반환한다. 이를 기반으로 LitElement 내부에서 실제 dom으로 렌더링을 수행한다.

_runEffect()

_runEffects() {
    return this._effects.map((effect) => {
        return new Promise((resolve, reject) => {
            try {
                return resolve(effect());
            } catch (e) {
                reject(e);
            }
        });
    });
}

useEffect의 실행 과정에서 등록된 사이드이펙트 함수들을 실행한다. 따로 effect()의 결과 함수를 어딘가에 저장하는 부분은 없으므로, 실제 리액트의 useEffect에 있는 cleanup code는 딱히 구현하지 않은 걸로 보인다.

실제 useEffect에서는 dependency 배열을 받아서 해당 배열의 값이 달라질 때 콜백 함수를 실행시키는데, 여기에서 이 부분 처리 로직은 useEffect 내부에 있다.

createUseState

export const createUseState = (element) => {

    const getState = (key) => {
        return element._dynamicState.get(key);
    };

    const setState = (key, value)  => {
        const newState = new Map(Array.from(element._dynamicState.entries()));
        newState.set(key, value);
        element._dynamicState = newState;
    };

    // useState hook
    return (defaultValue = null) => {
        const currentStateKey = element._stateKey;

        if (getState(currentStateKey) === undefined) {
            setState(currentStateKey, defaultValue);
        }

        const changeValue = (newValue) => {
            setState(currentStateKey, newValue)
        };

        element._stateKey++;
        return [getState(currentStateKey), changeValue];
    };
};

동적인 상태를 관장하는 useState 훅은 반환하는 createUseState 함수는 3개의 부분으로 구성된다.

getState

컴포넌트의 _dynamicState 맵에서 key를 찾아서 value를 반환한다.

setState

원래 맵의 엔트리를 복사해서 새 Map 객체를 만든 뒤, 해당 Map에 key - value를 설정해서 새 값을 넣는다. 이후, 컴포넌트의 _dynamicState 프로퍼티를 해당 Map 객체로 변경한다.

useState

컴포넌트의 _stateKey를 받아와 별도의 변수에 저장하고, 현재 호출 중인 useState와 실제 데이터를 매칭시킨다. 참고로 별도의 변수에 저장하지 않으면 changeValue를 호출할 때 changeValue가 가리키는 변수의 실제 주소를 보장하지 못한다는 문제가 생긴다.

만약 실제 데이터가 undefined이면 defaultValue로 초기화한다. 단, 어떤 미치광이가 setState(undefined)를 호출해서 undefined를 상태로 넣으면 다시 defaultValue가 된다는 잠재적인 문제가 있다.

changeValue는 내부에서 정의한 setState를 newValue 하나로 호출하도록 래핑해준다. 단 실제 리액트의 useState처럼 이전 함수에 따라 새 값으로 변경하는 건 불가능한 듯하다.

모든 함수의 내부 실행이 끝나면, element의 _stateKey를 1 올린다. 참고로 _stateKey는 함수형 컴포넌트가 실행되기 직전에 0으로 초기화되므로 같은 useState는 같은 __stateKey를 갖는 것이 보장된다.

리팩토링?

사실 key는 int형 자료형이라서, _dynamicState를 굳이 map으로 선언할 필요는 없어 보인다. 배열을 사용하고, 실제 useState처럼 함수를 받을 수 있도록 바꿔보면 다음과 같이 바꿀 수 있다.

export const createUseState = (element) => {

    const getState = (key) => {
        return element._dynamicState[key];
    };

    const setState = (key, value) => {
        const newState = [...element._dynamicState];
        if(typeof value === "function") newState[key] = value(newState[key]);
        else newState[key] = value;
        element._dynamicState = newState;
    };
    
    // ... 원래 것과 동일
}

createUseEffect

export const createUseEffect = (element) => {

    const getEffectState = (key) => {
        return element._effectsState.get(key);
    };

    const setEffectState = (key, value)  => {
        const newState = new Map(Array.from(element._effectsState.entries()));
        newState.set(key, value);
        element._effectsState = newState;
    };

    const addEffect = (effect) => {
        element._effects.push(effect);
    };

    const effectStateHasChanged = (stateToWatch, key) => {
        const effectState = getEffectState(key);
        if (effectState.length === 0) {
            return false;
        }

        for(let i = 0; i < stateToWatch.length; i++) {
            if (effectState[i] !== stateToWatch[i]) {
                return true;
            }
        }
        return false;
    };

    // useEffect hook
    return (effect, stateToWatch = undefined) => {
        // If no state to watch, run effect every time
        if (stateToWatch === undefined) {
            addEffect(effect);
            return;
        }

        const currentKey = element._effectKey;

        // If first time useEffect called, set the effect state to watch and run effect
        if (getEffectState(currentKey) === undefined) {
            setEffectState(currentKey, stateToWatch);
            addEffect(effect);
            return;
        }

        // see if state has changed to decide whether effect should run again
        if (effectStateHasChanged(stateToWatch, currentKey)) {
            addEffect(effect);
        }

        setEffectState(currentKey, stateToWatch);
        element._effectKey++;
    }
};

상태 변경에 따른 사이드이펙트를 관장하는 useEffect 훅은 다음의 방식으로 동작한다.

useEffect

  • stateToWatch가 undefined이면 모든 경우에 사이드이펙트가 실행되므로, 사이드이펙트를 컴포넌트에 등록시키고 함수를 종료한다.
  • 그렇지 않을 경우, 이전 의존성 배열과 현재 의존성 배열의 변경을 비교해야 한다. 해당 부분은 컴포넌트의 _effectState 프로퍼티에 저장되어 있으며, 동일한 useEffect마다 동일한 _effectState를 참조해야 하므로, _effectKey라는 참조 변수를 이용한다.
    • 의존성 배열의 상태가 없으면, useEffect가 가리키는 의존성 배열를 초기화하고 이펙트를 무조건 등록시킨다.
    • effectStateHasChanged 함수를 호출하여, 직전 의존성 배열과 현재 의존성 배열을 비교한다. 만약 다르다면, 이펙트를 등록시킨다.
    • 의존성 배열을 현재 의존성 배열으로 초기화하고, _effectKey를 1 증가시킨다.

getEffectState

컴포넌트에 등록된 _effectsState Map에서 key에 해당하는 값을 가져온다. _effectsState 맵은 함수형 컴포넌트가 가지고 있는 현재 의존성 배열들의 상태가 저장되어 있다.

setEffectState

컴포넌트에 등록된 _effectsState Map을 기반으로 내용이 동일한 새 맵 객체를 생성하고, key에 해당하는 값을 변경시킨 뒤 _effectState를 변경시킨다.

참고로 _effectState의 변경에 따라서 컴포넌트가 리렌더링되는 건 아니다. LitComponent의 properties에 등록되어 있지 않기 때문이다.

addEffect

컴포넌트의 _effects 배열에 인자로 받은 함수를 추가한다.

참고로 _effects 배열은 컴포넌트의 render 메소드가 호출될 때 매번 빈 배열로 초기화되므로, 직전 렌더링 시간에 등록된 사이드이펙트가 또 실행되지는 않는다.

effectStateHasChanged

컴포넌트의 _effectState Map에서 key의 직전 의존성 배열을 가져온다.

현재의 의존성 배열을 기준으로 반복문을 돌리면서 배열이 일치하는지를 파악한다.

만약 달라진 부분이 존재하면 true를 반환하고, 모든 배열의 원소가 일치하면 false를 반환한다.

참고로, 새롭게 생성된 의존성 배열의 원소가 없으면 false를 반환한다.

어떤 미치광이가 의존성 배열의 원소 수를 렌더링할 때마다 다르게 한다면 오류가 날 가능성이 높아지지만, 그 누구도 그렇게 함수형 컴포넌트를 짜지 않으므로 무시해도 좋다.

profile
홍익인간이 되고 싶은 꿈꾸는 방랑자

0개의 댓글