開発ブログ

株式会社Nextatのスタッフがお送りする技術コラムメインのブログ。

電話でのお問合わせ 075-744-6842 ([月]-[金] 10:00〜17:00)

  1. top >
  2. 開発ブログ >
  3. Javascript >
  4. 出来らあっ!生JS(TS)でインタラクティブなフォームを作れるっていったんだよ!!
no-image

出来らあっ!生JS(TS)でインタラクティブなフォームを作れるっていったんだよ!!

え!!生JSでインタラクティブなフォームを!?

こんにちは、タカギです。
みなさんの好きなWebフロントエンドフレームワークはなんですか?
弊社ではReactを使用することが最も多いです。
先日、Svelteについての紹介記事が弊社のブログで公開されましたね。 (以下、「Svelteの記事」と表記します。)

ご存じの通り、WebフロントエンドフレームワークにはReact, Vue.js, Svelte, SolidJSをはじめとした魅力的な選択肢がたくさんあります。
その中に、フレームワークを使わないという選択肢はあり得るでしょうか?
今回の記事ではフレームワークを使用せず、生のJavaScript(実際にはTypeScript)のみを使用してインタラクティブなフォームを作成することを試みます。
そしてそれがどの程度現実的な選択肢になり得るかを考えてみたいと思います。

※ 注意: この記事に目新しい技術や画期的なソースコードは出てきません。

作るもの

タイトルの通り、インタラクティブなフォームを作ります。
お題にはちょうど良いので、先ほど紹介したSvelteの記事内で作られていたお問い合せフォームと同じものを作成します。

上記記事では

  • お問合せフォーム 入力画面
  • お問合せフォーム 確認画面
  • お問合せフォーム 完了画面

の3つの画面があり、これらはSPA(シングルページアプリケーション)で作られています。
SPAのページ遷移の部分は追加でインストールしたsvelte-routingを利用することで実現していますが、今回はSvelte本体との比較を行いたいのでSPAの実装は行わないことにします。
(次回以降の課題とします)

MPA(マルチページアプリケーション)の想定で「お問合せフォーム 入力画面」のみの実装対象とします。

入力項目は下記の通りです。

  • お名前
    • 必須
    • 255文字以内
  • お名前(カナ)
    • 255文字以内
  • メールアドレス
    • 必須
    • メールアドレス形式
  • 問い合わせ内容
    • 必須
    • 10000文字以内

これらの他に画面表示内容として下記のボタンがあります

  • リセットボタン
    • クリックで入力項目がすべて空の状態になる
  • 送信ボタン
    • すべての入力項目にバリデーションエラーがない時に活性、それ以外は非活性
    • 初期表示では非活性
    • (今回はクリックでコンソールに入力項目を出力し、アラートで「OK」と表示します)

環境

  • OS: macOS Monterey 12.5
  • Node.js v16.14.2
  • TypeScript: 4.6.4
  • Vite: 3.1.0
  • Bulma: 0.9.4
  • Google Chrome: 104.0.5112.101

※ 具体的な環境構築手順については割愛します

設計

フレームワークの利用することの利点として「気の向くままにすべて手続的に書かれた生JS(やjQuery)より読みやすい」が挙げられることがあります。
何かしらの設計思想が存在するフレームワークに対しその比較はフェアではないので、生JS(TS)で書くからと言って気の向くままに筆を動かすのではなく、先に設計を行います。

フロントエンドのアーキテクチャにはMVC, MVVM, Flux, TEA(The Elm Architecture)などさまざまありますが、
特にこだわりや制約はないので今回はMVCを採用します。
MVCの説明についてはWikipediaに譲り、基本的にWikipediaでの解説されているMVCの指針に従うものとします。

※ ここで言うMVCとはフロントエンドにおけるMVCとし、LaravelやRuby On Railsなどのサーバーサイドの文脈で言われるMVC(MVC2)ではありません。

Model

Wikipediaには

MVCでは、プログラムを3つの要素、Model(モデル)、View(ビュー)、Controller(コントローラ)に分割する。

とあるので、Model, View, Controllerクラスをそれぞれ作成する前提で考えます。

Modelの説明には

そのアプリケーションが扱う領域のデータと手続き(ビジネスロジック - ショッピングの合計額や送料を計算するなど)を表現する要素である。また、データの変更をビューに通知するのもモデルの責任である(モデルの変更を通知するのにObserver パターンが用いられることもある)。

とあり、さらにControllerの説明には

(コントローラは)モデルに変更を引き起こす場合もあるが、直接に描画を行ったり、モデルの内部データを直接操作したりはしない。

とあります。これらを基にModelには下記の機能が必要と考えました。

  • 取り扱うデータをフィールドに保持する
  • フィールドを変更するためのpublicメソッドを持つ(外部から直接フィールドを書き換えられることはない)
  • ObserverパターンによりViewに現在の状態を通知する
  • 自身のフィールドから、Viewに通知するための(ViewがUIを構築するために必要な)"状態"を算出できる

疑似コードに起こすとこのようになります。


/**
 * ViewがUIを構築するために必要な"状態"を表現した型
 */
interface State {
}

/**
 * モデルから変更通知を受けるリスナー
 */
type Listener = (state: State) => void;

class Model {
  // 何かしらのフィールドが生える

  private getState(): State;

  // Observerパターンで変更を通知するためのメソッド
  private notify(state: State): void;
  addListener(listener: Listener): void;

  // フィールドを変更するためのメソッドが生える
}

ObserverパターンについてもWikipediaを参考にし、今回必要なもののみを取り上げました。

Stateはnotify()メソッドを通じてViewに渡される予定です。
ViewはStateから一意のUIを構築できると理想的であると考えます。

UI =  view(state

という式が成り立つイメージです。
UIは今回ではHTMLにあたります。

この式を成り立たせるためにはview()が純粋な関数である必要があります。
しかし今回はObserverパターンによりnotify()を受けて自身を更新していくスタイルです。
実際のViewの動きとしては

  1. 初期描画を行う
  2. Stateを受け取る度にUIを更新する

となり、この式は正確には成り立ちません。
とは言えStateさえあればその時点でのViewを再現できるというのはデバッグ・テスト時において強力な武器になるので、Viewの描画はState以外に依存しないのが望ましいです。

例)現在日時に依存する場合、Viewが直接new Date()するのではなくStateにDateを持つ

Controller

Wikipediaには

ユーザからの入力(通常イベントとして通知される)をモデルへのメッセージへと変換してモデルに伝える要素である。すなわち、UIからの入力を担当する。モデルに変更を引き起こす場合もあるが、直接に描画を行ったり、モデルの内部データを直接操作したりはしない。

とあります。ユーザー操作により発生したイベントをハンドリングするメソッドを作り、必要な場合はModelのメソッド呼び出すことになります。

class Controller {
  handleHogeEvent(ev: Event): void {
    // 必要に応じてModelのイベントを呼び出す
  }
}

このようなイメージです。

View

Wikipediaには

モデルのデータを取り出してユーザが見るのに適した形で表示する要素である。すなわち、UIへの出力を担当する。例えば、ウェブアプリケーションではHTML文書を生成して動的にデータを表示するためのコードなどにあたる。GUIにおいては通常、階層構造を成す。

モデルの説明でも少し触れたように、

  • 初期状態のUI(HTML)を返す
  • Modelから変更通知を受け取ってUI(HTML)に反映する

これらを行う必要があります。

class View {
  /**
   * UIの描画
   */
  render(state: State): UI

  /**
   * UIを更新(Model.notify()から呼び出されるはず)
   */
  update(state: State): void
}

こうなります。 UIの具体的な型は現時点では未定ですが、最終的にはHTMLに変換できるものであれば良いです。
素直にHTML文字列か、HTMLElementのようなオブジェクトになります。 これは実装の都合に合わせることにします。

実装

上述した設計方針に従い、仕様を満たせるように具体的なコードを書いていきます。

Model

import {email, maxLength, required, validate} from "../../utils/validation/validation";

/**
 * フォームの入力値
 */
export interface FormValues {
  name?: string;
  nameKana?: string;
  email?: string;
  content?: string;
}

/**
 * 各フォーム入力値のバリデーションエラーメッセージ
 */
export interface ValidationErrors {
  name: string[];
  nameKana: string[];
  email: string[];
  content: string[];
}

/**
 * 状態の変更を通知する関数
 */
type Listener = (state: State) => void;

/**
 * フォーム入力の初期値
 */
const initialValues: FormValues = {
  name: undefined,
  nameKana: undefined,
  email: undefined,
  content: undefined,
};

/**
 * バリデーションエラーの初期値
 */
const initialErrors: ValidationErrors = {
  name: [],
  nameKana: [],
  email: [],
  content: [],
};

/**
 * バリデーションルール
 */
const rules = {
  name: [required(), maxLength(255)],
  nameKana: [maxLength(255)],
  email: [required(), email()],
  content: [required(), maxLength(1000)],
}

/**
 * 変更時にlistenerに通知する"状態" (ViewはこのStateを元にUIを構築できるはずである)
 */
export interface State {
  /**
   * フォーム入力値
   */
  values: FormValues;
  /**
   * バリデーションエラーメッセージ
   */
  errors: ValidationErrors
  /**
   * フォーム入力の検証結果(入力値はすべて正常であるか)
   */
  isValid: boolean;
}

export class Model {
  private values: FormValues;
  private errors: ValidationErrors;
  private listeners: Listener[] = [];

  constructor({values, errors}: { values?: FormValues, errors?: ValidationErrors } | undefined = {}) {
    this.values = values ?? {...initialValues};
    this.errors = errors ?? {...initialErrors};
  }

  changeName(name: string): void {
    this.values.name = name;
    this.errors.name = validate(rules.name, name);
    this.notify();
  }

  changeNameKana(nameKana: string): void {
    this.values.nameKana = nameKana;
    this.errors.nameKana = validate(rules.nameKana, nameKana);
    this.notify();
  }

  changeEmail(email: string): void {
    this.values.email = email;
    this.errors.email = validate(rules.email, email);
    this.notify();
  }

  changeContent(content: string): void {
    this.values.content = content;
    this.errors.content = validate(rules.content, content);
    this.notify();
  }

  resetValues(): void {
    this.values = {...initialValues};
    this.errors = {...initialErrors};
    this.notify();
  }

  submit(): Promise<void> {
    console.log(this.values);
    alert("OK");
    this.resetValues();
    this.notify();
    return Promise.resolve();
  }

  private getState(): State {
    return {
      values: this.values,
      errors: this.errors,
      isValid: this.isValid(this.values),
    };
  }

  addListener(listener: Listener): void {
    listener(this.getState());
    this.listeners.push(listener);
  }

  /**
   * 変更を通知
   */
  private notify(): void {
    this.listeners.forEach(listener => listener(this.getState()));
  }

  private validate(values: FormValues): ValidationErrors {
    const {name, nameKana, email, content} = values;
    return {
      name: validate(rules.name, name),
      nameKana: validate(rules.nameKana, nameKana),
      email: validate(rules.email, email),
      content: validate(rules.content, content),
    }
  }

  private isValid(values: FormValues): boolean {
    const {name, nameKana, content, email} = this.validate(values);
    return [
      ...name,
      ...nameKana,
      ...content,
      ...email,
    ].length === 0;
  }
}

ほとんど設計で説明した通りの実装なので、解説することは特にありません。
入力値のバリデーションはプレゼンテーション層の関心でありビジネスロジックではない(=Modelの役割ではない)のでは?と思われる方もいるかもしれません。
Modelの定義については意見の分かれるところですが、今回はUIが依存する"状態"を算出するためのロジックは全てModelの責務と考え、このような構成にしています。
MVVMであればViewModelに相当するように思います。

ちなみにバリデーションのくだりはこの記事の主旨から逸れるので説明は割愛しますが、以下のような実装をしています。
まともに作ろうとするとそれだけで1つの記事が書けてしまうので、超簡易的なものでヨシとしてしまいました。

validation.ts

export interface ValidationRule {
  test(value?: string): boolean;

  message: string;
}

export function required(message: string = "必須です"): ValidationRule {
  const test = (value?: string): boolean => {
    if (!value) {
      return false;
    }
    return value !== "";
  }

  return {
    test,
    message,
  };
}

export function maxLength(max: number, message: string = `${max}文字以下で入力してください`): ValidationRule {
  const test = (value?: string): boolean => {
    if (!value) {
      return true;
    }
    return value.length <= max;
  }

  return {
    test,
    message,
  }
}

export function email(message: string = "メールアドレスの形式で入力してください"): ValidationRule {
  const test = (value?: string) => {
    if (!value) {
      return true;
    }
    // "@"が入っていればメールアドレスとみなすガバガバ判定で良いものとする
    return value.includes("@");
  }

  return {
    test,
    message,
  }
}

export function validate(rules: ValidationRule[], value?: string): string[] {
  return rules
    .map(rule => rule.test(value) ? undefined : rule.message)
    .filter((message): message is string => !!message)
}

Controller

次にControllerの実装です。

import {Model} from "./model";

export class Controller {
  constructor(private readonly model: Model) {
  }

  handleChangeName({target}: Event): void {
    if (target instanceof HTMLInputElement) {
      this.model.changeName(target.value);
    }
  }

  handleChangeNameKana({target}: Event): void {
    if (target instanceof HTMLInputElement) {
      this.model.changeNameKana(target.value);
    }
  }

  handleChangeEmail({target}: Event): void {
    if (target instanceof HTMLInputElement) {
      this.model.changeEmail(target.value);
    }
  }

  handleChangeContent({target}: Event): void {
    if (target instanceof HTMLTextAreaElement) {
      this.model.changeContent(target.value);
    }
  }

  handleReset(): void {
    this.model.resetValues();
  }

  async handleSubmit(): Promise<void> {
    await this.model.submit();
  }
}

こちらも特に解説することはありません。
あまり仕事をしていないように見えるので本当に必要?と思ってしまいますが、 Controllerのメソッドを見るだけでユーザー操作により発生し得るイベントが全てわかるので、存在意義があると思うことにします。
ControllerがなくなるとViewとModelがお互いに更新メソッドを呼び合う形になってしまうので、治安悪化が想像できます。

View

最後にViewですが、これは最終的にHTMLを出力するために少々小細工を入れたので順に説明します。

JS(TS)のコードでHTMLを記述したいのですが、document.createElement()を駆使して組み立てるのは可読性の面でかなり辛いものがあります。
とはいえ生JS(TS)で、という制約があるためReactで使い慣れたJSX(TSX)は使えません。
可読性をある程度保つために、下記のような記述方法を考えました。

return (
  div({}, [
    h1({ class: "title" }, [
      text("問い合わせフォーム")
    ])
  ])
);

このようなJSを書いた時に、

<div>
  <h1 class="title">問い合わせフォーム</h1>
</div>

といったHTMLが出力されれば理想的です。
これを実現するためにヘルパー関数を定義していきます。

interface Attributes {
  [key: string]: string | number | boolean | undefined;
}

export function createElement(tagName: string, attributes: Attributes = {}, children: Node[] = []): HTMLElement {
  const element = document.createElement(tagName);

  for (let [key, value] of Object.entries(attributes)) {
    element.setAttribute(key, value === undefined ? "" : `${value}`);
  }

  children.forEach(child => element.appendChild(child));

  return element;
}

export function div(attributes: Attributes = {}, children: Node[] = []): HTMLElement {
  return createElement("div", attributes, children);
}

export function h1(attributes: Attributes = {}, children: Node[] = []): HTMLElement {
  return createElement("h1", attributes, children);
}

あとはdivやh1のようにHTMLタグすべてに対し同じような関数を定義していきます。とても辛い作業になるので、私は今回使う分だけ定義しました。
createElement()関数がこの記述ですべてのHTML属性に対応できているのかが正直微妙だと思っているので、これは今後の私への課題とします。

ついでにHTMLタグ以外にも、あると少し便利なヘルパー関数を定義しておきます。

export function fragment(children: Node[] = []): DocumentFragment {
  const fragment = document.createDocumentFragment();
  children.forEach(child => fragment.appendChild(child));
  return fragment;
}

export function hidden(tagName: string = "div"): HTMLElement {
  return createElement(tagName, {hidden: true});
}

export function putElement<T extends Element>(element: Element, updated: T): T {
  element.replaceWith(updated);
  return updated;
}

これらのヘルパー関数を組み合わせて下記のような関数が作成できます。
バリデーションエラーを表示する関数です。
なんとなく見た目がReactコンポーネントに似てきた気がします。

interface Properties {
  errors: string[];
}

export function validationErrors({errors}: Properties): HTMLElement {
  return (
    ul({class: "help is-danger"}, errors.map(error => (
      li({}, [
        text(error)
      ])
    )))
  );
}

ここまでで準備してきた関数を組み合わせ、Viewクラスを実装します。

import {
  button,
  div,
  form,
  fragment,
  h1,
  hidden,
  input,
  label,
  putElement,
  text,
  textarea
} from "../../utils/dom/helper";
import {validationErrors} from "../../components/validation-errors";
import {Controller} from "./controller";
import {State} from "./model";

export class View {
  private $inputName = input({type: "text", class: "input", placeholder: "試験 太郎"});
  private $inputNameKana = input({type: "text", class: "input", placeholder: "テスト タロウ"});
  private $inputEmail = input({type: "email", class: "input", placeholder: "test@example.com"});
  private $textareaContent = textarea({class: "textarea"});

  private $resetButton = button({type: "button", class: "button is-link is-light"}, [
    text("リセット")
  ]);
  private $submitButton = button({type: "button", class: "button is-link"}, [
    text("送信")
  ]);

  private $nameErrors = hidden();
  private $nameKanaErrors = hidden();
  private $emailErrors = hidden();
  private $contentErrors = hidden();

  constructor(c: Controller) {
    this.$inputName.addEventListener("input", ev => c.handleChangeName(ev));
    this.$inputNameKana.addEventListener("input", ev => c.handleChangeNameKana(ev));
    this.$inputEmail.addEventListener("input", ev => c.handleChangeEmail(ev));
    this.$textareaContent.addEventListener("input", ev => c.handleChangeContent(ev));

    this.$resetButton.addEventListener("click", () => c.handleReset());
    this.$submitButton.addEventListener("click", () => c.handleSubmit());
  }

  /**
   * 初期描画
   */
  render(): DocumentFragment {
    return (
      fragment([
        h1({}, [
          text("お問合せフォーム"),
        ]),
        form({}, [
          div({class: "field"}, [
            label({class: "label"}, [
              text("お名前"),
              div({class: "control"}, [
                this.$inputName,
              ]),
              this.$nameErrors
            ]),
          ]),

          div({class: "field"}, [
            label({class: "label"}, [
              text("お名前(カナ)"),
              div({class: "control"}, [
                this.$inputNameKana,
              ]),
              this.$nameKanaErrors,
            ]),
          ]),

          div({class: "field"}, [
            label({class: "label"}, [
              text("メールアドレス"),
              div({class: "control"}, [
                this.$inputEmail,
              ]),
              this.$emailErrors,
            ]),
          ]),

          div({class: "field"}, [
            label({class: "label"}, [
              text("お問合せ内容"),
              div({class: "control"}, [
                this.$textareaContent,
              ]),
              this.$contentErrors,
            ]),
          ]),

          div({class: "field is-grouped"}, [
            div({class: "control"}, [
              this.$resetButton,
            ]),
            div({class: "control"}, [
              this.$submitButton,
            ]),
          ]),
        ]),
      ])
    );
  }

  update(state: State): void {
    const {values, errors} = state;

    this.$inputName.value = values.name ?? "";
    this.$inputNameKana.value = values.nameKana ?? "";
    this.$inputEmail.value = values.email ?? "";
    this.$textareaContent.value = values.content ?? "";
    this.$textareaContent.textContent = values.content ?? "";

    this.$nameErrors = putElement(this.$nameErrors, validationErrors({
      errors: errors?.name ?? []
    }));
    this.$nameKanaErrors = putElement(this.$nameKanaErrors, validationErrors({
      errors: errors?.nameKana ?? []
    }));
    this.$emailErrors = putElement(this.$emailErrors, validationErrors({
      errors: errors?.email ?? []
    }));
    this.$contentErrors = putElement(this.$contentErrors, validationErrors({
      errors: errors?.content ?? []
    }));

    this.$submitButton.disabled = !state.isValid;
  }
}

言い忘れていましたが、先ほどのSvelteの記事と同様にCSSフレームワークにBulmaを使用し少し見た目を整えています。

まずフィールドにViewのメソッドから使用するためのHTMLElementをすべて定義します。(先ほど定義したヘルパー関数を使います)
$nameErrors = hidden()となっているのは、初期描画時には不要なHTML要素であっても何かしら要素を置いておかないと後からJSで操作することが困難なためです。 あまり好ましくはないのですが、他に方法が思いつかなかったため妥協しました。

コンストラクタで受け取ったControllerのメソッドを、各HTML要素のイベントリスナーに登録します。

render()メソッドでは初期描画されるHTML要素を返します。

update()メソッドではModelの説明でも触れた通り、UIの状態を表すStateが渡されます。
これを元にフィールドで定義したHTML要素を状態に合うように更新します。

これでModel, View, Controllerの全て揃いました。
あとはこれらを使い、うまく動いてくれるように組み合わせます。

index.ts

import {FormValues, Model, ValidationErrors} from "./model";
import {Controller} from "./controller";
import {View} from "./view";

interface Properties {
  initialValues?: FormValues;
  initialErrors?: ValidationErrors;
}

export default function contactFormPage({initialValues, initialErrors}: Properties | undefined = {}): DocumentFragment {
  const m = new Model({
    values: initialValues,
    errors: initialErrors,
  });

  const c = new Controller(m);
  const v = new View(c);

  m.addListener(state => v.update(state));
  return v.render();
}

Model, View, Controllerのインスタンスをそれぞれ作成し、
Modelの変更通知を受け取れるようにaddListerメソッドでViewのupdateメソッドを登録しておきます。
こうすることで、Modelがnotify()を実行するたびにViewのupdate()が実行されます。

これで準備完了ですが、 Svelteの記事では全体のレイアウトを整えていました。
せっかくなので同等のものを作っておきます。

layout.ts

import {a, div, main, nav, text} from "../utils/dom/helper";

interface Properties {
}

export default function layout(_: Properties, children: Node[] = []): HTMLElement {
  return (
    div({class: "container"}, [
      nav({class: "navbar"}, [
        div({class: "navbar-menu"}, [
          div({class: "navbar-start"}, [
            a({href: "#", class: "navbar-item"}, [
              text("Contact")
            ])
          ])
        ])
      ]),
      main({}, children)
    ])
  )
}

引数がちょっと気持ち悪いですが(第一引数のオブジェクトが使われていない)、他のヘルパー関数でも全て第二引数で子要素の配列を受け取っているのでそれに合わせました。
実際には第一引数のオブジェクトで何か受け取りたいデータがありそうな気がするので、この形でいきます。

最後にこれらを組み合わせて本当に完成です。

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>お問合せフォーム</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

main.ts

import "bulma/css/bulma.css";

import contactFormPage from "./pages/contact-form";
import layout from "./components/layout";
import {putElement} from "./utils/dom/helper";

const root = document.querySelector("#app");
if (!root) {
  throw new Error("要素がありません");
}

const app = layout({}, [
  contactFormPage(),
]);

putElement(root, app);

ブラウザで開くとフォームが表示され、入力状態によってバリデーションエラーの表示が切り替わったり、ボタンの活性/非活性が切り替わったりすることが確認できました。
インタラクティブなフォームができたと思います。 実装はこれで終了です。

感想・まとめ

さて、生JS(TS)のみでインタラクティブなフォームが実装できることはわかりましたが、フレームワークを使わないというのは現実的な選択肢になり得るでしょうか?

ここで実装したものと比較し、Svelteの記事で紹介されているソースコードには以下のメリットがあります。

  1. 記述量が少ない
  2. 直感的に読みやすい
  3. ドキュメントに従えば似たコードになりやすい

1の記述量については見たままの通りで、とにかく手数が少ないです。
羨ましいです。

2について、「直感的」というのは個人的な感覚も関わるので一概には言えませんが、
この記事で紹介したコードよりSvelteの記事のコードの方が読みやすく感じる人がほとんどなのではないかと思います。
書いた本人の私でもそう思います。 しかし、直感以外の部分ではどうでしょうか。
Svelteの記事には

<input bind:value={$contact.name} />

という記述がありました。
直感的には意味は理解でき、実際にイメージした通りの挙動をしていました。
もしこれが直感に反する挙動であったり、やりたいことと方向はあってるけど微妙に違う、という場合はどうでしょうか?
Svelteは使用したことがないのでわかりませんが、Reactやその他周辺ライブラリを使っているとそういった場面に遭遇することが稀によくあります。
ドキュメントを読んで解決できれば理想的ですが、もしソースコードを読みに行く必要があった場合、その難易度は高いことが多いです。

3については圧倒的に敗北を感じます。
今回はWikipediaの解説に従いましたが、私以外の別の人がWikipediaを参考にMVCを書いたら全く別のコードになった、ということは十分考えられます。

これら対し、フレームワークを使用しなかったことで得られたメリットをあげます。

  1. 外部依存によるリスクがない
  2. 学習コストがない
  3. 自由に設計できる

1についてはそのままです。 Webフロントエンド技術のトレンドの移り変わりは非常にはやく、今はイケてる最新のフレームワークがいつ技術的負債になるかはわかりません。
フレームワークを使わなくても技術的負債を生み出すことはもちろん容易に可能ですが、外部に依存するということは、そのリスクが自分のコントロール下にはないということです。

2について、
ほとんどの場合、自分で書いたコードより他人の書いたコードを動かす方が難しいです。
ReactやSvelteに限らず何らかのフレームワークを利用する場合、経験上初期段階の学習コストは大したことはないのですが、
実際に業務で使用していると「痒いところに手が届かない」、「痒いところをかくために渡された道具は包丁しかなかった」と感じる場面があります。
こうなった時、 Google検索を彷徨ったり、ソースコードを読みに行ったりする時間は無視できないコストです。
ここで払った学習コストが一生無駄にならないのであれば素直に受け入れるのですが、時間は有限なので寿命の短い技術にあまり溶かしたくないという気持ちがあります。
ReactやSvelteの寿命がどれほどあるかは私にはわかりませんが、Web標準の寿命よりは間違いなく短いでしょう。

3についても重要なポイントです。
フレームワークを使用する/しないにかかわらず設計の手綱は常に自分が持つべきですが、
本意ではないがフレームワークの都合に合わせるという判断をせざるを得ない局面はよくあります。
フレームワークを使用しなければこれは起こり得ないので、設計についての自由度は段違いです。
今回はMVCで行きましたが次回はFluxかもしれませんし、まだ世に登場していない最強のアーキテクチャかもしれません。

これらを踏まえて、フレームワークを使用しないという選択肢はあり得るでしょうか?
私の中では、条件次第ではあり、という結論になりました。

  • MPAである
  • 記述量が増えることを受け入れてでも設計面の自由度を上げたい
  • フレームワークに頼らなくても、全体的な書き方を統一できる環境がある
    • 開発メンバーが1人または少人数である
    • ドキュメントを整備する余裕がある

といった条件であれば選択肢になり得るように思います。
ただ実際にやりたいかと言われると微妙で、フレームワークを使って楽したい……という判断になってしまうケースがほとんどだと思います。
今回の実装例はあくまで一例なので、もっと良い書き方が思いつけばまた考え方も変わると思います。

また、今回取り上げなかったことについても検討が必要です。

  • パフォーマンス
  • 規模が大きくなってきても耐えうるか

どちらも今回実装したお問合せフォームだけでは有意な比較ができるものではないのでこれらは次回以降の課題とします。

View.update()を見て気づいた方も多いと思いますが、このメソッドは呼ばれる度に問答無用で全てのDOMを更新するという非常に非効率な処理をしており、一定以上の規模になったときにパフォーマンスに深刻な問題が発生することは明白です。
仮想DOMを使わずにこれを捌いてるSvelteッ!ぼくは敬意を表するッ!

ここまでお読みいただきありがとうございました。
私はReactコンポーネントを作る作業に戻ります。

TOPに戻る