155
157

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ノンフレームワークなJavaScriptでもDOMとうまく付き合う方法

Last updated at Posted at 2020-07-24

ReactやVueなどのフロントエンドフレームワークが全盛期を迎えているJavaScriptですが、様々な制約から導入を足踏みしているプロジェクトは多々あると思います。

そして、そのようなプロジェクトではおそらくjQueryが現役で使われており、フロントエンドのコードはスパゲッティと化し、ネストされたコードは可読性を落とし、どの関数がどこで使われているのかわからない、そんな状態に陥っているのではないでしょうか。

この記事では、そんなプロジェクトを対象に、ノンフレームワークでも出来る限り可読性を向上させるための工夫をまとめてみました。

JavaScriptからHTMLをできるだけ触らない

JavaScriptとDOMは密結合になりがち

JavaScriptとDOM・HTMLの密結合がスパゲッティ化を招きます。逆に、JavaScriptとDOMを疎結合にしてしまえばスパゲッティ化しにくいといえます。

JavaScriptはHTMLによって運ばれブラウザで実行される言語なので、JavaScriptとHTMLを完全に分離することは不可能です。ただし、アプローチの仕方次第で結合度合いを下げることは可能です。

よくない例

下は、あるハンバーガショップのメニュー選択ページのJavaScriptです。
要望として、サラダが選択された時にはドレッシングの選択項目を出して欲しいと言われ、その実装を行いました。

document.getElementById('selectMenu').addEventListener('click', (event) => {
    const isSaladSelected = event.currentTarget.value === 'salad';
    if (isSaladSelected) {
        // ドレッシングメニューを表示する
        const dressingSelect = document.createElement('select');
        dressingSelect.id = 'dressingSelect';
        dressingSelect.innerHTML = `
            <option value="goma">ゴマドレッシング</option>
            <option value="wafu">和風ドレッシング</option>
            <option value="seasar">シーザードレッシング</option>
        `;
        document.getElementById('selectMenu').append(dressingSelect);
    } else {
        // ドレッシングメニューを消す
        document.getElementById('dressingSelect').remove();
    }
})

この時点ではまだ簡潔なように見えます。しかし、将来的にドレッシングがシーザーだったときにはクルトンの有無を、メニューがナゲットだったときにはソースの種類を、と要望が増えていったときはどうでしょう。破綻するのは目に見えています。

解決策: JavaScriptは表示の切り替えに専念する

この問題は、あらかじめHTMLにフォームを定義しておき、JavaScriptでは切り替えだけを行うことで解決できます。下は、あらかじめ項目を定義したHTMLです。 dressingSelect が非表示になっていることに注目してください。

<form>
  <select id="selectMenu">
    <option value="hamburger">ハンバーガー</option>
    <option value="potato">ポテト</option>
    <option value="salad">サラダ</option>
  </select>
  <select id="dressingSelect" style="visibility: hidden">
    <!-- visibility: hidden で最初は非表示 -->
    <option value="goma">ゴマドレッシング</option>
    <option value="wafu">和風ドレッシング</option>
    <option value="seasar">シーザードレッシング</option>
  </select>
</form>

JavaScriptはサラダだったときの切り替えのみに専念します。

document.getElementById('selectMenu').addEventListener('click', (event) => {
    const dressingSelect = document.getElementById('dressingSelect');
    const isSaladSelected = event.currentTarget.value === 'salad';
    dressubgSelect.style.visible = isSaladSelected ? 'visible' : 'hidden';
})

このように内容がすでに決まっているものはあらかじめHTMLに定義しておき、JavaScriptでは display: none 、もしくは visibility: hidden のつけ外しで表示・非表示を行なっていくと、JavaScript中でHTMLのあれこれを考える必要がなくなり、コードの見通しがよくなります。

動的に生成するときはページの表示時に

この方法は要素を動的に生成したい場合にも使えます。例えばドレッシングの在庫状況によりリストの内容を変更したい時は、切り替え時にリストを生成するのではなく、ページの読み込み時にあらかじめHTMLを生成しておけば良いのです。

<select id="dressingSelect" style="display: none">
  <!-- createDressingSelect -->
</select>
const createDressingSelect = (dressingList) => {
    // dressingListは配列で、ドレッシングの情報が入っているものとする。
    const dressingSelect = document.getElementById('dressingSelect');
    const dressingOptions = dressingList.map((dressing) => (
        `<option value="${dressing.value}">${dressing.name}</option>`
    ));
    dressingSelect.innerHTML = dressingOptions.join('\n');
}

getDressingList().then(createDressingSelect)

このアプローチを使用することにより、JavaScriptコードの中からDOMの操作をしている行を減らし、可読性を向上させることができます。

無名関数 & コールバックを多用するのは避ける

古いjQueryにより蘇る地獄

コールバック地獄という言葉があるように、JavaScriptでは以前からコールバックのネストが問題視されてきました。

jQueryのあるプラグインでは、オブジェクトにコールバックを設定させていました。

$('#dialog').dialog({
    title:"実行します。よろしいですか?",
    buttons: {
        "OK": () => {
            connection('/method', data)
                .then((response) => {
                    // ... 完了後の処理 ...
                });
        }
    }
});

見てわかるように、ネストがどんどん深くなっています。残念ながら、古いjQueryのプラグインにはこういったネストを増やしてしまうような設計が多くみられます。

解決策1: 関数を分割する

小手先の解決策ですが、無名関数を直接記述するのではなく、関数を分割することで対処できます。

const dialogOKButtonHandler = () => {
    connection('/method', data)
        .then((response) => {
            // ... 完了後の処理 ...
        });
}

$('#dialog').dialog({
    title:"実行します。よろしいですか?",
    buttons: {
        "OK": dialogOKButtonHandler
    }
});

解決策2: Promise化する

一番良い解決策は、このようなレガシーなjQueryプラグインをPromise対応のものに置き換える、もしくはラッパーを書くことです。

$('#dialog').dialog({
    title:"実行します。よろしいですか?",
    buttons: {
        "OK": dialogOKButtonHandler
    }
}).then(result => {
    return connection('/method', data)
}).then(response => {
    // ... 完了後の処理 ...
});

Promiseに対応させることで、関数を横にではなく縦に伸ばすことが可能になります。ネストも減らすことができ、可読性が向上します。

エレメントではなくデータを扱う

臭いものにはフタをする

臭いものにはフタをする という言葉があります。都合の悪いものを一時的に隠すという意味ですが、これはJavaScriptにも言えることです。

JavaScriptにとって、他のデータ形式であるHTMLを操作することは 臭いこと です。ですが、関数により処理を分割し、 フタをする ことができます。

DOMの操作とロジックは分割しましょう。ロジックの中では、エレメントではなくデータを扱うようにしましょう。
そうすることで、コードの見通しがよくなりロジックの再利用性が向上します。もしロジックの中にDOMの操作が紛れ込んでいたら、そのロジックは他所で使用することができなくなります。

よくない例

たとえばログインフォームを実装するときを考えましょう。下は良くない例です。
ログインボタンが押されたらユーザIDとパスワードをエレメントから取得してログインを試行します。ログインができたら、APIからデータを受け取り特定のDivに描写します。
要望として、3回アクセスに失敗したらキャプチャを出して欲しいと言われています。

// ログイン試行する
const tryLogin = async () => {
    // エレメントを取得
    const userIdInput = document.getElementById('userId');
    const passwordInput = document.getElementById('password');

    // エレメントからログイン試行
    const loginResultJSON = await connection('/login', {
        body: { userId: userIdInput.value, password: passwordInput.value }
    });
    return JSON.parse(loginResultJSON);
}

// データを取得しViewに追加する
const getDataListThenAppend = async () => {
    // データを取得
    const dataListJSON = await connection('/getData');
    const dataList = JSON.parse(dataListJSON);

    // データをDivに変換する
    const dataDivs = dataList.map((data) => {
        const div = document.createElement('div');
        div.innerHTML = `<p>${data}</p>`;
        return div;
    })

    // 変換したDivをHTMLに連結する
    const dataView = document.getElementById('dataView');
    dataView.append(...dataDivs);
}

let loginFailureCount = 0;
document.getElementById('loginButton').addEventListener('click', async () => {
    const loginResult = await tryLogin();

    // ログインできなかったときの処理
    if (!loginResult.ok) {
        loginFailureCount++;
        if (loginFailureCount > 3) {
            // 3回目でキャプチャを表示
            const caputureDiv = document.getElementById();
            caputureDiv.style.display = 'block';
        }
        return false;
    }

    await getDataListThenAppend();
});

一見、しっかりと関数が分割されているように見えます。しかしながら、それぞれの関数の中でDOMから情報を取得しており、これでは各関数とHTMLが密結合になってしまいます。密結合になると、その関数を他所で利用するためには、該当の関数のみならず結合されているHTMLをも移動させなければいけません。

解決策: ロジックの関数にDOMを触らせない

これを下のように分割し直しましょう。

// ログイン試行
const tryLogin = (userId, password) => {
    const loginResultJSON = await connection('/login', {
        body: { userId, password }
    });
    return JSON.parse(loginResultJSON);
}

// データを取得
const getDataList = () => {
    const dataListJSON = await connection('/getData');
    return JSON.parse(dataListJSON);
}

// データをHTMLに結合する
const appendDataListToHTML = (dataList) => {
    // データをDivに変換する
    const dataDivs = dataList.map((data) => {
        const div = document.createElement('div');
        div.innerHTML = `<p>${data}</p>`;
        return div;
    })

    // 変換したDivをHTMLに連結する
    const dataView = document.getElementById('dataView');
    dataView.append(...dataDivs);
}

let loginFailureCount = 0;
document.getElementById('loginButton').addEventListener('click', async () => {
    // エレメントを取得
    const userIdInput = document.getElementById('userId');
    const passwordInput = document.getElementById('password');

    const loginResult = await tryLogin(userIdInput.value, passwordInput.value);

    // ログインできなかったときの処理
    if (!loginResult.ok) {
        loginFailureCount++;
        if (loginFailureCount > 3) {
            // 3回目でキャプチャを表示
            const caputureDiv = document.getElementById();
            caputureDiv.style.display = 'block';
        }
        return false;
    }

    const dataList = await getDataList();
    appendDataListToHTML(dataList);
});

DOMの操作とロジックを意識して分割しました。これにより、関数に与える値のみを揃えれば関数を利用することができ、コードの再利用性を向上させることができます。

ESModules <script type="module"> を使う

生のJavaScriptでよくあがる問題として、下のような事柄が挙げられます。

  • ファイル分割・ライブラリ導入ごとに <script type="text/javascript"> が増える問題
  • とりあえず jQuery($ => {}) で囲んでしまう問題

2017年ごろからブラウザに順次導入されたESModulesを利用することで、これらの問題を解決することができます。

ES Modulesとは

ES Modulesは、JavaScriptファイルの分割と読み込みをサポートする仕組みの一つです。 export 文と import 文を組み合わせることで、JavaScriptを適時分割することが可能になり、コードの可読性とモジュール性を大幅に向上させることができます。

import / export

下の例を見てください。一番目は単体のJavaScriptファイル、二番目はHTMLです。

export 文を利用することで、関数を外側から読み込める形にしています。

// ./script.js
export const showDateText = (year, month, day) => {
    return `${year}${month}${day}日`;
}

こちらでは、 import 文を利用し他のファイルから関数を読み込んでいます。

<!-- index.html -->
<script type="module">
    import { showDateText } from './script.js';

    const pElm = document.getElementById('hoge');
    pElm.innerText = '私の誕生日は' + showDateText(1998, 4, 14) + 'です。';
    // p要素の内容は ”私の誕生日は1998年4月14日です。” になる
</script>

<p id="hoge">
</p>

コメントにある通り、 p 要素の内容は ”私の誕生日は1998年4月14日です。” になります。

上は簡単な例ですが、ES Modulesはページを跨いで使用される関数が増えるたびに利便性が向上します。
JavaScriptを分割し、それを読み込むためには <script src=""> が必要でしたが、ES Modulesを利用すると必要な関数を必要なときに外部のファイルから読み込むことが可能になり、グローバルスコープを汚さずにHTMLのheadも最低限のタグ数で済ますことができます。

onLoad いらず

また、moduleとして読み込まれたJavaScriptは、head内に定義されていたとしてもDOMの読み込みが完了した後に実行されます。(JavaScriptのイベント domcontentloaded 相当)

今までDOMを操作するためにスクリプトをHTMLの下部に書いたり、イベントリスナやjQueryに関数を渡してその中でスクリプトを書いていた人は、moduleに置き換えることで簡潔にかくことができるようになります。

<script type="text/javascript">
    window.addEventListener('domcontentloaded', () => {
        const pElm = document.getElementById('hoge');
    });
</script>

<p id="hoge"></p>

上をmoduleで置き換えると下のようになります。

<script type="module">
    const pElm = document.getElementById('hoge');
</script>

<p id="hoge"></p>

そして、ES Modulesは後述するWebComponentsと相性が非常に良いです。

ただし、ES Modulesは**IEには対応されていません。**IEへの対応が必要な場合はBabelやWebpackを使うなどしましょう。

Web Componentsを知る

最後に、発展系としてWeb Componentsを紹介します。

Web Componentsとは

Web Componentsは、独自のタグを定義することでHTML要素のコンポーネントかを助ける仕組みです。複数のページにまたがって利用したり、様々な箇所で何度も利用したりするときに、JavaScriptで独自のHTMLタグを定義することでコードの重複を防ぎます。ReactやVueを利用したことがある人はイメージしやすいかもしれません。

コンポーネントの定義

下の例を見てみましょう。これは、実際にオリジナルなコンポーネントである <original-header><original-footer> を定義しているところです。

// CommonElements.js

class OriginalHeader extends HTMLElement {
    constructor() {
        super();
        this.render();
    }

    render() {
        // Divを作成し、自身(HTMLElement)に連結させています。
        const div = document.createElement('div');
        div.style.backgroundColor = 'lightskyblue';

        div.innerHTML = `
            <p>これはヘッダーです。ようこそオリジナルページへ。</p>
            <p>JavaScriptを通して読み込むことで、様々なHTMLから呼び出すことができます。</p>
        `;

        this.append(div);
    }
}

class OriginalFooter extends HTMLElement {
    constructor() {
        super();
        this.render();
    }

    render() {
        // こちらも同じです。
        const div = document.createElement('div');
        div.style.backgroundColor = 'lightskyblue';

        div.innerHTML = `
            <p>これはフッターです。ページを見ていただきありがとうございました。</p>
            <p>WebComponentsの仕組みを利用して、再利用可能なコードを目指しましょう。</p>
        `;

        this.append(div);
    }
}

// HTMLElementを継承しただけでは使用できる状態になりません。
// customElements.define関数を使い、登録する必要があります。
customElements.define('original-header', OriginalHeader);
customElements.define('original-footer', OriginalFooter);

定義したコンポーネントの使用

上で定義したエレメントを実際に使用してみます。

<script type="module">
    import './CommonElements.js';
    // CommonElementsの中でcustomElements.define()しているため、
    // ここでは読み込むだけで完了です。
</script>

<original-header></original-header>
<div>
    これはページの内容です。このHTMLがとても綺麗なことに注目してください。
</div>
<original-footer></original-footer>

このページをWebブラウザで読み込むと、下のようなページが出現します。

スクリーンショット 2020-07-24 16.05.09.png

定義した内容が表示されていることがわかります。

例にあるように、ヘッダーやフッターなど様々なページで使いまわされる要素をCustomElementsとして定義しておけば、各ページごとにHTMLを定義する必要がなくなります。

ロジックやプロパティ渡しも可能

定義したエレメントは普段使っているエレメントと同じです。ReactやVueのように独自の記法を理解する必要はなく、基本的には今までと同じ感覚で定義することが可能です。
ロジックが必要なボタンやフォームなどでも、ロジックをあらかじめメソッドとして定義しておけば様々なページで使い回すことができ、結果的にページ独自のスクリプト量を減少させることが可能です。ReactやVueと同じように、HTML側からプロパティとしてデータを渡すことも可能です。

Web Componentsは(IEを除く)ブラウザ標準ですので、すぐに使い始めることができます。

フロントエンドフレームワークの導入を考え続ける

最後に、タイトルと相反する内容にはなりますがフロントエンドフレームワークの導入を考え続けましょう。

ReactやVueなどのフロントエンドフレームワークは、DOMとうまく付き合う方法を提供しています。
ReactはHTMLをJavaScriptのオブジェクトとして扱い、VueはDOMの操作を抽象化することでコードを簡潔にすることを目指しています。

開発者は設けられた枠組みの中でコードを書くことで、コードは分割され、コンポーネントとして再利用が可能な形になり、見通しが向上します。最近では、DOMの更新をできるだけ抑え、ページの高速化ができるような仕組みも整えられています。

一方、生のJavaScriptを利用すると、全て自由にコードを記述することができるようになります。ReactやVueなどとは違い、直感的な操作ができます。

ですが、DOMの操作には制限がありません。そのような中でDOMを使うとあっという間にJavaScriptとDOM、延いてはHTMLと密結合なコードを生成することになります。機能の追加や削除を繰り返しているうちに画面描写に関するコードがスクリプト中に散らばり、可読性が低下していきます。

ここまでに書いた様々な工夫を試すことで、ある程度コードの品質を維持することはできます。しかしながら、これは考え方でありフレームワークによって提供される枠組みではありません。可能であれば、フロントエンドフレームワークを導入を進めてみましょう。

155
157
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
155
157

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?