본문 바로가기

FrontEnd

Svelte 코드 컴파일러는 어떻게 동작할까?

반응형

Svelte

스벨트는 내부적으로 Virtual Dom을 사용하지 않는 것으로 알고 있습니다.

그렇다면 컴파일러가 상당히 많은 일을 해줘야 할텐데, 어떻게 이것이 가능할 것일까요?

 

요새 Vue3을 이용해 신규 프로젝트를 진행하고 있습니다.

춣시되면 아마 한국에서 Vue3을 이용한 하이브리드 앱 중 가장 큰 규모의 엔터프라이즈 애플리케이션이 될 것 같습니다.

(이 글을 읽는 어려분도 제가 개발하고 있는 앱을 신규 버전으로 사용하게 될 확률이 상당히 높습니다. 이미 엄청 유명한 앱이거든요)

 

원래 리액트만 활용했던 만큼,

리액트의 불변 모델과 온리 코드 모델, 함수형 사고 방식과 다른 Vue3의 라이프사이클과 가변 모델, 탬플릿에 적응해야 했는데요.

 

Vue3는 탬플릿을 사용하는 대신 컴파일러 레벨에서 상당한 최적화를 제공해 줍니다.

https://vuejs.org/guide/extras/rendering-mechanism.html#compiler-informed-virtual-dom

 

Rendering Mechanism | Vue.js

 

vuejs.org

React처럼 반드시 Root부터 Virtual Dom을 순회할 필요가 없죠

 

그렇다면 Svelte는 어떻게 VDOM 조정(Reconcilation/Patch/Mount)을 수행할까요?

Medium에서 관련된 글을 찾아 읽고 정리해 보았습니다.

 

Introduction

대부분의 프론트엔드 프레임워크는 시각적 DOM을 메모리 내 DOM 복사본(Virtual DOM)과 동기화하는 diffing 엔진에 의존합니다.
Svelte는 다릅니다.
스벨트 컴파일러는 시각적 트리를 직접 업데이트하는 코드(JavaScript)를 생성합니다.
<h1>Hello World</h1>와 같은 html을 다음과 같이 변환하는 것과 유사합니다.
const element = document.createElement('h1')
element.textContent = "Hello World"
document.body.appendChild(element)

이걸 이렇게 해야 하는 이유는 뭘까요?
바로 데이터 바인딩 때문입니다. (aka. 선언적 UI)

 

즉, <h1>{someValue}</h1>를 선언적으로 작성할 수 있으며
someValue가 변경될 때마다 element.textContent = someValue와 같은 명령문을 작성할 필요가 없습니다.
Svelte는 우리를 위해 동기화 코드를 생성합니다.


스벨트 컴파일러는 어떻게 동작하나요?

컴파일러는 .svelte 파일을 가져와 AST 추상 구문 트리(Abstract Syntax Tree)로 구문 분석하고
트리를 분석하고 Javascript 및 CSS를 생성합니다.

Disclamer: 아래 예제는 간결함을 위해 단순화되었습니다.

태그 파싱

.svelte 파일의 구조는 .html 파일과 유사합니다.
<script>// js goes here</script>

<style>/* css goes here */<style>

<!-- More (visual) html tags here -->
<h1>...</h1>
<p>...</p>
  • 첫 번째 단계는 문서를 구문 분석한 다음 (html 처리와 유사한 로직)
  • 태그에 대한 3개의 버킷(<script> 태그, <style> 태그 및 시각적 태그(기타 모든 것))을 만드는 것입니다.

CSS 파싱

<style> 태그는 각 CSS 규칙에 고유한 접두사를 추가할 수 있도록 구문 분석됩니다.
예를 들어 아래 코드는

h1 {
  color: teal;
}

다음으로 변합니다:

h1.random-code-abc123 {
  color: teal;
}
다른 컴포넌트에 정의된 CSS 규칙과 충돌하지 않도록 고유한 접두사가 추가됩니다.
css-tree 패키지는 CSS를 살펴보고 표현식을 검사하는 데 사용됩니다.
import {parse, walk, generate} from 'css-tree'

// CSS 소스를 AST로 파싱함
const input = '.example { color: teal }'
const ast = parse(input)
const randomPrefix = 'xyz123'
const selectors = []

// AST를 순회하며 셀렉터를 처리함
walk(ast, node => {
  // 해당 노드가 셀렉터인지 확인
  if (node.type === 'Selector') {
    // 해당 노드를 저장해놓고 나중에 처리함
    selectors.push(node)
  }
})

// AST 조작
selectors.forEach(selector => {
  // 클래스 셀렉터에 랜덤 프리픽스 추가 `.xyz123`
  // it will turn `.example` into `.example.xyz123`
  selector.children.insertData({
    type: 'ClassSelector',
    name: randomPrefix
  })
})

// AST를 CSS(text)로 변환
const output = generate(ast)

// print the CSS text
console.log(output)
//> .example.xyz1234{color:teal}

자바스크립트 파싱하기

Svelte는 <script> 태그를 구문 분석하여
  • exports statement(props)을 추출합니다
  • 반응성 문(reactive statements)을 찾습니다
JavaScript 소스 코드는 acorn 패키지를 사용하여 AST로 변환됩니다.

예를 들어 prop export let name을 선언한다고 가정합니다.
모든 export let 문estree-walker를 사용하여 AST를 탐색해 찾을 수 있습니다.

import {parse} from 'acorn'
import {walk} from 'estree-walker'

// 2개의 export 된 프롭을 포함한 소스코드를 산안
const sourceCode = "export let title, color"

// parse the source code
// enable `sourceType: 'module'` since want to allow exports 
const ast = parse(sourceCode, {sourceType: 'module'})

// walk the AST
walk(ast, {
  enter(node) {
    // check if this node is a "named export"
    if (node.type === 'ExportNamedDeclaration') {
      // 명명된 export 노드에서 name 추출
      const props = node.declaration.declarations.map(declaration => declaration.id.name)
      // 프린트
      console.log(`We got props: ${props.join(', ')}`)
      //> We got props: title, color
    }
  }
})

시각적 태그 처리하기

나머지는 <p>,<h1>과 같은 시각적 태그입니다.

Svelte는 자체 태그 파서를 사용하지만 parse5를 사용하여 동일한 작업을 수행할 수 있습니다.
 
아래 소스는
import { parseFragment } from 'parse5'

const source = "<h1 class='snazzy'>Hello World!</h1>"
const fragment = parseFragment(source)

fragment.childNodes.forEach(node => {
  console.log(node)
})

다음과 같은 출력을 생성합니다.

{
  nodeName: 'h1',
  tagName: 'h1',
  attrs: [ { name: 'class', value: 'snazzy' } ],
  namespaceURI: 'http://www.w3.org/1999/xhtml',
  childNodes: [
    {
      nodeName: '#text',
      value: 'Hello World!',
      parentNode: ...
    }
  ] 
}
이것은 JavaScript 코드를 생성하는 데 사용할 HTML 문서의 전체 트리를 제공합니다.

모든 것을 조합하기

다음과 같은 간단한 .svelte 파일이 있다고 가정해 보겠습니다.
<script>
  export let name;

  function handleClick(e) {
    e.preventDefault()
    alert(`Hello ${name}!`)
  }
</script>

<h1 class="snazzy" on:click=handleClick>Hello {name}!</h1>
컴파일러는 다음과 같은 .js를 생성합니다.
// target: this is the target element to mount the component
// props: a list of props, defined with `export let`
export default function component({ target, props }) {
  // code generated to extract the props into variables:
  let { name } = props; 

  // all functions are copied directly from the <script> tag
  function handleClick(e) {
    e.preventDefault();
    alert(`Hello ${name}!`);
  }

  // variables are declared for each element and text node:
  let e0, t1, b2, t3;

  // returns an object with lifecycle functions to create, mount, detach and update the component. 
  return {
    // called when the components is created
    // creates elements/nodes, adds attributes and wires up event handlers
    create() {
      e0 = document.createElement("h1")
      t1 = document.createTextNode("Hello ")
      b2 = document.createTextNode(name)
      t3 = document.createTextNode("!")

      e0.setAttribute("class", "snazzy")
      e0.addEventListener("click", handleClick)
    },

    // called when the component is mounted to the `target`
    // it just appends things
    mount() {
      e0.appendChild(t1)
      e0.appendChild(b2)
      e0.appendChild(t3)

      target.append(e0)
    },

    // called to change the value of props
    update(changes) {
      // check if name changed
      if (changes.name) {
        // update `name` variable and all binding to `name`
        b2.data = name = changes.name
      }
    },

    // called to remove the component from the DOM
    detach() {
      e0.removeEventListener("click", handleClick)
      target.removeChild(e0)
    }
  };
}
이제 이 컴포넌트를 DOM에 마운트할 수 있습니다.
import MyComponent from './component'

// instantiate the component
const component = MyComponent({
  target: document.body,
  props: {name: "World"}
})

// create the nodes
component.create()

// append the nodes into the target
component.mount()

마치며

  • Svelte는 .svelte 파일을 구문 분석한 다음 JavaScript 파일을 생성하는 컴파일러입니다.
  • JavaScript 파일에는 컴포넌트를 마운트하고, 이벤트를 처리하고, 값이 변경될 때 DOM을 패치하는 논리가 포함되어 있습니다.

본문에 언급된 코드들은 https://github.com/joshnuss/micro-svelte-compiler에서 확인하실 수 있습니다.

 

물론 이게 svelte의 다는 아닙니다.

바닐라 JS로 프레임워크를 개발해 보신 적이 있으시면 아시겠지만,

컴포넌트 단위 리렌더링 최적화를 위해선 scheduler가 필요합니다.

(자식이 부모의 언마운트를 유발하는 경우 상태 변화 시점(자식 > 부모) 때문에 돔 조작 시 오류가 발생할 수 있고, 불필요한 함수가 실행될 수 있기 때문이죠)

그렇지 않으면 페이지 단위로 자식 컴포넌트를 포함해 전부 리렌더링 해야합니다.

(src/runtime/internal 에서 scheduler 모듈을 확인하실 수 있습니다.)

 

저 또한 실무에서 아직 svelte를 사용한 적이 없으므로

어떤 사용 사례에서 어떤 식으로 프레임워크가 최적화 하는지는 잘 모릅니다만,

svelte가 virtual dom없이 리렌더링을 최적화하는 방식에 대한 통찰을 얻기에는 적절한 분량인 것 같습니다.

 

내년에는 sveltekit을 좀 만져볼 예정입니다.

svelte는 후발주자인 만큼 여러 프레임워크들의 장점만을 흡수하였고,

sveltekit은 remix, nuxt, next의 장점을 쏙쏙 가져왔다고 하니 꽤 기대가 됩니다.

(또한 vite와 같은 storybook과 같은 툴도 쉽게 사용할 수 있다고 합니다!)

 

개인적으로 react 대신 vue를 사용하며 좋았던 부분은

  • 불필요한 최적화 관련 구문을 사용할 필요가 없으며
  • 때때로 불필요하게 장황해 질 수 있는 코드의 복잡도(렌더링 관심사)를 탬플릿으로 분산할 수 있다는 점이 좋았습니다.

대신 Vue3은 reactivity 시스템을 사용할 때 알아두어야 할 점(프록시 및 컴파일 매커니즘)이 생각보다 까다로웠는데

스벨트는 이 복잡도를 컴파일 타임에 처리하는(Vue3도 Reactivity Transform을 사용하려 했지만...)방안으로 해결합니다.

분명 svelte, 2023년 try 할 가치가 있는 프레임워크라고 생각합니다.

참고

https://dev.to/joshnuss/svelte-compiler-under-the-hood-4j20

 

The Svelte compiler: How it works

Learn how to the compiler works internally

dev.to

https://lihautan.com/the-svelte-compiler-handbook/

 

The Svelte Compiler Handbook | Tan Li Hau

Anyone who is interested in the Svelte compilation process wants to get started in reading Svelte source code The Svelte compilation process can be broken down into 4-steps Parsing source code into Abstract Syntax Tree (AST) Tracking references and depende

lihautan.com

https://lihautan.com/compile-svelte-in-your-head/

 

반응형