データバインディング

データの更新内容を取得し、またデータの変更を反映するための仕組みとして、データバインディングがあります。データバインディングの登場人物は、

  • バインディングソース: オリジナルデータ
  • バインディングターゲット: ソースに結びつけられた(通常複数の)オブジェクト

以上の2つです。

アプリケーションの表示部分において、アプリケーションの内部状態を表すデータ「そのもの」への参照を持っている必要は必ずしもなく、むしろ内部状態の変更を自動的に反映し、表示と内部状態が矛盾なく更新されることが望ましいです。

データバインディングはそうした表示と内部状態の協調のために用いられる手法です。Alier ではデータバインディングのためのモジュールとして、ObservableObjectObservableArray の2つを用意しています。

ObservableObject はデータバインディングのための基本的な機能を提供するクラスです。ソースおよびバインディングターゲットの登録、ソースの変更監視およびバインディングターゲットへの変更通知を行います。変更通知に対する処理はバインディングターゲットに任されます。

ObservableArray は、一次元配列の構造を持ったデータの変更監視を行うためのクラスです。ObservableArray では配列操作を splicesort の2種類に分類し、splice / sort を単位として同期を行います。splice / sort を基本単位とする理由は、これらの2つで(概念的にはより原子的な)配列の基本的な操作を代替できるからです。

配列の基本操作は以下の3つに分類できます:

  • 要素の挿入
  • 要素の削除
  • 要素の置換

挿入と削除は、splice 操作としていずれも表現でき、pushpopunshiftshift はいずれも splice 操作に含まれます(例えば arr.push(x) は式 (arr.splice(arr.length, 0, x), arr.length) に還元できます)。

置換は sort 操作として表現でき、reverse も sort 操作に含めることができます(例えば in-place な操作ではありませんが、[...arr.entries()].sort(([i, ], [j, ]) => (j - i)).map(([i, v]) => v) のようにして、インデックス付き配列の sort として reverse を扱うことができます)。

要素の値の変更自体は ObservableArray 自身ではなく、ObservableObject が監視します。つまり、ObservableArray の実態は(配列操作に関する Proxy を伴った)ObservableObject の配列です。

双方向バインディングと単方向バインディング

データバインディングには、バインディングターゲットのデータ更新をソースへ反映することを許す、双方向バインディング方式と、ターゲットからソースへ変更の反映が行われない単方向バインディング方式があります。

Alier では双方向バインディングを行うか単方向バインディングを行うかを選ぶタイミングが2つあります。1つは監視用オブジェクト ObservableObject / ObservableArray の生成時、既定の動作として双方向/単方向バインディングのどちらを行うか決定できます。1つは監視用オブジェクトにバインディングターゲットを登録する際、対象のオブジェクトに対して双方向/単方向バインディングのどちらを行うか決定できます。

双方向バインディングと単方向バインディングの違いは以下の通りです:

  • 双方向バインディング
    • バインディングターゲットの更新がソースへ反映される
    • ターゲット/ソース間の変更通知では差分情報のみが渡される
  • 単方向バインディング
    • バインディングターゲットの更新はソースへ反映されない
    • ソースの変更通知ではソースの全情報が渡される

双方向と単方向で変更時に通知される情報が異なるのは、ターゲット側の状態がソース側と常に同期されていると仮定できるかどうかという事情を反映しています。

双方向バインディングにおいては、常にソースとターゲットの状態は同期されているものと仮定されます。従って、個々の変更操作に対して差分情報を送りあうだけで変更元の状態を復元できると仮定されます

一方で単方向バインディングでは、ターゲットの情報をソース側では監視していません。従って、ソースの情報をターゲットへ通知する際は、整合性のため常にソースの(監視対象のプロパティの)全情報をターゲットへ送ります。

サンプル

Web アプリでのサンプルコードです。View のテキストをモデルの ObservableObject と紐づけます。

import { ObservableObject } from "/src/ObservableObject.js";

export class BindingClass extends AlierModel {
    greetingImpl = new ObservableObject({ greeting: "hello" }, false);
}
<model>
    <import path="/scripts/BindingClass.js" />
    <interface>
        <unit name="binding">
            <observable-object name="greeting"
                locale="local"
                class="BindingClass"
                path="greetingImpl" />
        </unit>
    </interface>
</model>
import * as AlierFramework from "/src/AlierFramework.js";
Object.assign(globalThis, AlierFramework);

Alier.main(async () => {
    await setupModelInterface("/scripts/BindingInterface.xml");

    const vl = new (class BindingSample extends ViewLogic {
        constructor() {
            super();
            const html = `
                <div main-container id="binding-sample">
                    <p id="greeting" data-ui-component data-primary="innerText">dummy text</p>
                </div>`;
            this.loadContainer({ text: html, id: "binding-sample" });
            this.relateElements(this.collectElements(this.container));
        }
        messageHandler(msg) {
            return msg.deliver({
                vl$attached: () => {
                    Alier.Model.binding.greeting.bindData(this);
                },
            });
        }
    })();
    Alier.View.attach(vl);
});
<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Binding sample</title>
        <script type="module" src="/scripts/BindingSample.js"></script>
    </head>
    <body>
    </body>
</html>
const path = require("node:path");
const { Router } = require("@suredesigns/alier/Router");
const { WebResource } = require("@suredesigns/alier/WebResource");

const HTTP_PORT = 8080;
const ORIGIN = `http://localhost:${HTTP_PORT}`;

const router = new Router({
    allowPostMethodOverride: true,
    parsesQueryAsJson: true,
    trailingSlashPolicy: "remove",
});

const src = new WebResource({
    path: "/src/*",
    target: path.resolve("/path/to/Alier/Web/src/"),
    contentType: "text/*",
});
const index = new WebResource({
    path: "/index.html",
    target: path.resolve("./index.html"),
    contentType: "text/html",
});
const scripts = new WebResource({
    path: "/scripts/*",
    target: path.resolve("./scripts/"),
    contentType: "text/*",
});

router
    .enable(index)
    .enable(src)
    .enable(scripts)
    .listen(HTTP_PORT);

console.debug(`server is running at ${ORIGIN}/index.html`)