👏

宣言的UIなJSフレームワーク ベンチマークコード

2023/07/26に公開

https://github.com/mogera551/quel/

JavaScriptフレームワークの有名なベンチマークをコーディングしてみる

https://github.com/krausest/js-framework-benchmark

ベンチマークの内容

  • 1000行のリスト生成・表示
  • 1000行のリスト置換・表示
  • 10000行のリスト生成・表示
  • 1000行のリスト追加・表示
  • 10行毎に行更新
  • リストクリア
  • 行の内容を交換
  • 行選択
  • 行削除

状態の保持

  • ViewModelクラスのプロパティとして宣言
  • リストと選択行のidを保持
main.js
  data = []; // リスト
  selected; // 選択行のid

行の選択状態

  • アクセサプロパティ(getter)で行の選択状態を定義
  • 行のidと選択行idが一致する場合、選択中を返す
  • ループ中のアクセサプロパティでは、ワイルドカードを使ったthis["data.*.id"]へアクセスできる
  • アクセサプロパティ(getter)を使う場合、依存関係の定義を行う
main.js
  get "data.*.selected"() {
    return this["data.*.id"] === this.selected;
  }
  
  // 依存関係の定義
  $dependentProps = {
    "data.*.selected": [ "data.*.id", "selected" ],
  }

各処理

  • ViewModelクラスのメソッドとして実装

1000行のリスト生成・表示

  • 1000行データを作成して、プロパティを更新
  • 選択行idをクリア
main.js
  run() {
    this.data = buildData(1000);
    this.selected = undefined;
  }

10000行のリスト生成・表示

  • 10000行データを作成して、プロパティを更新
  • 選択行idをクリア
main.js
  runLots() {
    this.data = buildData(10000);
    this.selected = undefined;
  }

1000行のリスト追加・表示

  • 1000行データを作成し追加、プロパティを更新
main.js
  add() {
    this.data = this.data.concat(buildData(1000));
  }

10行毎に行更新

  • 10行毎に、行データを更新
  • プロパティの更新は、ドット記法のプロパティ名で行う
main.js
  update() {
    for(let i = 0; i < this.data.length; i += 10) {
      this[`data.${i}.label`] += ` !!!`;     
    }
  }

リストクリア

  • 空の配列で、プロパティを更新
main.js
  clear() {
    this.data = [];
  }

行の内容を交換

  • 分割代入でスワップ処理を記述
  • プロパティのアクセスは、ドット記法のプロパティ名で行う
main.js
  swapRows() {
    if (this.data.length > 998) {
      [this["data.1"], this["data.998"]] = [this["data.998"], this["data.1"]];
    }
  }

行の選択

  • 選択行idを更新
  • ループ中のイベントハンドラは、ワイルドカードを使ったthis["data.*.id"]へアクセスできる
main.js
  select() {
    this.selected = this["data.*.id"];
  }

行の削除

  • 行をインデックスにより削除し、リストプロパティを更新
  • ループ中のイベントハンドラでは、引数としてコンテキスト変数(インデックス値)$1を利用できる
main.js
  remove(e, $1) {
    this.data = this.data.toSpliced($1, 1);
  }

Viewの定義

  • html変数で定義

ボタンのイベント処理

  • data-bind属性で、onclickとViewModelクラスのメソッドを関連付ける
html変数(必要な部分だけ抜粋)
  <button id="run" data-bind="onclick:run">Create 1,000 rows</button>
  <button id="runlots" data-bind="onclick:runLots">Create 10,000 rows</button>
  <button id="add" data-bind="onclick:add">Append 1,000 rows</button>
  <button id="update" data-bind="onclick:update">Update every 10th row</button>
  <button id="clear" data-bind="onclick:clear">Clear</button>
  <button id="swaprows" data-bind="onclick:swapRows">Swap Rows</button>

リスト

  • 繰り返し(ループ)は、loop:end:で囲む
  • ループ対象のプロパティdataloop:の後ろに書く
  • ループ中の要素へのアクセスは、ワイルドカードを使ったドット記法で行うdata.*
  • class属性へのクラス名の追加・削除は、class.クラス名:プロパティで行う
    • プロパティが真の場合追加、偽の場合削除される
  • ループ中のアクセサプロパティdata.*.selectedでは、コンテキスト変数が使用できる
  • ループ中のイベントselect() remove()では、コンテキスト変数が使用できる
html変数(必要な部分だけ抜粋)
  <table>
    <tbody>
      {{ loop:data }}
      <tr data-bind="class.danger:data.*.selected">
        <td>{{ data.*.id }}</td>
        <td><a data-bind="onclick:select">{{ data.*.label }}</a></td>
        <td><a data-bind="onclick:remove"><span class="glyphicon-remove"></span></a></td>
        <td></td>
      </tr>
      {{ end: }}
    </tbody>
  </table>

考察

  • 他のフレームワークよりもわかりやすく簡潔に記述できたと思う

ファイル構成

--+-- index.html
  |
  +-- quel.min.js [フレームワーク]
  |
  +-- buildData.js [データ生成]
  |
  +-- main.js [コンポーネント定義]

ソースコード

index.html
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="utf-8"/>
	<title>quel-"keyed"</title>
	<link href="/path/to/currentStyle.css" rel="stylesheet"/>
</head>
<body>
	<myapp-main></myapp-main>

<script type="module">
import quel from "./quel.min.js";
import MyappMain from "./main.js";

quel.componentModules({ MyappMain });
</script>

</body>
</html>
buildData.js
// データ生成用
function random(max) {
  return Math.round(Math.random() * 1000) % max;
}

const A = ["pretty", "large", "big", "small", "tall", "short", "long", "handsome", "plain", "quaint", "clean",
  "elegant", "easy", "angry", "crazy", "helpful", "mushy", "odd", "unsightly", "adorable", "important", "inexpensive",
  "cheap", "expensive", "fancy"];
const C = ["red", "yellow", "blue", "green", "pink", "brown", "purple", "brown", "white", "black", "orange"];
const N = ["table", "chair", "house", "bbq", "desk", "car", "pony", "cookie", "sandwich", "burger", "pizza", "mouse",
  "keyboard"];

let nextId = 1;

export function buildData(count) {
  const data = new Array(count);
  for (let i = 0; i < count; i++) {
    data[i] = {
      id: nextId++,
      label: `${A[random(A.length)]} ${C[random(C.length)]} ${N[random(N.length)]}`,
    };
  }
  return data;
}
main.js
import { buildData } from "./buildData.js";

// Viewの定義
const html = `
<div class="container">
  <div class="jumbotron">
    <div class="row">
      <div class="col-md-6">
        <h1>quel keyed</h1>
      </div>
      <div class="col-md-6">
        <div class="row">
        <div class="col-sm-6 smallpad">
          <button class="btn btn-primary btn-block" id="run" data-bind="onclick:run">Create 1,000 rows</button>
        </div>
        <div class="col-sm-6 smallpad">
          <button class="btn btn-primary btn-block" id="runlots" data-bind="onclick:runLots">Create 10,000 rows</button>
        </div>
        <div class="col-sm-6 smallpad">
          <button class="btn btn-primary btn-block" id="add" data-bind="onclick:add">Append 1,000 rows</button>
        </div>
        <div class="col-sm-6 smallpad">
          <button class="btn btn-primary btn-block" id="update" data-bind="onclick:update">Update every 10th row</button>
        </div>
        <div class="col-sm-6 smallpad">
          <button class="btn btn-primary btn-block" id="clear" data-bind="onclick:clear">Clear</button>
        </div>
        <div class="col-sm-6 smallpad">
          <button class="btn btn-primary btn-block" id="swaprows" data-bind="onclick:swapRows">Swap Rows</button>
        </div>
        </div>
      </div>
    </div>
  </div>
  <table class="table table-hover table-striped test-data">
    <tbody>
      {{ loop:data }}
      <tr data-bind="class.danger:data.*.selected">
        <td class="col-md-1">{{ data.*.id }}</td>
        <td class="col-md-4"><a data-bind="onclick:select">{{ data.*.label }}</a></td>
        <td class="col-md-1"><a data-bind="onclick:remove"><span class="glyphicon glyphicon-remove" aria-hidden="true"></span></a></td>
        <td class="col-md-6"></td>
      </tr>
      {{ end: }}
    </tbody>
  </table>
</div>
<span class="preloadicon glyphicon glyphicon-remove" aria-hidden="true"></span>
`;

// ViewModelの定義
class ViewModel {
  data = [];
  selected;
  get "data.*.selected"() {
    return this["data.*.id"] === this.selected;
  }

  select() {
    this.selected = this["data.*.id"];
  }
  remove(e, $1) {
    this.data = this.data.toSpliced($1, 1);
  }
  run() {
    this.data = buildData(1000);
    this.selected = undefined;
  }
  runLots() {
    this.data = buildData(10000);
    this.selected = undefined;
  }
  add() {
    this.data = this.data.concat(buildData(1000));
  }
  update() {
    for(let i = 0; i < this.data.length; i += 10) {
      this[`data.${i}.label`] += ` !!!`;     
    }
  }
  clear() {
    this.data = [];
  }
  swapRows() {
    if (this.data.length > 998) {
      [this["data.1"], this["data.998"]] = [this["data.998"], this["data.1"]];
    }
  }

  $dependentProps = {
    "data.*.selected": [ "data.*.id", "selected" ],
  }
}

export default { ViewModel, html };

Discussion