vue-lite-js
TypeScript icon, indicating that this package has built-in type declarations

1.4.1 • Public • Published

Clones Vue.js to implement a basic MVVM framework

✅ 한국어 | English

🚀 Introduction

현대적인 프레임워크들이 MVVM(Model-View-ViewModel) 패턴을 사용하여 효율적인 데이터 바인딩과 사용자 인터페이스 관리를 지원한다는 점에서 영감을 받아 이러한 MVVM 패턴을 기반으로 Vue.js를 클론하여 유사한 기능과 문법을 제공하는 기초적인 MVVM 프레임워크입니다.

이 저장소의 주된 목표는 Vue.js의 핵심 동작 방식을 클론하면서, MVVM 패턴과 핵심적인 옵저버 패턴을 적용해 보는 것입니다. 프로젝트의 전반적인 구조는 #Reference의 코드를 기반으로 하였으며 복잡한 문제를 고려하지 않았지만, 양방향 데이터 바인딩과 Vue.js의 핵심원리를 이해하는데 도움을 줄 수 있다고 생각합니다.

🎉 Getting Started

  • Using npm

vuelitenpm에서 설치하고 프로젝트에서 사용하려면, 다음 명령어를 실행하세요

npm install vue-lite-js@latest
  • Using cdn

브라우저에서 직접 사용하려면, 아래와 같이 cdn을 통해 스크립트를 포함하세요

<script src="https://unpkg.com/vue-lite-js@latest"></script>
  • Local Development

개발 환경에서 소스 코드를 수정하고 직접 테스트 하고싶으면, 다음 단계를 따라주세요

저장소 클론
git clone https://github.com/Heonys/vue-lite-js 
의존성 설치
npm install 
개발 서버 실행
npm run start 
테스트를 위한 마크다운 및 스크립트 작성
📦 vuelite 
├── 📂 dev 
│    ├── 📄 index.html ✅
│    └── 📄 index.ts ✅
├── 📂 src ✅
│    ├── 📂 core
│    ├── 📂 types
│    ... 

src폴더 에서 소스코드를 수정하고 dev폴더에서 마크다운과 스크립트 작성이 가능합니다.

💡Basic usage

Description of GIF
CDN Demo: https://vuelite-demo.vercel.app

<div id="app">
  <input type="text" v-model="message" />
  <p v-style="textStyle">{{ message }}</p>
  <button v-on:click="handleClick">change vuelite</button>
  <div>
    <input type="checkbox" v-model="checked" />
    <span>{{ isChecked }}</span>
  </div>
</div>
import Vuelite from "vue-lite-js";

new Vuelite({
  el: "#app",
  data() {
    return {
      message : "",
      checked: true,
      textStyle: { color: "#FF0000" },
    }
  },
  methods: {
    handleClick() {
      this.message  = "vuelite";
    },
  },
  computed: {
    isChecked() {
      return this.checked ? "checked" : "unchecked";
    },
  }
})

✨ Details

  • 기본적으로 Vue.jsOption API 방식을 클론하고 있으며, Vue.js의 핵심 기능을 지원하지만 모든 기능을 지원하지 않습니다.

  • 옵션에서 template 속성은 지원하지만, Vue.js.vue 확장자와 같은 로더를 지원하지 않기 때문에 HTML 파일에서 따로 마크업을 작성해야 하는 불편함이 있습니다. 따라서 템플릿을 분리해서 사용하는 방식은 전통적인 Vue.js보다는 Angular와 유사한 면이 있습니다.

  • 싱글 파일 컴포넌트 포맷을 지원하지 않는 이러한 특성 때문에 <style> 태그 형태를 지원하기 위해서 styles 속성을 지원합니다.

new Vuelite({
  // ... 
  styles: {    
    "#wrapper": {
      // only camelCase key
      width: "50%",
      background: "#ffa",
      cursor: "pointer",
    },
  },
})

🧩 Overview

Description of diagram

⭐ Workflow

1. viewmodel 생성

class Vuelite {
  constructor(options: Options) {
    this.el = document.querySelector(options.el);
    this.options = options;
    injectReactive(this);
    injectStyleSheet(this);
    const scanner = new VueScanner(new NodeVisitor());
    scanner.scan(this);
  }
}

MVVM 패턴에서 viewmodel 역할을 수행하는 vue 인스턴스의 생성 단계로 option 객체를 받아서 DOM과 데이터 바인딩을 제공할 수 있도록 하는 진입점 역할을 합니다.

Viewmodel을 구현하는 핵심 아이디어

  1. 옵션객체로 받아온 데이터에 반응성을 불어넣어 데이터의 변화를 감지
  2. DOM을 순회하며 디렉티브를 파싱하고 옵저버를 생성
  3. 반응형 데이터와 옵저버의 상호작용에 따른 양방향 바인딩을 달성

2. 반응성 주입

Description of diagram

// target: 래핑하려는 원본 객체
// handler: 동작을 가로채는 메서드인 '트랩(trap)'이 담긴 객체
new Proxy(target, handler);

Proxy 객체는 원본 객체를 감싸는 객체로 타겟이 되는 원본 객체에 대한 접근을 제어하거나, 특정 동작을 가로채서 새로운 기능을 추가할 수 있게 하는 래핑 객체입니다.

viewmodel에서 데이터의 변화를 감지하여 실제 뷰(DOM)와의 양방향 바인딩하는 것이 우리의 목적이기 때문에 데이터의 변화에 어떻게 감지할 수 있을지 고민해 봐야 하는데 이를 위해 자바스크립트에서는 언어 차원에서 객체의 속성에 동적으로 gettersetter를 등록할 수 있게 해주는 Object.defineProperty와 더불어 Proxy를 사용하여 이를 구현할 수 있습니다.

실제로 Vue2 에서는 defineProperty로 반응형 데이터를 구현하고, Vue3 에서는 Proxy를 사용하여 반응형 데이터를 구현합니다.

따라서 Proxy객체로 viewmodel의 모든 data 속성을 래핑하여 get 트랩, set 트랩을 추가하고 모든 속성들의 변화를 감지하도록 구현할 것입니다.

const handler = {
  get(target: Target, key: string, receiver: Target) {
    // 1. get 트랩 (getter)
  },
  set(target: Target, key: string, value: any, receiver: Target) {
    // 2. set 트랩 (setter)
  },
};
new Proxy(data, handler);

하지만 Proxy를 생성하는 현재 단계에선 getter, setter를 설명하기 난해한 부분이 존재하는데 헷갈리지 말아야 하는건 이 부분은 해당 속성에 접근하거나 해당 속성의 값을 수정할 때 작동하는 트랩으로 어차피 나중에 실행되는 부분으로 핵심적인 로직이긴 하지만, 지금 단계에선 그냥 getter, setter를 등록함으로써 Reactivty를 주입했구나 하고 생각하면 될 것 같습니다.

  1. get 트랩 Dep 객체를 생성하고 현재 활성화된 Observer와의 의존성을 연결하는 역할을 합니다. Scanner에서 디렉티브를 파싱하고 Observer를 생성할 때, 해당 디렉티브에 해당하는 expresionvm에서 찾는 과정에서 getter를 발생시키고 따라서 해당 expresion에 매핑되는 Dep이 생성되어 생성된 Obserber와의 연결이 맺어집니다.

  2. set 트랩 get 트랩은 옵저버가 생성될 때 이미 한번은 실행되었기 때문에 이후에 set 트랩에서는 항상 해당하는 key에 대응하는 Dep과 매핑되어 있습니다. setter가 발생한 시점은 해당 속성 값의 변화가 일어났다는 뜻으로 notify를 호출함으로써 해당 Dep을 구독하고 있는 모든 Observer들에게 너가 의존하고 있는 속성에 변화가 일어났으니 update를 하라고 알림을 보내는 역할을 합니다.

Dep 객체 생성

Dep객체는 Dependency의 약자로 데이터의 변화를 감지하고, 구독자인 Observer들에게 알림을 하는 역할을 합니다. Proxy를 생성할 때 데이터의 모든 속성마다 Dep객체가 생성되는 것으로도 알 수 있지만 모든 반응형 데이터들은 매핑되는 Dep을 가지고 있습니다. 여기서 Dep 인스턴스 자체는 매핑된 반응형 데이터에 대한 상태를 가지고 있지 않으며, 이는 Reactivtydefine메소드에서 내부적으로 deps라는 이름으로 keyDep를 매핑하여 관리하고 있기 때문에 나중에 setter가 동작할 때 클로저 공간에 있는 deps에 접근하여 매핑되는 key가 뭔지 알 수 있기 때문에 Dep 자체는 자기가 매핑된 키에 대한 상태를 갖고 있지 않고 notify를 할 수 있습니다.

class Dep {
  static activated: Observer
  //...
}

activated속성은 현재 활성화된 옵저버가 무엇인지 상태를 갖고 DepObserber의 의존관계를 맺기 위한 static변수로 일종의 전역변수 같은 느낌으로 사용됩니다.

computed와 methods 주입

injectMethod(vm);
injectComputed(vm);

DOM과 바인딩이 되어야 하는 data들과는 다르게 computedmethod들은 반응성을 주입할 필요가 없습니다. 따라서 viewmodel에서 접근할 수 있도록 viewmodel의 속성으로 등록해 주면 되는데 핵심은 this 바인딩을 통해 computed 또는 method내부에서 this를 사용할 때 thisvm을 가리키도록 명시적으로 지정해 줍니다.

3. 디렉티브 및 템플릿 파싱

const scanner = new VueScanner(new NodeVisitor());
scanner.scan(this);

옵션에서 전달받은 el 속성으로부터 하위의 모든 노드를 순회하면서 v-접두사가 붙은 디렉티브 속성 또는 템플릿 문법 {{ }} 을 사용하고 있는 모든 텍스트를 검사합니다. 여기서 DOM을 순회하는게 아닌 Node 단위로 순회하는 이유는 템플릿을 파싱하기 위해 텍스트 노드까지 검사해야하기 때문입니다.

노드 순회를 위해 순회하는 역할 자체는 Visitor에게 위임하고 노드마다 처리할 구체적인 액션은 Scanner에서 처리하도록 VisitorScanner를 분리합니다.

const action = (node: Node) => {
  isReactiveNode(node) && new Observable(vm, node);
};

모든 노드를 순회하면서 해당 노드가 디렉티브를 갖거나 텍스트에 템플릿 문법을 가졌는지를 확인하고 Observable 생성합니다.

여기서 Observable은 단순히 v-접두사를 갖는 디렉티브인지 템플릿인지의 여부만 확인하여 Directive를 생성하고, 템플릿 바인딩은 v-text 디렉티브로 변경됩니다. 이때, 이벤트를 등록하는 v-on을 제외하고 모든 디렉티브는 디렉티브 종류에 따라서 updater를 인자로 받아서 v-bind 에서 일괄적으로 Observer를 생성합니다.

4. v-model 바인딩

Vue.jsv-model 디렉티브는 양방향 데이터 바인딩을 아주 간단하게 구현할 수 있게하는 디렉티브로 사용자 입력을 vue 인스턴스의 데이터와 자동으로 동기화합니다. 따라서 사용자의 입력을 받는 UI 요소들인 input, textarea, select 요소에서 사용됩니다.

<!-- v-model을 사용한 양방향 바인딩 -->
<div>
  <input type="text" v-model="title">
  <div>{{ title }}</div>
</div>`;

<!-- 단방향 바인딩 + 이벤트 핸들러 -->
<div>
  <input 
    type="text" 
    v-bind:value="title" 
    v-on:input="handleInput"
  >
  <div>{{ title }}</div>
</div>

실제로 v-model은 위의 코드처럼 v-bindv-on:event의 조합으로 동일하게 동작하며 vuelite 에서도 이러한 두가지 방식을 모두 지원합니다.

<input type="checkbox" v-model="isChecked">

<input type="radio" name="gender" value="male" v-model="selectedOption">
<input type="radio" name="gender" value="female" v-model="selectedOption">

<select v-model="selectedRadio">
  <option value="javascript">javascript</option>
  <option value="python">python</option>
</select>

v-model을 구현할 때 문제는 각각의 요소마다 바인딩되는 값이 value, checked 등으로 다를 뿐더러 같은 checked 속성에 바인딩 하더라도 checkboxradio 버튼은 동작 방식이 다르고, 이벤트도 change, input 처럼 달라지기 때문에 요소의 값이나 상태를 통일된 방식으로 접근할 수 있게해서 일관되게 바인딩하게 해줄 필요가 있습니다.

따라서 Directive 클래스에서 v-model을 처리할때는 이러한 요소들 또는 타입에 따라서 일관되게 사용할 수 있게 분기처리하여 updater와 이벤트리스너를 등록합니다.

5. Observer 생성

bind(updater?: Updater) {
  // ... 
  const value = evaluateValue(this.vm, this.exp);
  updater && updater(this.node, value);
  new Observer(this.vm, this.exp, (value) => {
    updater && updater(this.node, value);
  });

디렉티브 종류에 따라서 updater가 정해지고 결과적으로 Obserber가 생성됩니다. 여기서 updaterReactive가 주입된 속성에서 변화가 일어나 set 트랩에서 notify가 발생했을 때 해당 dep을 구독하고 있는 모든 Observer들에게 변화가 일어났음을 알리고 업데이트를 요청하는 구체적인 업데이트 함수를 의미합니다. 즉, Observer는 변화에 대응하여 DOM을 업데이트하고 따라서 viewmodeldata 변화가 최종적으로 화면에 반영됩니다.

위의 코드에서 Observer를 생성하기 전에 updater를 미리 한번 실행하는데 이건 첫 렌더링에 viewmodel의 속성을 DOM에 반영하기 위함입니다.

Observer와 Dep의 관계

서로가 서로를 컬렉션으로 관리하는 다대다의 관계를 갖습니다.

Dep의 입장에서는 여러 개의 디렉티브에서 같은 속성을 사용할 수 있기 때문에 여러 Observer들을 관리하는 것이고, 반대로 하나의 디렉티브에서 여러 개의 반응형 데이터에 의존할 수 있기 때문에 Observer는 여러 Dep을 가질 수 있습니다.

옵션에서 전달한 data 들은 모두 1:1로 매핑되는 Dep가 생성되고, 반대로 모든 디렉티브는 1:1로 매핑되는 Observer가 생성되어 그 둘이 상호작용 한다고 생각하면 됩니다.

getterTrigger

// Observer
getterTrigger() {
  Dep.activated = this;
  const value = evaluateValue(this.vm, this.exp);
  Dep.activated = null;
  return value;
}
// Dep 
depend() {
    Dep.activated?.addDep(this);
}

Observer 클래스에는 getterTrigger 메소드가 존재하는데 이 메소드의 역할은 단순히 vm에서 해당 속성을 가져오는 일을 하고 있어 보이지만, 이 함수는 그 이상으로 중요한 역할을 하고 있습니다.

  1. 처음에 Reactivty 클래스에서 모든 data 속성에 래핑한 프록시 객체의 get 트랩을 의도적으로 발생시키기 위한 트리거로 사용됩니다.
  2. get 트랩이 발생되기 이전에 Dep.activated를 현재의 this 즉, 현재의 Observer로 설정을 해놓고 get 트랩이 발생하면 dep.depend()를 호출하여 현재 활성화된 ObserverDep의 관계를 구축합니다.

결과적으로 getterTrigger는 반응형 데이터의 get 트랩을 발생시켜서 Dep 객체를 생성하며, 값을 가져옴과 동시에 이렇게 만들어진 Dep객체가 Observer와의 관계를 맺어주는 중요한 역할을 합니다.

  • 정리하자면

ObserberDep을 구독하여 기다리고 DepObserber에게 감시당하다가 Dep이 자신의 변화가 발생했을 때 구독자(Observer)들에게 변화를 통지하는 관계

📝 Todos

  • [x] methods, computed 내부에서 this의 타입추론 및 자동완성 개선 <1.1.0>
  • [x] 디렉티브 축약 형태 지원하기 <1.2.1>
  • [x] 템플릿 문법에서 표현식 지원하기 <1.3.0>
  • [ ] 조건부 렌더링 추가 (v-if/else, v-show 디렉티브)
  • [ ] 리스트 렌더링 추가 (v-for 디렉티브)
  • [ ] created, mounted, updated 등의 라이프사이클 훅 지원하기
  • [ ] watch 지원하기
  • [ ] 뷰모델 분리하기 (중첩될 수 있기 때문에 부모·자식 관계 추가)
  • [ ] props, children 지원하기 (v-slot)
  • [ ] Angular 처럼 템플릿과 스타일을 분리하여 주입하는 방식을 지원 (데코레이터)
  • [ ] 부분적으로 Composition API 지원하기

📖 Reference

Readme

Keywords

Package Sidebar

Install

npm i vue-lite-js@1.4.1

Version

1.4.1

License

MIT

Unpacked Size

122 kB

Total Files

29

Last publish

Collaborators

  • jihonest