요약과 서평 | 프레임워크 없는 프론트엔드 개발

요약과 서평 | 프레임워크 없는 프론트엔드 개발

집에 쌓아두고 먼지투성이가 된 책들이 한두 권이 아니다… 더 늦기 전에 하나씩 꺼내서 읽어볼 예정이다. 첫 번째로는 프레임워크 없는 프론트엔드 개발이라는 책을 읽어봤다.

전반적으로 책을 요약하고 간단한 서평을 하려고 한다.

요약

1장 : 프레임워크에 대한 이야기

프레임워크가 무엇인지 알아보고 라이브러리와의 차이를 알아본다. 다음으로 자바스크립트 프레임워크의 역사를 알아본다.

2장 : 렌더링

데이터를 표시하는 렌더링을 프레임워크 없이 하는 방법에 대해 알아본다.

// index.js

...

registry.add('todos', todosView)
registry.add('counter', counterView)
registry.add('filters', filtersView)

const state = {
  todos: getTodos(),
  currentFilter: 'All'
}

const render = () => {
  window.requestAnimationFrame(() => {
    const main = document.querySelector('.todoapp')
    const newMain = registry.renderRoot(main, state)
    applyDiff(document.body, main, newMain)
  })
}

...

index.html 에서 .todoapp 이라는 class를 가진 Element를 가져와서 renderRoot 를 통해 newMain를 만들고 applyDiff 함수로 특정 노드가 바뀌었는지 확인하고 바뀌었다면 특정 노드를 새로운 노드로 변경한다.

// registry.js
 
const registry = {}

const renderWrapper = component => {
  return (targetElement, state) => {
    const element = component(targetElement, state)

    const childComponents = element
      .querySelectorAll('[data-component]')

    Array
      .from(childComponents)
      .forEach(target => {
        const name = target
          .dataset
          .component

        const child = registry[name]
        if (!child) {
          return
        }

        target.replaceWith(child(target, state))
      })

    return element
  }
}

const add = (name, component) => {
  registry[name] = renderWrapper(component)
}

const renderRoot = (root, state) => {
  const cloneComponent = root => {
    return root.cloneNode(true)
  }

  return renderWrapper(cloneComponent)(root, state)
}

export default {
  add,
  renderRoot
}

내가 감탄한 부분이다. renderWrapper 함수를 재활용하며 registry.add 를 통해 추가한 View들을 렌더해주는 역할을 하고 있다.

// applyDiff.js

const isNodeChanged = (node1, node2) => {
  const n1Attributes = node1.attributes
  const n2Attributes = node2.attributes
  if (n1Attributes.length !== n2Attributes.length) {
    return true
  }

  const differentAttribute = Array
    .from(n1Attributes)
    .find(attribute => {
      const { name } = attribute
      const attribute1 = node1
        .getAttribute(name)
      const attribute2 = node2
        .getAttribute(name)

      return attribute1 !== attribute2
    })

  if (differentAttribute) {
    return true
  }

  if (node1.children.length === 0 &&
    node2.children.length === 0 &&
    node1.textContent !== node2.textContent) {
    return true
  }

  return false
}

const applyDiff = (
  parentNode,
  realNode,
  virtualNode) => {
  if (realNode && !virtualNode) {
    realNode.remove()
    return
  }

  if (!realNode && virtualNode) {
    parentNode.appendChild(virtualNode)
    return
  }

  if (isNodeChanged(virtualNode, realNode)) {
    realNode.replaceWith(virtualNode)
    return
  }

  const realChildren = Array.from(realNode.children)
  const virtualChildren = Array.from(virtualNode.children)

  const max = Math.max(
    realChildren.length,
    virtualChildren.length
  )
  for (let i = 0; i < max; i++) {
    applyDiff(
      realNode,
      realChildren[i],
      virtualChildren[i]
    )
  }
}

export default applyDiff

이전 Node와 새로운 Node가 Attribute 수가 같은지, Attribute 이름이 같은지, 자식 Node의 수가 같은지, Content가 같은지 비교하며 변경점이 있는지 재귀함수를 활용해서 체크한다.

위 코드들을 이해하면서 리액트의 내부 코드의 흐름과 굉장히 유사하다고 느꼈다.

3장 : DOM 이벤트 관리

사용자와 인터렉션하기 위해 2장의 코드에 이벤트 핸들러를 연결한다.

// index.js

const events = {
  deleteItem: (index) => {
    state.todos.splice(index, 1)
    

기존 코드에 이벤트를 추가해줬다. 이벤트가 발생할 때마다 render 를 호출하는 것을 주목하자.

// app.js

const addEvents = (targetElement, events) => {
  targetElement
    .querySelector('.new-todo')
    .addEventListener('keypress', e => {
      if (e.key === 'Enter') {
        events.addItem(e.target.value)
        e.target.value = ''
      }
    })
}

export default (targetElement, state, events) => {
  const newApp = targetElement.cloneNode(true)

  newApp.innerHTML = ''
  newApp.appendChild(getTemplate())

  addEvents(newApp, events)

  return newApp
}

이벤트를 부착한다.

4장 : 웹 구성 요소

HTML 템플릿, 사용자 정의 요소를 소개하고 웹 컴포넌트로 Todo 앱을 만드는 방법을 소개한다. 웹 컴포넌트를 책 전반에서 다루진 않기 때문에 관련 코드는 생략한다. (아래는 관련 링크)

Using custom elements - Web APIs | MDN

One of the key features of web components is the ability to create custom elements: that is, HTML elements whose behavior is defined by the web developer, that extend the set of elements available in the browser.

https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements
Using custom elements - Web APIs | MDN

5장 : HTTP 요청

AJAX가 나오면서 변화한 웹 방식에 관해 설명하고 HTTP 클라이언트를 구현하는 세 가지 방법에 대해 알아본다.

XMLHttpRequest

const setHeaders = (xhr, headers) => {
  Object.entries(headers).forEach(entry => {
    const [
      name,
      value
    ] = entry

    xhr.setRequestHeader(
      name,
      value
    )
  })
}

const parseResponse = xhr => {
  const {
    status,
    responseText
  } = xhr

  let data
  if (status !== 204) {
    data = JSON.parse(responseText)
  }

  return {
    status,
    data
  }
}

const request = params => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()

    const {
      method = 'GET',
      url,
      headers = {},
      body
    } = params

    xhr.open(method, url)

    setHeaders(xhr, headers)

    xhr.send(JSON.stringify(body))

    xhr.onerror = () => {
      reject(new Error('HTTP Error'))
    }

    xhr.ontimeout = () => {
      reject(new Error('Timeout Error'))
    }

    xhr.onload = () => resolve(parseResponse(xhr))
  })
}

const get = async (url, headers) => {
  const response = await request({
    url,
    headers,
    method: 'GET'
  })

  return response.data
}

...

export default {
  get,
  post,
  put,
  patch,
  delete: deleteRequest
}

Fetch API

const parseResponse = async response => {
  const { status } = response
  let data
  if (status !== 204) {
    data = await response.json()
  }

  return {
    status,
    data
  }
}

const request = async params => {
  const {
    method = 'GET',
    url,
    headers = {},
    body
  } = params

  const config = {
    method,
    headers: new window.Headers(headers)
  }

  if (body) {
    config.body = JSON.stringify(body)
  }

  const response = await window.fetch(url, config)

  return parseResponse(response)
}

const get = async (url, headers) => {
  const response = await request({
    url,
    headers,
    method: 'GET'
  })

  return response.data
}

...

export default {
  get,
  post,
  put,
  patch,
  delete: deleteRequest
}

Axios

const request = async params => {
  const {
    method = 'GET',
    url,
    headers = {},
    body
  } = params

  const config = {
    url,
    method,
    headers,
    data: body
  }

  return axios(config)
}

const get = async (url, headers) => {
  const response = await request({
    url,
    headers,
    method: 'GET'
  })

  return response.data
}

...

export default {
  get,
  post,
  put,
  patch,
  delete: deleteRequest
}
라이브러리를 사용할 때는 항상 이에 대한 인터페이스를 생성하라. 필요시 새로운 라이브러리로 쉽게 변경할 수 있다.

6장 : 라우팅

SPA에서 라이브러리를 사용하지 않고 클라이언트 라우팅을 하는 방법에 대해 알아본다.

프래그먼트 식별자 기반 라우팅

// router.js

export default () => {
  ...

  const checkRoutes = () => {
    const { hash } = window.location;

    // 현재 프래그먼트가 경로 중 하나와 매칭되는지 확인
    const currentRoute = routes.find((route) => {
      const { testRegExp } = route;
      return testRegExp.test(hash);
    });

    if (!currentRoute) {
      notFound();
      return;
    }

    const urlParams = extractUrlParams(currentRoute, window.location.hash);

    // 경로에 대한 컴포넌트를 보여준다.
    currentRoute.component(urlParams);
  };

  router.addRoute = (fragment, component) => {
    const params = [];

    // 정규표현식을 사용해 매개 변수를 params에 넣어준다.
    const parsedFragment = fragment
      .replace(ROUTE_PARAMETER_REGEXP, (match, paramName) => {
        params.push(paramName);
        return URL_FRAGMENT_REGEXP;
      })
      .replace(/\//g, "\\/");

    console.log(`^${parsedFragment}$`);

    routes.push({
      testRegExp: new RegExp(`^${parsedFragment}$`),
      component,
      params,
    });

    return router;
  };

  router.setNotFound = (cb) => {
    notFound = cb;
    return router;
  };

  router.navigate = (fragment) => {
    window.location.hash = fragment;
  };

  router.start = () => {
    window.addEventListener("hashchange", checkRoutes);

    if (!window.location.hash) {
      window.location.hash = "#/";
    }

    checkRoutes();
  };
  ...
};

// index.js

...

router
  .addRoute("#/", pages.home)
  .addRoute("#/list", pages.list)
  .addRoute("#/list/:id", pages.detail)
  .addRoute("#/list/:id/:anotherId", pages.anotherDetail)
  .setNotFound(pages.notFound)
  .start();

...

히스토리 API 기반 라우팅

// router.js

export default () => {
	...

	const checkRoutes = () => {
		const { pathname } = window.location
    if (lastPathname === pathname) {
      return
    }

		...
	}

  router.addRoute = (path, callback) => {
    const params = []

    const parsedPath = path
      .replace(
        ROUTE_PARAMETER_REGEXP,
        (match, paramName) => {
          params.push(paramName)
          return URL_FRAGMENT_REGEXP
        })
      .replace(/\//g, '\\/')

    routes.push({
      testRegExp: new RegExp(`^${parsedPath}$`),
      callback,
      params
    })

    return router
  }
	
	...

  router.navigate = path => {
    window
      .history
      .pushState(null, null, path)
  }

  router.start = () => {
    checkRoutes()
    window.setInterval(checkRoutes, TICKTIME)
  }

	...
}

바뀐 부분만 별도로 적었다. pushState 메서드는 새 URL로 이동시켜 주는 역할을 한다.

Navigo 기반 라우팅

(생략합니다 ~)

7장 : 상태 관리

프론트엔드에서 상태를 관리하는 3가지 방법을 알아본다.

모델 - 뷰 -컨트롤러

// model/state.js

export default (initalState = INITIAL_STATE) => {
  const state = cloneDeep(initalState);
  let listeners = [];

  const addChangeListener = (listener) => {
    listeners.push(listener);

    listener(freeze(state));

    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  };

  const invokeListeners = () => {
    const data = freeze(state);
    listeners.forEach((l) => l(data));
  };

  const addItem = (text) => {
    if (!text) {
      return;
    }

    state.todos.push({
      text,
      completed: false,
    });

    invokeListeners();
  };
	
	...

  return {
    addItem,
    ...
  };
};

대부분의 상태 관리 라이브러리 Core 코드에서 사용하는 방식이다. 어떠한 액션이 일어나면 리스너들을 실행해준다.

반응형 프로그래밍

// model/model.js

import observableFactory from './observable.js'

export default (initialState = INITIAL_STATE) => {
	const state = observableFactory(initialState)
	
	...
}

// model/observable.js

export default (initialState) => {
  let listeners = []

  const proxy = new Proxy(cloneDeep(initialState), {
    set: (target, name, value) => {
      target[name] = value
      listeners.forEach(l => l(freeze(proxy)))
      return true
    }
  })

  proxy.addChangeListener = cb => {
    listeners.push(cb)
    cb(freeze(proxy))
    return () => {
      listeners = listeners.filter(l => l !== cb)
    }
  }

  return proxy
}

기존 MVC 상태 관리 방식에서 Proxy를 활용하여 옵저버블 모델을 생성했다. Proxy에 대한 이해도가 높지 않아서 그런지 위처럼 했을 때 기존 MVC 방식에 비해 어떠한 장점이 있는지 잘 모르겠다.

이벤트 버스

노드들을 연결하는 단일 객체가 모든 이벤트를 처리한다는 컨셉이다. 다른 방식(MVC, 반응형)과 큰 차이가 있다고 느껴지지는 않았다. 우리가 Redux에서 많이 사용한 dispatch를 통해 뷰에서 이벤트 버스로 이벤트를 전달한다.

(코드 생략)

8장 : 적합한 작업을 위한 적합한 도구

(생략합니다 ~ 가볍게 읽어보세요!)

서평

책의 전반적인 내용은 프론트엔드를 개발할 때 대부분 사용하는 라이브러리, 프레임워크(React, Vue, Next 등)를 사용하지 않고 바닐라 자바스크립트로 구현하는 방법에 관한 이야기를 한다. 하지만 그렇다고 프레임워크 없는 개발이 옳아요! 좋아요! 라고 말하지 않는다. 1 ~ 7장까지 걸쳐서 우리가 프레임워크, 라이브러리를 사용하면서 편하게 활용할 수 있는 웹에서 필요한 기능 (렌더링, 상태 관리, 라우팅 등) 들이 어떻게 구현되어 있는지 원리와 내부 구현에 관해 설명한다. 그리고 마지막 장에서 어떻게 적합한 도구를 고를 수 있는가에 대한 이야기를 한다.

당연하게 사용해 왔던 기술들이 어떠한 원리로 구성되어 있는지 책을 통해 이해하면서 내가 사용하고 있는 기술들을 좀 더 깊이 이해할 수 있었다.

특히, 책 전반에 걸쳐 사용되는 핵심 코드는 함수형으로 프로그래밍 되어있는데 코드를 천천히 뜯어보며 다시 한번 함수형 프로그래밍의 매력을 느낄 수 있었다.

나에게는 가려운 부분을 많이 긁어준 책이었다. (오랜만에 재밌게 끝까지 읽어본 책이었다.) 책의 분량도 많지 않으니 웹 개발자는 한 번쯤 읽어보는 걸 추천한다!

Reference

https://github.com/Apress/frameworkless-front-end-development