Skip to content

2019.08 Vue2 컴포넌트 구현

컴포넌트 사용 예제

컴포넌트 마운트

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<body>

<div id="app"></div>

<script type="module" src="./core/fp.js"></script>
<script type="module" src="./core/store.js"></script>
<script type="module" src="./core/helper.js"></script>
<script type="module" src="./core/component.js"></script>

<script type="module" src="./guide/ButtonComponent.js"></script>
<script type="module" src="./guide/CounterComponent.js"></script>

<script type="module" src="./guide/ReactiveComponent/NowComponent.js"></script>
<script type="module" src="./guide/ReactiveComponent/NowControllerComponent.js"></script>
<script type="module" src="./guide/ReactiveComponent/ParentComponent.js"></script>
<script type="module" src="./guide/ReactiveComponent/ChildComponent.js"></script>
<script type="module" src="./guide/ReactiveComponent.js"></script>

<script type="module" src="./guide/mount.js"></script>
</body>
</html>
js
import {ButtonComponent} from './ButtonComponent.js';
import {CounterComponent} from './CounterComponent.js';
import {ReactiveComponent} from './ReactiveComponent.js';
import {InputComponent} from './InputComponent.js';

document
  .querySelector('#app')
  .appendChild(ButtonComponent())
  .appendChild(CounterComponent())
  .appendChild(ReactiveComponent())
  .appendChild(InputComponent());

ButtonComponent

js
import {component} from '../core/component.js'

export const ButtonComponent = component({
  template() {
    return `<div>
      <button>Child Button</button>
    </div>`
  }
});

CounterComponent

js
import {component} from '../core/component.js'
import {createStore} from '../core/store.js';

const store = createStore({
  count: 0
});

export const CounterComponent = component({
  template() {
    const count = store.get('count');
    return `<div>
			<button class="up">Up</button>
			${count}
			<button class="down">Down</button>
		</div>`
  },
  events() {
    return [
      ['.up', 'onclick', 'upCount'],
      ['.down', 'onclick', 'downCount'],
    ]
  },
  methods() {
    return {
      upCount() {
        this.calcCount(1)
      },
      downCount() {
        this.calcCount(-1)
      },
      calcCount(num) {
        this.setCount(this.getCount() + num)
      },
      getCount() {
        return store.get('count')
      },
      setCount(count) {
        store.set('count', count)
      }
    }
  },
  created({render}) {
    store.watch('count', render)
  }
});

ReactiveComponent

js
import {component} from '../core/component.js';
import {NowComponent} from './ReactiveComponent/NowComponent.js';
import {NowControllerComponent} from './ReactiveComponent/NowControllerComponent.js';
import {ParentComponent} from './ReactiveComponent/ParentComponent.js';

export const ReactiveComponent = component({
  data() {
    return {
      now: Date.now()
    }
  },
  template() {
    return `<div>
      <now bind-props="now"></now>
      <now-controller on="changeNow"></now-controller>
      <parent-component></parent-component>
    </div>`
  },
  components() {
    return [
      ['now', NowComponent],
      ['now-controller', NowControllerComponent],
      ['parent-component', ParentComponent],
    ]
  },
  methods({store}) {
    return {
      changeNow(data, message) {
        console.log(message)
        store.set('now', data)
      },
    }
  }
})

NowComponent

js
import {component} from '../../core/component.js';

export const NowComponent = component({
  template({props}) {
    return `<h1>Now: ${props}</h1>`
  }
});

NowControllerComponent

js
import {component} from '../../core/component.js';

export const NowControllerComponent = component({
  template() {
    return `<div>
			<button class="now-btn">Change Now</button>
		</div>`
  },
  events() {
    return [
      ['button.now-btn', 'onclick', 'changeNow'],
    ]
  },
  methods({emit}) {
    return {
      changeNow() {
        emit(Date.now(), 'from Child')
      },
    }
  }
});

ParentComponent

js
import {component} from '../../core/component.js';
import {ChildComponent} from './ChildComponent.js';

export const ParentComponent = component({
  data() {
    return {
      message: 'From Parent'
    }
  },
  template() {
    return `<div>
      <h1>Parent</h1>
      <child-component 
        bind-props="message"
        on="onEmit"></child-component>
    </div>`
  },
  components() {
    return [
      ['child-component', ChildComponent]
    ]
  },
  methods({store}) {
    return {
      onEmit(message) {
        store.set('message', message)
      }
    }
  }
});

ChildComponent

js
import {component} from '../../core/component.js';

export const ChildComponent = component({
  template({props}) {
    return `<div>
      <h2>Child</h2>
      <p>message: ${props}</p>
      <button>Send Message</button>
    </div>`
  },
  events() {
    return [
      ['button', 'onclick', 'sendMessage']
    ]
  },
  methods({emit}) {
    return {
      sendMessage() {
        emit('Message From Child')
      }
    }
  }
});

컴포넌트 코어

fp.js

js
export const noop = () => {};
export const always = v => () => v;

store.js

js
export const ALL = '*';

export const createStore = (state = {}) => {
  const store = new Map();
  const subscriber = new Map();
  const set = mutation({store, subscriber});

  setDefaultState({state, set});

  return {
    set,
    delete: remove({store, subscriber}),
    get: getter({store}),
    watch: watch({subscriber}),
    watchAll: watchAll({subscriber}),
  }
};

const setDefaultState = ({state, set}) => {
  Object.entries(state).forEach(([key, value]) => set(key, value))
};

const mutation = ({store, subscriber}) => (key, value, noNotify = true) => {
  store.set(key, value);
  noNotify && notify({subscriber, store, key})
};

const remove = ({store, subscriber}) => key => {
  store.delete(key);
  notify({subscriber, store, key})
};

const notify = ({subscriber, key, store}) => {
  const data = store.get(key);
  if (subscriber.has(key)) {
    for (const listener of subscriber.get(key)) {
      listener(data)
    }
  }
  broadCast({subscriber, key, data})
};

const broadCast = ({subscriber, key, data}) => {
  if (subscriber.has(ALL)) {
    for (const listener of subscriber.get(ALL)) {
      listener({key, data})
    }
  }
};

const getter = ({store}) => key => store.get(key);

const watch = ({subscriber}) => (key, listener) => {
  let listeners;
  if (subscriber.has(key)) {
    listeners = subscriber.get(key)
  } else {
    listeners = []
  }
  listeners.push(listener);
  subscriber.set(key, listeners)
};

const watchAll = ({subscriber}) => {
  const watcher = watch({subscriber});
  return listener => {
    watcher(ALL, listener)
  }
};

helper.js

js
export const addEvent = (methods, methodName) => event => {
  methods[methodName].call(methods, event)
};

export const getElem = (selector, parent = document) => {
  return parent.querySelectorAll(selector)
};

export const getAttr = (elem, attr) => elem.getAttribute(attr);

component.js

js
import * as _ from './fp.js'
import {createStore} from './store.js';
import {addEvent, getAttr, getElem} from './helper.js';

/**
 * @param options
  {
    data = _.always({}),
    template = _.noop,
    template({data: state, props})
    components = _.always([]),
    methods = _.always([]),
    events = _.always([]),
    beforeCreate = _.noop,
    created = _.noop
  }
 */
export const component = (options) => ({props, emit} = {}) => {
  const {
    data = _.always({}),
    template = _.noop,
    components = _.always([]),
    methods = _.always([]),
    events = _.always([]),
    beforeCreate = _.noop,
    created = _.noop
  } = options
  const state = data({props})
  const store = createStore(state)

  beforeCreate({props, data: state})

  const render = create({
    state,
    template,
    components,
    methods,
    events,
    store,
  })
  const dom = render({props, emit})
  const renderFn = replaceWith({dom, render, props})

  created({
    dom,
    props,
    data: state,
    render: renderFn,
  })
  reactive({store, state, renderFn})
  return dom
}

const create = ({
                  state,
                  template,
                  components,
                  methods,
                  events,
                  store,
                }) => ({props, emit}) => {
  const dom = parseDOM(template({data: state, props}))
  const methodResult = methods({dom, data: state, props, store, emit})

  bindEvent(events(), methodResult, dom)
  bindComponent({
    dom,
    state,
    components: components(),
    methodResult
  })
  return dom
}

const replaceWith = (params) => () => {
// render는 create와 component 함수의 반환 함수, 즉 2가지다.
  const {dom, render, props, on} = params
  const newDom = render({props, emit: on})

  dom.replaceWith(newDom)
  Object.assign(params, {dom: newDom})
}

const reactive = ({store, state, renderFn}) => {
  store.watchAll(({key, data}) => {
    state[key] = data
    renderFn()
  })
}

export const parseDOM = (template) => {
  var tmp = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  tmp.innerHTML = template
  return tmp.children[0]
}

export const bindEvent = (events, methods, dom) => {
  for (const [selector, event, methodName] of events) {
    if (selector === 'document') {
      document[event] = addEvent(methods, methodName)
    } else {
      getElem(selector, dom).forEach(elem => {
        elem[event] = addEvent(methods, methodName)
      })
    }
  }
}

export const bindComponent = ({components, dom, state, methodResult}) => {
  for (const [selector, component] of components) {
    getElem(selector, dom).forEach(elem => {
      replaceWith({
        dom: elem,
        render: component,
        props: getProps(elem, state),
        on: getOn(elem, methodResult)
      })()
    })
  }
}

// props는 자식에게 전달할 데이터
const getProps = (elem, state) => {
  const bindProps = getAttr(elem, 'bind-props')
  const props = getAttr(elem, 'props')
  if (bindProps) {
    return state[bindProps]
  } else {
    if (props) {
      return props
    }
    return {}
  }
}

// on은 자식으로부터 전달받은 콜백
const getOn = (elem, methodResult) => {
  const attr = getAttr(elem, 'on')
  if (attr && methodResult) {
    return methodResult[attr]
  }
}