[번역] How to write your own Virtual DOM 2: Props & Events

[번역] How to write your own Virtual DOM 2: Props & Events

https://medium.com/@deathmood/write-your-virtual-dom-2-props-events-a957608f5c76

안녕하세요!

나는 이 주제를 계속하고 궁극적으로 우리가 ‘baby’ 가상 DOM 구현에서 실제 프로젝트에서 사용할 수 있는 smth로 이동할 수 있게 해주는 모든 다음 사항을 여러분과 공유하게 되어 매우 기쁩니다.

오늘은 주로 속성(props) 설정/차이와 이벤트 처리에 대해 이야기하겠습니다. 확인 가자 😉

시리즈의 두 번째 기사입니다. 이곳에 처음 오신 분이라면 소개를 꼭 읽어보세요 .

Babel로 다루기

props로 시작하기 전에 이전 구현에서 한 가지 작은 차이를 수정해야 합니다. 다음과 같이 속성(props) 없이 노드만 있는 경우:

<div></div>
Babel은 트랜스파일할 때 속성이 없기 때문에 요소의 props 속성을 'null'로 설정합니다 . 따라서 우리는 다음을 갖게 됩니다:
{ type: ‘’, props: null, children: [] }
기본적으로 빈 객체 로 설정하는 것이 더 좋습니다 . 그러면 속성을 반복하는 동안 오류가 발생하지 않습니다(나중에 확인하게 됩니다). 이 문제를 해결하기 위해 h(…) 함수를 다음과 같이 수정합니다 .
function h(type, props, …children) {
  return { type, props: props || {}, children };
}

props 설정

props 설정은 정말 간단합니다. 곧 알게 될 것입니다. DOM 표현을 기억하시나요? 거기에 일반 JS 객체와 같은 props 를 저장하므로 이 마크업의 경우 다음과 같습니다.

<ul className=”list” style=”list-style: none;”></ul>
우리는 다음과 같은 메모리 내 표현을 갖게 될 것입니다:
{ 
  type: ‘ul’, 
  props: { className: ‘list’, style: ’list-style: none;’ } 
  children: []
}
따라서 props 객체의 각 필드는 attribute name 이고 이 필드의 값은 attribute value 입니다 . 따라서 실제 DOM 노드에만 이 항목을 설정하면 됩니다. setAttribute(…) 메소드 주위에 함수 래퍼를 작성해 보겠습니다 .
function setProp($target, name, value) {
  $target.setAttribute(name, value);
}
이제 우리는 하나의 속성(prop)을 설정하는 방법을 알았습니다. props 객체의 모든 필드를 반복하여 속성을 모두 설정할 수 있습니다.
function setProps($target, props) {
  Object.keys(props).forEach(name => {
    setProp($target, name, props[name]);
  });
}
이제 createElement(…) 함수를 기억하시나요? 실제 DOM 노드 생성 직후에 setProps(..)를 설정하겠습니다 .
function createElement(node) {
  if (typeof node === ‘string’) {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  setProps($el, node.props);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}
그러나 이것이 끝이 아닙니다. 우리는 몇 가지 작은 것들을 잊어버렸습니다. 먼저 'class'는 JS에서 예약어이므로 속성 이름으로 사용하지 않습니다. 우리는 'className'을 사용할 것입니다:
<nav className="navbar light">
  <ul></ul>
</nav>
하지만 실제 DOM에는 'className' 속성이 없으므로 setProp(…) 함수에서 이를 처리해야 합니다.

또 다른 점은 다음과 같이 부울 값을 사용하여 부울 DOM 속성(예: 선택됨, 비활성화됨)을 설정하는 것이 더 편리하다는 것입니다.

<input type="checkbox" checked={false} />
이 샘플에서는 'checked' 속성이 실제 DOM 요소에 설정되지 않을 것으로 예상합니다. 그러나 실제로는 그렇게 될 것입니다. 아시다시피 이 속성의 존재만으로도 설정하기에 충분하기 때문입니다. 그래서 우리는 그것을 고쳐야 합니다. 속성을 설정할 뿐만 아니라 요소 참조에 해당 부울 속성도 설정한다는 점에 유의하세요.
function setBooleanProp($target, name, value) {
  if (value) {
    $target.setAttribute(name, value);
    $target[name] = true;
  } else {
    $target[name] = false;
  }
}
좋아요. 여기서 마지막으로 말할 것은 사용자 정의 속성입니다. 내 말은, 이것이 우리 자신의 구현이므로 앞으로는 다른 역할을 갖고 DOM에 표시되어서는 안 되는 속성을 갖고 싶을 수도 있다는 것입니다. 따라서 우리는 이 속성이 사용자 정의인지 여부를 확인하는 함수를 작성하겠습니다. 아직 사용자 정의 속성이 없기 때문에 지금은 비어 있습니다.
function isCustomProp(name) {
  return false;
}
위의 모든 문제를 해결하는 완성된 setProp(..) 함수 는 다음과 같습니다 .
function setProp($target, name, value) {
  if (isCustomProp(name)) {
    return;
  } else if (name === ‘className’) {
    $target.setAttribute(‘class’, value);
  } else if (typeof value === ‘boolean’) {
    setBooleanProp($target, name, value);
  } else {
    $target.setAttribute(name, value);
  }
}
이제 JSFiddle에서 테스트해 보겠습니다.
/** @jsx h */

function h(type, props, ...children) {
  return { type, props: props || {}, children };
}

function setBooleanProp($target, name, value) {
  if (value) {
    $target.setAttribute(name, value);
    $target[name] = true;
  } else {
    $target[name] = false;
  }
}

function isCustomProp(name) {
  return false;
}

function setProp($target, name, value) {
  if (isCustomProp(name)) {
    return;
  } else if (name === 'className') {
    $target.setAttribute('class', value);
  } else if (typeof value === 'boolean') {
    setBooleanProp($target, name, value);
  } else {
    $target.setAttribute(name, value);
  }
}

function setProps($target, props) {
  Object.keys(props).forEach(name => {
    setProp($target, name, props[name]);
  });
}

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  setProps($el, node.props);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

//--------------------------------------------------

const f = (
  <ul style="list-style: none;">
    <li className="item">item 1</li>
    <li className="item">
      <input type="checkbox" checked={true} />
      <input type="text" disabled={false} />
    </li>
  </ul>
);

const $root = document.getElementById('root');
$root.appendChild(createElement(f));
<div id="root"></div>
.item {
  background: yellow;
  margin: 10px 0;
}

props 비교

자, 이제 소품을 사용하여 요소를 만들 수 있으므로 차이점을 생각하는 방법을 생각해 볼 차례입니다. 결국에는 속성을 설정 하거나 제거 해야 합니다 . 우리는 이미 props를 설정할 수 있는 함수를 작성했습니다 . 이제 이를 제거 할 수 있는 함수를 작성해 보겠습니다 . 실제로 이는 매우 간단한 프로세스이므로 이에 대해서는 언급하지 않겠습니다.

function removeBooleanProp($target, name) {
  $target.removeAttribute(name);
  $target[name] = false;
}
function removeProp($target, name, value) {
  if (isCustomProp(name)) {
    return;
  } else if (name === ‘className’) {
    $target.removeAttribute(‘class’);
  } else if (typeof value === ‘boolean’) {
    removeBooleanProp($target, name);
  } else {
    $target.removeAttribute(name);
  }
}


이제 두 가지 속성(기존 속성과 새 속성)을 비교하고 그 비교 결과에 따라 실제 DOM 노드를 수정하는 updateProp 함수를 작성해 보겠습니다 . 실제로 우리는 다음 사례를 처리해야 합니다.
  • 새 노드 에는 해당 이름을 가진 속성이 없으므로 제거해야 합니다.
  • 이전 노드 에는 해당 이름을 가진 속성이 없으므로 이를 설정해야 합니다.
  • 해당 이름을 가진 속성은 새 노드 와 이전 노드 모두에 존재합니다 . 그런 다음 해당 값을 비교해야 합니다 . 두 값이 같지 않으면 해당 속성을 새 노드 의 값으로 다시 설정해야 합니다.
  • 다른 경우에는 속성이 변경되지 않았으므로 아무것도 할 필요가 없습니다.

좋아요. 다음은 하나의 prop에 대해 정확히 동일한 작업을 수행하는 함수입니다.

function updateProp($target, name, newVal, oldVal) {
  if (!newVal) {
    removeProp($target, name, oldVal);
  } else if (!oldVal || newVal !== oldVal) {
    setProp($target, name, newVal);
  }
}
간단하지 않았나요? 그러나 노드는 하나 이상의 속성을 가질 수 있으므로 모든 prop을 반복하고 각 쌍에 대해 updateProp(…) 함수 를 호출할 수 있는 함수를 작성해 보겠습니다 .
function updateProps($target, newProps, oldProps = {}) {
  const props = Object.assign({}, newProps, oldProps);
  Object.keys(props).forEach(name => {
    updateProp($target, name, newProps[name], oldProps[name]);
  });
}
여기서는 새 노드와 이전 노드의 소품을 모두 포함하는 복합 개체를 생성하고 있습니다. 따라서 props를 반복하는 동안 '정의되지 않은' 항목이 있을 수 있지만 괜찮습니다. 함수가 이를 처리할 수 있습니다.

마지막으로 해당 함수를 updateElement(…) 함수에 넣는 것입니다(이전 여정에서 기억하시나요?). 어디에 넣어야 할까요? 아마도 노드가 변경되지 않았고 그 자식을 비교하려는 경우 이전에 해당 속성을 확인하고 싶을 수도 있습니다. 따라서 하위 노드를 비교하기 직전에 마지막 `if` 절에 이를 넣습니다.

function updateElement($parent, newNode, oldNode, index = 0) {
  ...
  } else if (newNode.type) {
    updateProps(
      $parent.childNodes[index],
      newNode.props,
      oldNode.props
    );
 
    ...
  }
}
그리고 이것이다. 계속해서 테스트해 보세요.
/** @jsx h */

function h(type, props, ...children) {
  return { type, props: props || {}, children };
}

function setBooleanProp($target, name, value) {
  if (value) {
    $target.setAttribute(name, value);
    $target[name] = true;
  } else {
    $target[name] = false;
  }
}

function removeBooleanProp($target, name) {
  $target.removeAttribute(name);
  $target[name] = false;
}

function isCustomProp(name) {
  return false;
}

function setProp($target, name, value) {
  if (isCustomProp(name)) {
    return;
  } else if (name === 'className') {
    $target.setAttribute('class', value);
  } else if (typeof value === 'boolean') {
    setBooleanProp($target, name, value);
  } else {
    $target.setAttribute(name, value);
  }
}

function removeProp($target, name, value) {
  if (isCustomProp(name)) {
    return;
  } else if (name === 'className') {
    $target.removeAttribute('class');
  } else if (typeof value === 'boolean') {
    removeBooleanProp($target, name);
  } else {
    $target.removeAttribute(name);
  }
}

function setProps($target, props) {
  Object.keys(props).forEach(name => {
    setProp($target, name, props[name]);
  });
}

function updateProp($target, name, newVal, oldVal) {
  if (!newVal) {
    removeProp($target, name, oldVal);
  } else if (!oldVal || newVal !== oldVal) {
    setProp($target, name, newVal);
  }
}

function updateProps($target, newProps, oldProps = {}) {
  const props = Object.assign({}, newProps, oldProps);
  Object.keys(props).forEach(name => {
    updateProp($target, name, newProps[name], oldProps[name]);
  });
}

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  setProps($el, node.props);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === 'string' && node1 !== node2 ||
         node1.type !== node2.type
}

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parent.childNodes[index]
    );
  } else if (newNode.type) {
    updateProps(
      $parent.childNodes[index],
      newNode.props,
      oldNode.props
    );
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}

//---------------------------------------------------------

const f = (
  <ul style="list-style: none;">
    <li className="item">item 1</li>
    <li className="item">
      <input type="checkbox" checked={true} />
      <input type="text" disabled={false} />
    </li>
  </ul>
);

const g = (
  <ul style="list-style: none;">
    <li className="item item2">item 1</li>
    <li style="background: red;">
      <input type="checkbox" checked={false} />
      <input type="text" disabled={true} />
    </li>
  </ul>
);

const $root = document.getElementById('root');
const $reload = document.getElementById('reload');

updateElement($root, f);
$reload.addEventListener('click', () => {
  updateElement($root, g, f);
});
<button id="reload">RELOAD</button>
<div id="root"></div>
#root {
  border: 1px solid black;
  padding: 10px;
  margin: 30px 0 0 0;
}

.item {
  background: yellow;
  margin: 10px 0;
}

.item2 {
  background: green;
}

Events

물론 일반적인 동적 앱을 가지려면 이벤트를 처리하는 방법을 알아야 합니다. 이 시점에서 우리는 이미 클래스별로 노드를 querySelector(…) 한 다음 해당 노드에 EventListener(…)를 추가 할 수 있습니다. 그러나 그것은 흥미롭지 않습니다. 사실 저는 React처럼 smth를 갖고 싶습니다:

<button onClick={() => alert(‘hi!’)}></button>
응, 이거 괜찮아 보이는데. 보시다시피 여기서는 props를 사용하여 이벤트 리스너를 선언하고 있습니다. 그리고 속성 이름은 `on` 접두사로 시작합니다.
function isEventProp(name) {
  return /^on/.test(name);
}
소품 이름 에서 이벤트 이름을 추출하기 위해 다음 함수를 작성합니다(`on` 접두사만 제거함).
function extractEventName(name) {
  return name.slice(2).toLowerCase();
}


props 객체에서 이벤트를 선언하는 경우 setProps(..)/updateProps(…) 함수 에서 이벤트를 처리해야 하는 것 같습니다 . 하지만 다시 생각해 보세요. 실제로 기능을 어떻게 비교할 수 있습니까 ? 어떻게?

등호로 비교할 수 없습니다. 글쎄, toString() 메소드를 사용 하고 함수의 코드를 비교할 수 있습니다. 하지만 여기에는 몇 가지 문제가 있습니다. 내부에 네이티브 코드가 포함된 일부 함수가 있으므로 이러한 방식으로 비교할 수 없습니다.

물론 이벤트 버블링을 사용하여 이를 처리할 수 있습니다. ‘body’ 또는 루트 요소에 연결되고 내부 요소의 모든 이벤트를 처리할 자체 이벤트 관리자를 작성할 수 있습니다. 따라서 업데이트할 때마다 이벤트 리스너를 다시 추가할 수 있으며 비용이 많이 들지 않습니다.

하지만 여기서는 그렇게 하려고 하지 않을 것입니다. 이는 더 많은 문제를 추가하며 실제로 이벤트 리스너는 자주 변경되지 않습니다. 따라서 요소 생성 시 이벤트 리스너를 한 번만 설정해 보겠습니다 .

따라서 우리는 setProps(…) 함수가 실제 DOM 노드에 이 이벤트 소품을 설정하는 것을 원하지 않습니다 . 우리는 이벤트 리스너 추가를 직접 처리하려고 합니다. 호 이걸 하려고? 맞춤 소품을 확인하는 기능을 기억하시나요? 이제 비어 있지 않습니다.

function isCustomProp(name) {
  return isEventProp(name);
}




실제 DOM 노드를 알고 props 객체가 있는 경우 이벤트 리스너를 추가하는 것도 매우 간단합니다.

function addEventListeners($target, props) {
  Object.keys(props).forEach(name => {
    if (isEventProp(name)) {
      $target.addEventListener(
        extractEventName(name),
        props[name]
      );
    }
  });
}
이를 createElement 함수 에 넣어보겠습니다 .
function createElement(node) {
  if (typeof node === ‘string’) {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  setProps($el, node.props);
  addEventListeners($el, node.props);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}




이벤트 다시 추가

정말로 이벤트 리스너를 다시 추가해야 한다면 어떻게 될까요? 간단하게 설명하겠습니다. 매우 간단한 해결책이 하나 있습니다. 그러나 불행하게도 성능에 해를 끼칩니다. ‘forceUpdate’라는 사용자 정의 속성을 하나 더 소개하겠습니다. 노드가 변경되었는지 어떻게 확인하는지 기억하시나요? 이 기능을 수정하겠습니다.

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === ‘string’ && node1 !== node2 ||
         node1.type !== node2.type ||
         node.props.forceUpdate;
}
따라서 `forceUpdate`가 true이면 노드가 완전히 다시 생성되고 이에 따라 새 이벤트 리스너가 추가됩니다. 여기서도 처리해야 합니다. 왜냐하면 이 prop이 실제 DOM 노드에 설정되는 것을 원하지 않기 때문입니다.
function isCustomProp(name) {
  return isEventProp(name) || name === ‘forceUpdate’;
}
그리고 그게 거의 전부입니다. 예, 이 솔루션은 성능에 좋지 않습니다. 그러나 그것은 간단합니다 :))

계속해서 테스트해 보세요.

/** @jsx h */

function h(type, props, ...children) {
  return { type, props: props || {}, children };
}

function setBooleanProp($target, name, value) {
  if (value) {
    $target.setAttribute(name, value);
    $target[name] = true;
  } else {
    $target[name] = false;
  }
}

function removeBooleanProp($target, name) {
  $target.removeAttribute(name);
  $target[name] = false;
}

function isEventProp(name) {
  return /^on/.test(name);
}

function extractEventName(name) {
  return name.slice(2).toLowerCase();
}

function isCustomProp(name) {
  return isEventProp(name) || name === 'forceUpdate';
}

function setProp($target, name, value) {
  if (isCustomProp(name)) {
    return;
  } else if (name === 'className') {
    $target.setAttribute('class', value);
  } else if (typeof value === 'boolean') {
    setBooleanProp($target, name, value);
  } else {
    $target.setAttribute(name, value);
  }
}

function removeProp($target, name, value) {
  if (isCustomProp(name)) {
    return;
  } else if (name === 'className') {
    $target.removeAttribute('class');
  } else if (typeof value === 'boolean') {
    removeBooleanProp($target, name);
  } else {
    $target.removeAttribute(name);
  }
}

function setProps($target, props) {
  Object.keys(props).forEach(name => {
    setProp($target, name, props[name]);
  });
}

function updateProp($target, name, newVal, oldVal) {
  if (!newVal) {
    removeProp($target, name, oldVal);
  } else if (!oldVal || newVal !== oldVal) {
    setProp($target, name, newVal);
  }
}

function updateProps($target, newProps, oldProps = {}) {
  const props = Object.assign({}, newProps, oldProps);
  Object.keys(props).forEach(name => {
    updateProp($target, name, newProps[name], oldProps[name]);
  });
}

function addEventListeners($target, props) {
  Object.keys(props).forEach(name => {
    if (isEventProp(name)) {
      $target.addEventListener(
        extractEventName(name),
        props[name]
      );
    }
  });
}

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  setProps($el, node.props);
  addEventListeners($el, node.props);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === 'string' && node1 !== node2 ||
         node1.type !== node2.type ||
         node1.props && node1.props.forceUpdate;
}

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parent.childNodes[index]
    );
  } else if (newNode.type) {
    updateProps(
      $parent.childNodes[index],
      newNode.props,
      oldNode.props
    );
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}

//---------------------------------------------------------

function log(e) {
  console.log(e.target.value);
}

const f = (
  <ul style="list-style: none;">
    <li className="item" onClick={() => alert('hi!')}>item 1</li>
    <li className="item">
      <input type="checkbox" checked={true} />
      <input type="text" onInput={log} />
    </li>
    {/* this node will always be updated */}
    <li forceUpdate={true}>text</li>
  </ul>
);

const g = (
  <ul style="list-style: none;">
    <li className="item item2" onClick={() => alert('hi!')}>item 1</li>
    <li style="background: red;">
      <input type="checkbox" checked={false} />
      <input type="text" onInput={log} />
    </li>
    {/* this node will always be updated */}
    <li forceUpdate={true}>text</li>
  </ul>
);

const $root = document.getElementById('root');
const $reload = document.getElementById('reload');

updateElement($root, f);
$reload.addEventListener('click', () => {
  updateElement($root, g, f);
});
<button id="reload">RELOAD</button>
<div id="root"></div>
#root {
  border: 1px solid black;
  padding: 10px;
  margin: 30px 0 0 0;
}

.item {
  background: yellow;
  margin: 10px 0;
}

.item2 {
  background: green;
}

결론

그게 다입니다 :)) 그것이 당신에게 흥미로웠기를 바랍니다. 이벤트 리스너를 비교하는 간단한 방법을 알고 있다면 의견을 통해 아이디어를 공유해 주시면 좋을 것입니다.

다음 글에서는 Virtual DOM을 위한 커스텀 컴포넌트 시스템을 작성해보겠습니다 :))

Comments

No comments yet. Why don’t you start the discussion?

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다