ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Web] 순수 자바스크립트로 양방향 데이터 바인딩 구현(Pure Javascript Two-way Data binding)
    frontend/javascript&web 2023. 6. 12. 12:15

    오늘은 순수 자바스크립트를 이용해 데이터 양방향 바인딩을 구현해 보도록 하겠습니다. 종종 레거시한 시스템에서 개발을 진행하다보면 최신 프레임워크의 편리함을 다시 깨닫게 되고, 사용하고 싶은 욕구가 엄청 커질때가 있습니다. 저는 금융권 IT 쪽에 종사하고 있고, 보수적인 개발성향과 분위기로 인해 레거시한 환경에서 개발하는 케이스가 상당히 많았습니다. 그렇다 보니 데이터 하나를 연결할 때에도 일일이 이벤트 처리를 해주는 케이스가 많았고, 이러한 개발 환경은 개발자의 퍼포먼스를 매우 떨어트린다고 생각했습니다.

     

    그래서 전 순수 자바스크립트로 양방향 바인딩을 구현하려고 하였고, 얼추 비슷하게 이를 구현하여 진행과정에 대해 설명을 드리고자합니다. 가장 먼저 콘셉입니다. 정적 파일로 구현이 된 케이스가 많았기 때문에 정적 html 환경에서 가장 잘 어울릴 것 같은 프레임워크를 선택하여야 되었고, 컴포넌트 기반의 UI구성이 아닌 정적인 풀페이지 작업에서는 vue와 같은 스타일의 환경이 가장 잘 어울린다고 생각해서, vue 2.0을 모티브로 하여 작업을 진행하였습니다.

     

    1. 구조

    양방향 바인딩을 가능하게 하는 순수 자바스크립트 클래스 내에는 다음과 같은 내용이 필요하다고 생각하였습니다.

     

    1. element scope
    2. data
    3. methods
    4. mounted

    첫번째는 해당 스크립트의 스코프 어느 엘리먼트 내부의 스코프를 기준으로 동작하게 될 것인지에 대한 element scope

    두번째는 실제로 DOM과 연결되어 사용될 data

    세번째는 해당 data에 접근하여 실제 이벤트등을 실행하게 될 methods

    네번째는 스크립트가 모두 로딩이 되고 나서 최초로 수행할 mounted

     

    2. 구현

    2.1 클래스 생성

    제가 다니는 회사의 앞글자를 따서 Bframe이라 명시한 Class를 생성하고 생성자를 가장 먼저 만들어 보겠습니다.

    class Bframe {
       constructor(options){
       }
    }

    Vue처럼 생성자에 옵션을 받아서 내용을 정의하고 사용할 예정이라 위와 같이 작성하였습니다. 이제 그럼 하나씩 필요한 내용을 채워가 보겠습니다.

     

    2.2 데이터 구현

    데이터는 데이터 변경시 DOM의 업데이트가 이루어 져야 되기 때문에 Proxy라는 객체를 사용할 것입니다. MDN에서는 프록시를 다음과 같이 정의하고 있습니다.  'Proxy 객체를 사용하면 한 객체에 대한 기본 작업을 가로채고 재정의하는 프록시를 만들 수 있습니다.' 이처럼 Proxy를 사용하면 Object의 set, get에 대한 동작을 처리할 수 있기 때문에 데이터 변경 시 DOM 변경을 시켜주기에 최적의 객체라고 생각하였습니다.

    new Proxy(타겟, 핸들러);

    프록시는 위와 같이 프록시할 타겟 과 get, set 작업을 가로채서 동작시킬 핸들러로 이루어져 있습니다.  이를 토대로 하여 우리의 데이터를 구현해 보도록 하겠습니다.

    class Bframe {
       constructor(options){
          this.$options = options;
          this.$el = document.querySelector(options.el);
          this.$data = new Proxy(options.data, this.getHandler());
       }
       getHandler(){
          return {
             get:(target,key,receiver) => Reflect.get(target,key,receiver),
             set:(target,key,value,receiver) => {
                const result = Reflect.set(target,key,value,reciever);
                return result;				
             }
          }
       }
    }

    여기서 Reflect라는 것을 사용하였는데, 프록시 객체를 사용할때 상속이나 기타 환경에 따라 기대했던 호출 기대값과 다른 값이 나오는 케이스가 있는데 이런 것을 미연에 방지하고자 Reflect를 사용하였습니다. 자세한 내용은 MDN Proxy내용에 Reflect에 대해서 잘 나와있습니다.

     

    2.3 DOM 업데이트

    이제 데이터 변경을 감지 까지 하였으면 이를 DOM과 연계하는 작업이 필요합니다. getHandler의 set가운데 이를 포함시켜 두면 데이터가 변경될 때 DOM이 변경되는 구조를 만들 수 있습니다.

    class Bframe {
       constructor(options){
          this.$options = options;
          this.$el = document.querySelector(options.el);
          this.$data = new Proxy(options.data, this.getHandler());
       }
       getHandler(){
          return {
             get:(target,key,receiver) => Reflect.get(target,key,receiver),
             set:(target,key,value,receiver) => {
                const result = Reflect.set(target,key,value,reciever);
                this.updateDOM(key);
                return result;				
             }
          }
       }
       updateDOM(key){
          const bindings = Array.from(this.$el.querySelectorAll(`[b-model=${key}]`));
          bindings.forEach((element)=>{
             this.updateElement(element,this.$data[key]);
          })
       }
       updateElement(element,value){
          if(element.tagName === "INPUT" || element.tagName === "TEXTAREA"){
             if(element.type != "radio" && element.type != "checkbox"){
                element.value = value;
             }else{
                if(element.value === value){
                   element.checked=true;
                }
             }
          }else{
             element.textContent = value;
          }
       }
    }

    updateDOM은 element내부에서 b-model이라는 attribute를 가진 엘리먼트를 찾는 함수이며, 이 내부에선 updateElement 함수는 이를 기반으로 하여 태그가 INPUT이나 TEXTAREA인지 체크를 하고 엘리먼트 value에 값을 셋팅하거나 그 외엔 textContent에 값을 뿌려주는 동작을합니다.

     

    2.4 메소드 바인딩

    객체의 데이터에 편하게 접근하여 사용할 수 있는 메소드들을 정의해보겠습니다. 

    class Bframe {
       constructor(options){
          this.$options = options;
          this.$el = document.querySelector(options.el);
          this.$data = new Proxy(options.data, this.getHandler());
          this.$methods = this.bindMethods(options.methods, this);
       }
       
       getHandler(){
          return {
             get:(target,key,receiver) => Reflect.get(target,key,receiver),
             set:(target,key,value,receiver) => {
                const result = Reflect.set(target,key,value,reciever);
                this.updateDOM(key);
                return result;				
             }
          }
       }
       
       bindMethods(methods, context) {
          let boundMethods = {};
          for (let key in methods) {
             boundMethods[key] = methods[key].bind(context);
          }
          return boundMethods;
       }
       
       updateDOM(key){
          const bindings = Array.from(this.$el.querySelectorAll(`[b-model=${key}]`));
          bindings.forEach((element)=>{
             this.updateElement(element,this.$data[key]);
          })
       }
       
       updateElement(element,value){
          if(element.tagName === "INPUT" || element.tagName === "TEXTAREA"){
             if(element.type != "radio" && element.type != "checkbox"){
                element.value = value;
             }else{
                if(element.value === value){
                   element.checked=true;
                }
             }
          }else{
             element.textContent = value;
          }
       }
    }

    bindMethods함수는 간단합니다. 옵션내의 메소드 등을 loop를 돌며 메소드를 바인딩 합니다. context에는 this를 정의하여 객체의 데이터에 쉽게 접근이 가능하도록 되있습니다.

     

    2.5 DOM 초기화

    이제 우리는 DOM을 초기화 하는 작업을 진행해보겠습니다. DOM 초기화에서 할 작업은 다음과 같습니다.

     

    1. 초기데이터 있을 시에 엘리먼트에 데이터 바인딩
    2. 이벤트 리스너 추가

    위와 같은 작업을 처리하게 될 initializeDOM이라는 함수를 작성해보겠습니다.

    class Bframe {
       constructor(options){
          this.$options = options;
          this.$el = document.querySelector(options.el);
          this.$data = new Proxy(options.data, this.getHandler());
          this.$methods = this.bindMethods(options.methods, this);
          this.initializeDOM();
       }
       
       getHandler(){
          return {
             get:(target,key,receiver) => Reflect.get(target,key,receiver),
             set:(target,key,value,receiver) => {
                const result = Reflect.set(target,key,value,reciever);
                this.updateDOM(key);
                return result;				
             }
          }
       }
       
       initializeDOM() {
          const bindings = Array.from(this.$el.querySelectorAll("[b-model]"));
          bindings.forEach((element) => {
             const key = element.getAttribute("b-model");
             this.updateElement(element, this.$data[key]);
             if (element.tagName == "INPUT" || element.tagName == "TEXTAREA") {
                if (element.type != "radio" && element.type != "checkbox") {
                   element.addEventListener("input", (e) => {
                      this.$data[key] = e.target.value;
                   });
                } else {
                   element.addEventListener("click", (e) => {
                      this.$data[key] = e.target.value;
                   });
                }
             }
          });
       }
    
       bindMethods(methods, context) {
          let boundMethods = {};
          for (let key in methods) {
             boundMethods[key] = methods[key].bind(context);
          }
          return boundMethods;
       }
       
       updateDOM(key){
          const bindings = Array.from(this.$el.querySelectorAll(`[b-model=${key}]`));
          bindings.forEach((element)=>{
             this.updateElement(element,this.$data[key]);
          })
       }
       
       updateElement(element,value){
          if(element.tagName === "INPUT" || element.tagName === "TEXTAREA"){
             if(element.type != "radio" && element.type != "checkbox"){
                element.value = value;
             }else{
                if(element.value === value){
                   element.checked=true;
                }
             }
          }else{
             element.textContent = value;
          }
       }
    }

    위와 같이 initializeDOM을 작성하면 초기 실행시 데이터 바인딩과 이벤트 할당이 이루어지게 됩니다. 마지막으로 mounted를 작성해 보겠습니다.

     

    2.6 Mounted

    이것은 모든 전처리 과정이 끝나고 실행되는 마운팅 함수 입니다. 마지막에 runMounted를 선언해주고 runMounted안에서 mounted안에 있는 함수를 call하여주면 됩니다.

    class Bframe {
       constructor(options){
          this.$options = options;
          this.$el = document.querySelector(options.el);
          this.$data = new Proxy(options.data, this.getHandler());
          this.$methods = this.bindMethods(options.methods, this);
          this.$mounted = options.mounted;
          this.initializeDOM();
          this.runMounted();
       }
       
       getHandler(){
          return {
             get:(target,key,receiver) => Reflect.get(target,key,receiver),
             set:(target,key,value,receiver) => {
                const result = Reflect.set(target,key,value,reciever);
                this.updateDOM(key);
                return result;				
             }
          }
       }
       
       initializeDOM() {
          const bindings = Array.from(this.$el.querySelectorAll("[b-model]"));
          bindings.forEach((element) => {
             const key = element.getAttribute("b-model");
             this.updateElement(element, this.$data[key]);
             if (element.tagName == "INPUT" || element.tagName == "TEXTAREA") {
                if (element.type != "radio" && element.type != "checkbox") {
                   element.addEventListener("input", (e) => {
                      this.$data[key] = e.target.value;
                   });
                } else {
                   element.addEventListener("click", (e) => {
                      this.$data[key] = e.target.value;
                   });
                }
             }
          });
       }
    
       bindMethods(methods, context) {
          let boundMethods = {};
          for (let key in methods) {
             boundMethods[key] = methods[key].bind(context);
          }
          return boundMethods;
       }
       
       updateDOM(key){
          const bindings = Array.from(this.$el.querySelectorAll(`[b-model=${key}]`));
          bindings.forEach((element)=>{
             this.updateElement(element,this.$data[key]);
          })
       }
       
       runMounted() {
          if (this.$mounted) {
             this.$mounted.call(this);
          }
       }
       
       updateElement(element,value){
          if(element.tagName === "INPUT" || element.tagName === "TEXTAREA"){
             if(element.type != "radio" && element.type != "checkbox"){
                element.value = value;
             }else{
                if(element.value === value){
                   element.checked=true;
                }
             }
          }else{
             element.textContent = value;
          }
       }
    }

    이제 기초적인 양방향 바인딩을 자바스크립트로 구현해 보았습니다. 물론 Virtual DOM, 이벤트 재할당(동적 엘리먼트 추가시) 과 여러가지 기타 작업들이 더 추가되고 보완되어야 될 사항들이 많습니다.  가장 기본적인 구조만 잡았습니다.

     

    3. 사용

     

    <!DOCTYPE html>
    <html lang="en">
      <meta charset="UTF-8" />
      <title>류호진 테스트</title>
      <meta name="viewport" content="width=device-width,initial-scale=1" />
      <link rel="stylesheet" href="" />
      <style></style>
      <body>
        <div id="app">
          <input type="text" b-model="message" />
          <span b-model="message"></span>
          <input type="radio" name="aa" id="1" b-model="aa" value="1" />
          <input type="radio" name="aa" id="2" b-model="aa" value="2" />
          <button onclick="app.$methods.updateMessage()">Change Message</button>
        </div>
        <script src="./index.js"></script>
        <script>
          const app = new Bframe({
            el: "#app",
            data: {
              message: "잘되니?",
              count: 0,
              aa: "1",
            },
            methods: {
              updateMessage() {
                this.$data.message = "테스트!";
                this.nextTick(() => {
                  console.log("돔 변경");
                  console.log(this.$data)
                });
              },
            },
            mounted() {
              console.log("마운팅");
            },
          });
        </script>
      </body>
    </html>

    vue의 v-model처럼 attribute에 b-model이라는 attribute를 선언하여 이를 통해 vue와 유사하게 컨트롤 되는 모습을 볼 수 있습니다.

     

    위와 같이 사용을 하면 Vue와 유사한 형태로 프로젝트를 진행할 수 있다는 장점이 있습니다. 물론 SPA도 아니고 가상돔 처리도 아직은 되어있지 않지만 이러한 구조를 지속적으로 개선해 나가면 재밋는 개인 작업이 될 수 있다고 생각을 하고 있습니다.

     

    남은 작업은 다음와 같습니다. Virtual DOM, 이벤트 재할당, show/hide, element loop 등.. 많은 기본적인 과제들이 남아 있습니다. 추가적으로 더 작업이 이루어진 후에 다음편의 글로 찾아 뵙겠습니다.

     

    실제 코드를 한번 확인해보실 분은 아래 링크에 들어가시면 됩니다.

    https://github.com/ryuhojin/reactive-javascript

    반응형

    댓글

Designed by Tistory.