양방향 데이터 바인딩 사용법 (syn.$bind)
개요
syn.$bind는 순수 객체({}) 및 평면 객체 배열([{}, {}, ...])을 Proxy로 감싸서, 프로퍼티 값 변경/재할당, 배열 push·pop·splice·정렬, 배열 인덱스 직접 교체, 배열 내 객체 값 변경을 표준 JS 문법만으로 감지하고 화면에 반영하는 모듈입니다.
syn.uicontrols.$data가 syn-datafield/syn-options 메타 설정 기반으로 HandStack 전용 컨트롤(syn_grid, syn_data 등)을 바인딩하는 것과는 별개의 기능입니다. $bind는 syn-bind/syn-bind-list 속성만으로 표준 HTMLElement는 물론 AUIGrid, Handsontable, tail-select, daterangepicker 같은 임의의 커스텀 컨트롤까지 get/set/on/off 4개 함수(어댑터) 구현만으로 가볍게 양방향 연결하고 싶을 때 사용합니다. 두 시스템은 서로 다른 HTML 속성을 사용하므로 같은 화면에서 함께 써도 충돌하지 않습니다.
로드 방법
syn.js가 로드되면 전역에 syn.$bind로 즉시 사용할 수 있습니다. 별도 활성화 설정이나 외부 라이브러리 의존성은 없습니다.
빠른 시작
<input type="text" syn-bind="value:user.name">
<span syn-bind="text:user.name"></span>
const mounted = syn.$bind.mount(document.body, { user: { name: '홍길동' } });
mounted.store.data.user.name = '김철수'; // 표준 JS 문법으로 값 변경 시 화면 자동 반영
mounted.destroy(); // 바인딩 해제
데이터 변경은 별도 API 없이 표준 JS 문법을 그대로 사용합니다.
store.data.user.name = '홍길동'; // 프로퍼티 값 변경
store.data.user = { name: '김철수' }; // 프로퍼티(객체) 재할당
store.data.items.push({ title: '신규' }); // 배열 push
store.data.items[2] = { title: '교체' }; // 배열 인덱스 직접 변경
store.data.items[2].title = '수정'; // 배열 내 객체 값 변경
store.data.items.splice(1, 1); // 배열 splice
주요 시나리오
선언적 바인딩 문법
syn-bind 속성에 타입1(인자1):경로1; 타입2(인자2):경로2 형태로 여러 바인딩을 세미콜론으로 나열할 수 있습니다. 내장 타입은 text, html, value, checked, radio, edit, show, hide, disabled, class(이름), attr(이름), style(속성), adapter(어댑터명)입니다.
<input type="checkbox" syn-bind="checked:user.active">
<span syn-bind="show:user.active">사용중</span>
<span syn-bind="hide:user.active">미사용</span>
리스트(배열) 반복 바인딩
syn-bind-list="경로" 컨테이너 안에 <template> 1개를 행 템플릿으로 두면, 배열의 push/pop/splice/인덱스 교체에 따라 자동으로 행이 추가·삭제·교체됩니다.
<tbody syn-bind-list="items">
<template>
<tr>
<td><input type="text" syn-bind="value:title"></td>
</tr>
</template>
</tbody>
커스텀 컨트롤 어댑터
표준 HTMLElement가 아닌 컨트롤은 get/set/on/off 4개 함수만 구현해 registerAdapter()에 등록하면, 동일한 syn-bind="adapter(어댑터명):경로" 문법으로 AUIGrid, Handsontable, tail-select, daterangepicker 같은 서드파티 컨트롤도 연결할 수 있습니다.
syn.$bind.registerAdapter('grid', {
get(el) { return el._grid.getData(); },
set(el, value) { el._grid.setData(value || []); },
on(el, handler) { el._grid.on('change', handler); }
});
<div id="grid" syn-bind="adapter(grid):items"></div>
저수준 API: createStore / mount / attach
createStore(data)는 DOM에 연결하지 않고 반응형 store만 만듭니다. mount(root, dataOrStore)는 store 생성과 DOM 스캔·바인딩을 한 번에 수행합니다. attach(root, store)는 이미 있는 store를 다른 DOM 영역에 추가로 연결만 하는 저수준 API입니다.
const store = syn.$bind.createStore({ count: 0 });
const mounted = syn.$bind.mount(document.body, store);
syn.$bind.attach(document.getElementById('extra'), store);
store 조회/변경/구독
store.get(path)/store.set(path, value)는 store.data.xxx 접근과 동일한 결과를 냅니다. store.subscribe(path, cb, { deep })은 경로 변경을 구독하며(반환값은 구독 해제 함수), deep: true면 하위 경로 변경까지 함께 수신합니다. store.batch(fn)은 splice처럼 한 번의 호출에서 여러 트랩 호출이 발생하는 연산을 감싸 구 독 콜백이 항상 연산 완료 후의 안정된 상태만 관찰하도록 합니다.
const unsubscribe = store.subscribe('items', (ev) => console.log(ev.type, ev.path, ev.value), { deep: true });
store.batch(() => { store.data.items.splice(1, 1); });
unsubscribe();
scope와 raw
store.scope(basePath)는 특정 경로를 기준으로 상대 경로만 쓰는 얇은 뷰(ScopedStore)를 만듭니다(리스트 바인딩이 각 행마다 내부적으로 사용하는 것과 같은 방식). syn.$bind.raw(value)/store.toRaw()는 프록시를 원본 순수 객체/배열로 되돌립니다.
const rowScope = store.scope('items[0]');
rowScope.set('title', '수정된 제목');
const rawItem = syn.$bind.raw(store.data.items[0]);
주의:
toRaw()로 얻은 원본 객체를 직접 수정하면 실제 값은 바뀌지만 Proxy의set트랩을 거치지 않아 이벤트가 발생하지 않고 화면도 갱신되지 않습니다. 화면까지 갱신하려면 항상store.data.xxx = ...형태로 Proxy를 통해야 합니다.
실전 예제 페이지
/sample/syn/binding.html 예제에서 다음 항목을 실습할 수 있습니다.
- 1~3번 섹션:
value/checked/radio/edit등 기본 바인딩 타입, 중첩 객체 재할당,syn-bind-list(push/pop/splice/index 교체/정렬) - 4~5번 섹션:
registerAdapter()로 만든 별점/토글/미니 그리드 데모, 실제 AUIGrid/Handsontable/tail-select/daterangepicker 어댑터 참고 코드 - 6번 섹션: 루트 구독(
store.subscribe('', cb, { deep: true }))으로 모든 변경 이벤트 로그 확인,store.toRaw()실시간 스냅샷 - 7번 섹션:
createStore,mount/attach,registerBinding,registerAdapter,raw,get/set,subscribe,batch,toRaw,scopeAPI 전체를 개별적으로 실습
주의 사항
- 순수 객체/평면 객체 배열 전용입니다.
Date,Map,Set등 다른 참조 타입은 관찰 대상이 아닙니다. - 동일한 원본 객체는 내부
WeakMap캐시로 Proxy를 재사용하므로, 같은 데이터를 여러 곳에서 바인딩해도 항상 동일한 Proxy 인스턴스를 참조합니다. syn-bind-list컨테이너에는 반드시<template>자식이 하나 있어야 하며, 없으면 오류가 발생합니다.adapter(어댑터명)사용 전에syn.$bind.registerAdapter()로 먼저 등록해야 합니다. 미등록 어댑터를 참조하면 오류가 발생합니다.syn.uicontrols.$data/syn-datafield기반 바인딩과는 별개의 시스템입니다. 서로 다른 HTML 속성을 사용하므로 같은 화면에서 함께 사용해도 충돌하지 않지만, 같은 데이터를 두 시스템에 동시에 연결하지는 않도록 주의하십시오.
관련 모듈
- API 상세는 아래 API 참조 섹션을 확인하세요.
API 참조
모듈 정보
| 항목 | 내용 |
|---|---|
| 전역 별칭 | syn.$bind |
| 예제 페이지 | /sample/syn/binding.html |
| 의존 모듈 | syn.$l(eventLog), syn.$object(getType) |
| 활성화 조건 | 없음. syn.js 로드 즉시 사용 가능. |
| HTML 속성 | syn-bind="타입(인자):경로"(세미콜론으로 다중 바인딩 나열), syn-bind-list="경로"(배열 반복 바인딩, <template> 자식 1개 필요), syn-bind-adapter(어댑터 이름을 별도 속성으로 지정하고 싶을 때) |
내장 바인딩 타입 (syn.$bind.bindings)
| 타입 | 인자 | 방향 | 이벤트 | 설명 |
|---|---|---|---|---|
text | - | 단방향(store→DOM) | - | el.textContent에 값을 반영. |
html | - | 단방향(store→DOM) | - | el.innerHTML에 값을 반영. |
show | - | 단방향(store→DOM) | - | 값이 참이면 display를 원래대로, 거짓이면 none. |
hide | - | 단방향(store→DOM) | - | show의 반대. |
disabled | - | 단방향(store→DOM) | - | el.disabled = !!value. |
class(이름) | 클래스명 | 단방향(store→DOM) | - | 값의 참/거짓에 따라 classList.toggle(이름, value). |
attr(이름) | 속성명 | 단방향(store→DOM) | - | 값이 null/undefined/false면 속성 제거, 아니면 setAttribute. |
style(속성) | CSS 속성명 | 단방향(store→DOM) | - | el.style[속성] = value. |
value | - | 양방향 | input(일반)/change(SELECT) | el.value 동기화. type="number"/"range"는 숫자로 변환해 store에 반영. |
checked | - | 양방향 | change | el.checked 동기화(체크박스). |
radio | - | 양방향 | change | el.checked = (el.value === String(value)), 변경 시 el.value를 store에 반영. |
edit | - | 양방향 | input | contenteditable 요소의 textContent 동기화. |
adapter(어댑터명) | 어댑터명(생략 시 syn-bind-adapter 속성 값 사용) | 양방향(어댑터 구현에 따름) | 어댑터의 on/off | registerAdapter()로 등록한 커스텀 컨트롤 어댑터를 연결. |
메서드
syn.$bind.createStore(data)
- 설명: 순수 객체/평면 객체 배열을 Proxy로 감싼 반응형
Store인스턴스를 생성합니다. DOM에 연결하지 않으며, 화면 반영이 필요하면subscribe()로 직접 연결하거나 이후attach()/mount()로 DOM에 붙여야 합니다. - 매개변수
이름 타입 필수 설명 data object | ArrayY 감쌀 원본 데이터(순수 객체 또는 평면 객체 배열). - 반환값:
Store - 예시
const store = syn.$bind.createStore({ count: 0 });
syn.$bind.mount(root, dataOrStore)
- 설명:
createStore+attach를 한 번에 수행합니다.root내부에서[syn-bind-list],[syn-bind]엘리먼트를 찾아 바인딩을 연결합니다. - 매개변수
이름 타입 필수 설명 root HTMLElement | DocumentFragmentY 바인딩 대상을 탐색할 루트 노드. dataOrStore object | Array | StoreY 순수 데이터를 넘기면 내부에서 createStore를 호출하고, 이미 만든Store를 넘기면 그대로 재사용. - 반환값:
{ store: Store, destroy(): void }—destroy()를 호출하면 이mount()호출로 생성된 모든 바인딩(DOM 이 벤트 리스너, store 구독)이 해제됩니다. - 예시
const mounted = syn.$bind.mount(document.body, { user: { name: '홍길동' } });mounted.store.data.user.name = '김철수';mounted.destroy();
syn.$bind.attach(root, store)
- 설명:
mount()의 저수준 버전으로, store를 새로 만들지 않고 이미 있는store를 다른 DOM 영역에 추가로 연결만 합니다. 여러 화면 영역이 같은 store를 공유할 때 사용합니다. - 매개변수
이름 타입 필수 설명 root HTMLElement | DocumentFragmentY 바인딩 대상을 탐색할 루트 노드. store StoreY 연결할 기존 store. - 반환값:
Array<Function>— 이attach()호출로 생성된 정리(cleanup) 함수 배열. 각 함수를 호출하면 해당 바인딩이 해제됩니다. - 예시
syn.$bind.attach(document.getElementById('extra'), mounted.store);
syn.$bind.registerBinding(type, handler)
- 설명:
syn-bind선언 문법에서 사용할 새로운 바인딩 타입을 등록합니다. 반드시mount()/attach()호출 이전에 등록해야 해당 타입이 인식됩니다. - 매개변수
이름 타입 필수 설명 type stringY syn-bind="타입:경로"에서 사용할 타입 이름.handler objectY { toDOM(el, value, arg), event?, fromDOM?(el) }.event를 생략하면 단방향(store→DOM) 바인딩이 되고, 문자열 또는(el) => string함수로 지정하면 해당 DOM 이벤트 발생 시fromDOM(el)결과를 store에 반영하는 양방향 바인딩이 됩니다. - 반환값: 없음
- 예시
syn.$bind.registerBinding('uppercase', {toDOM(el, v) { el.textContent = (v || '').toUpperCase(); }});<b syn-bind="uppercase:user.name"></b>
syn.$bind.registerAdapter(name, adapter)
- 설명: 표준 HTMLElement가 아닌 커스텀 컨트롤(AUIGrid, Handsontable, tail-select, daterangepicker 등)을
syn-bind="adapter(name):경로"문법으로 연결할 수 있도록 어댑터를 등록합니다.get/set은 필수이며,on/off를 생략하면 store→컨트롤 단방향 바인딩만 동작합니다. - 매개변수
이름 타입 필수 설명 name stringY 어댑터 이름. adapter objectY { get(el), set(el, value), on?(el, handler), off?(el, handler) }.get/set이 함수가 아니면 오류가 발생합니다. - 반환값: 없음
- 예시
syn.$bind.registerAdapter('grid', {get(el) { return el._grid.getData(); },set(el, value) { el._grid.setData(value || []); },on(el, handler) { el._grid.on('change', handler); }});
syn.$bind.raw(value)
- 설명: 트리 중간의 Proxy 값(예:
store.data.items[0])을 원본 순수 객체/배열로 변환합니다. Proxy가 아닌 값은 그대로 반환합니다. - 매개변수
이름 타입 필수 설명 value anyY 변환할 값. - 반환값:
any— Proxy면 원본 객체/배열, 아니면 입력값 그대로. - 예시
const rawRow = syn.$bind.raw(store.data.items[0]);
syn.$bind.bindings / syn.$bind.controlAdapters / syn.$bind.Store
- 설명: 각각 등록된 내장/커스텀 바인딩 타입 테이블, 등록된 커스텀 컨트롤 어댑터 테이블,
Store클래스 참조입니다.registerBinding/registerAdapter로 채워지는 내부 테이블을 직접 조회하거나,new syn.$bind.Store(data)처럼 클래스를 직접 사용할 때 참조합니다.
Store 인스턴스 메서드
createStore()/mount()가 반환하는 Store 인스턴스는 다음 메서드를 제공합니다.
store.data
- 설명: 원본 데이터를 감싼 최상위 Proxy입니다.
store.data.user.name = '값'처럼 표준 JS 문법으로 값을 변경하면 즉시 화면에 반영됩니다.
store.get(path) / store.set(path, value)
- 설명:
"items[0].title"형식의 경로 문자열로 값을 조회/설정합니다.store.data.xxx로 접근/대입하는 것과 완전히 동일한 결과를 냅니다. - 매개변수
이름 타입 필수 설명 path stringY "user.name","items[0].title"형식의 경로.value anyset만 Y설정할 값. - 반환값:
get은 조회된 값(any),set은 없음. - 예시
const name = store.get('user.name');store.set('user.name', '김민수');
store.subscribe(path, cb, opts)
- 설명: 지정한 경로의 변경을 구독합니다.
opts.deep === true면 하위 경로(items[0].title등) 변경도 함께 수신합니다. 경로를 빈 문자열('')로 구독하면 모든 변경을 수신합니다(디버깅/전체 감시용). - 매개변수
이름 타입 필수 설명 path stringY 구독할 경로. 빈 문자열이면 모든 변경 수신. cb (event) => voidY event = { path, type, key, value, oldValue, parentPath }.type은'set'/'add'/'delete'/'length'중 하나.opts { deep?: boolean }N deep: true면 하위 경로 변경까지 수신. - 반환값:
Function— 호출하면 구독을 해제하는 함수. - 예시
const unsubscribe = store.subscribe('items', (ev) => console.log(ev.type, ev.path), { deep: true });unsubscribe();
store.batch(fn)
- 설명:
splice처럼 한 번의 호출에서 내부적으로 여러 트랩(get/set/deleteProperty) 호출이 연속 발생하는 연산을 감쌉니다.batch없이는 연산 도중 구독 콜백이 불완전한 중간 상태를 볼 수 있지만,batch로 감싸면 연산이 모두 끝난 뒤 한 번만(모아서) 통지됩니다. - 매개변수
이름 타입 필수 설명 fn () => voidY 배칭할 동기 함수. - 반환값: 없음
- 예시
store.batch(() => { store.data.items.splice(1, 1); });
store.toRaw()
- 설명: 루트 데이터를 원본 순수 객체/배열(Proxy가 아닌 실제 참조)로 반환합니다. 반환값을 직접 수정하면 데이터는 실제로 바뀌지만, Proxy의
set트랩을 거치지 않으므로 이벤트가 발생하지 않고 화면도 갱신되지 않습니다. - 반환값:
object | Array - 예시
const snapshot = JSON.stringify(store.toRaw(), null, 2);
store.scope(basePath)
- 설명: 특정 경로를 기준으로 상대 경로만 사용하는 얇은 뷰(
ScopedStore)를 만듭니다.syn-bind-list의 각 행이 내부적으로 자동 생성하는 것과 같은 방식으로, 리스트 행 컴포넌트처럼 상위 경로를 몰라도 되는 코드를 작성할 때 유용합니다. - 매개변수
이름 타입 필수 설명 basePath stringY 기준 경로(예: "items[0]"). - 반환값:
ScopedStore - 예시
const rowScope = store.scope('items[0]');rowScope.set('title', '수정된 제목');
ScopedStore 인스턴스 메서드
store.scope(basePath)가 반환하는 ScopedStore는 basePath 기준 상대 경로로 동작하는 get/set/subscribe/batch/scope를 제공하며, 내부적으로 원본 store에 위임합니다.
scopedStore.data
- 설명:
store.get(basePath)와 동일(getter).basePath위치의 Proxy 값을 반환합니다.
scopedStore.get(relPath) / scopedStore.set(relPath, value)
- 설명:
basePath기준 상대 경로(생략 시basePath자체)로 조회/설정합니다. 상대 경로가[로 시작하면("[0]") 인덱스로, 아니면.로 이어붙인 프로퍼티 경로로 해석합니다.
scopedStore.subscribe(relPath, cb, opts) / scopedStore.batch(fn)
- 설명: 각각
store.subscribe/store.batch에basePath기준 절대 경로로 변환해 위임합니다.
scopedStore.scope(relPath)
- 설명: 현재
basePath기준 상대 경로로 한 단계 더 좁힌ScopedStore를 반환합니다.