Webフロントエンドだけでファイルを読み書きしたい

5 min読了の目安(約5000字TECH技術記事

こんにちは、Webアプリは作りたいけどサーバは立てたくないWebプログラマのpirosukeです。

今回はアプリケーションサーバやデータベースを使用せず、Webのフロントエンドだけでファイルの内容を読み込んだりファイルを出力したりする機能を作る方法を紹介します。

使用するのはファイルのアップロード機能とダウンロード機能です。

Webサイトでファイルをアップロードしたりダウンロードしたりする機能といえば、ファイルをインターネット上のサーバに送ったりサーバからファイルを受け取ったりする形が多いですが、実は選択したファイルをそのままフロントのJavascriptで読み込んで使用したり、Javascriptでファイルを出力したりすることができるのです。

この仕組みを使うことで、例えばメモ帳のようにファイルを開いて中身を編集し、編集が終わったら保存するような機能をWebアプリをHTMLとJavascriptのフロントエンド処理だけで実現することができます。

アプリ実装例

先日私が娘に百人一首で勝つために作った「百人一首特訓ツール」でこのフロントエンドでのファイル入出力機能を実装したので、例として紹介します。Vue + Vuetify ベースで作成しています。

百人一首特訓ツール

ソースはこちら

https://github.com/pirosuke/hyakushu-static

このツールは「特訓を開始」すると和歌の上の句をもとに下の句を当てる問題が10問出題されて、
和歌ごとの正解率が記録されます。

この正解率の記録はVueのstateとして保存されているだけなのでブラウザを閉じたり他のサイトに移動すると消えてしまいます。

左側のメニューで「履歴の保存」をクリックするとstateに保管された正解率のデータをJSON形式でダウンロードすることができ、次回この特訓ツールを使う際に「履歴を読込」メニューでダウンロードしたJSONをアップロードすると前回の記録に次の特訓の結果が追加されます。

保存を忘れるとデータが消えてしまうので、未保存の記録がある状態で別のページに移動しようとすると確認ダイアログが表示されます。

このツールの機能のうち、「履歴の保存」と「履歴の読込」を行う機能で上述のファイルアップロード、ダウンロード機能を実装して使用しています。

JSONファイルを入出力する

まず下記のようなコードでアップロード用のボタンとダウンロード用のボタンを作成します。
(今回のツールはVue + Vuetifyベースなので使用しているタグが独特ですが本題の機能にはあまり関係ありません)

<v-list>
    <v-list-item link @click="saveLog">
        <v-list-item-action>
        <v-icon>mdi-download</v-icon>
        </v-list-item-action>
        <v-list-item-content>
        <v-list-item-title>履歴を保存</v-list-item-title>
        </v-list-item-content>
    </v-list-item>
    <v-list-item>
        <v-list-item-action>
        <v-icon>mdi-upload</v-icon>
        </v-list-item-action>
        <v-list-item-content>
        <v-list-item-title>
            <label>
            履歴を読込
            <input ref="file" class="file-button" type="file" @change="loadLog" />
            </label>
        </v-list-item-title>
        </v-list-item-content>
    </v-list-item>
</v-list>

「履歴を保存」メニューをクリックすると関数「saveLog」が実行され、ダウンロード処理が行われます。

「履歴を読込」メニューをクリックするとファイル選択ウィンドウが開き、ファイルを選択すると関数「loadLog」が実行され、ファイル内容の取得が行われます。

ファイルダウンロード機能を実装する

次にそれぞれの関数を実装します。

関数「saveLog」で実行されるファイルのダウンロード処理では「file-saver」というnpmモジュールを使用しています。

https://www.npmjs.com/package/file-saver

ファイルに保存したいデータをBlobオブジェクトにして、このモジュールの関数「saveAs」に渡すと指定したファイル名でダウンロード処理を走らせることができます。

下記のように実装すると、関数「saveLog」が呼ばれたときに「answer_log.json」というファイル名でblobの内容が含まれるファイルがダウンロードされます。

import { saveAs } from "file-saver"

...

saveLog () {
    const blob = new Blob([JSON.stringify(<保存したいオブエクト>)], {
        type: "application/json"
    })

    saveAs(blob, "answer_log.json")
},

ファイルアップロード機能を実装する

関数「loadLog」で実行されるファイルのアップロード処理では、まず選択されたファイルをファイルコンポーネントから取得し、内容をチェックしてからFileReaderで読み込んでいます。

FileReaderは最近のブラウザなら標準で使用可能なファイル読込用APIです。

https://developer.mozilla.org/ja/docs/Web/API/FileReader

下記のように実装することでアップロードされたファイルの内容をテキスト形式で読込み、それをJSONに変換して使用することができます。

async loadLog (event) {
    const files = event.target.files || event.dataTransfer.files
    const file = files[0]

    if (!this.checkFile(file)) {
        alert("ファイルを読み込めませんでした")
        return
    }

    const logData = await this.getFileData(file)

    const logJson = JSON.parse(logData)
},

getFileData(file) {
    return new Promise((resolve, reject) => {
        const reader = new FileReader()
        reader.readAsText(file)
        reader.onload = () => resolve(reader.result)
        reader.onerror = error => reject(error)
    })
},

checkFile(file) {
    if (!file) {
        return false
    }

    if (file.type !== 'application/json') {
        return false
    }

    const SIZE_LIMIT = 5000000 // 5MB
    if (file.size > SIZE_LIMIT) {
        return false
    }
    return true
}

FileReaderの使い方については下記のQiita記事を参考にしました。

https://qiita.com/itoshiki/items/511d58b827f4ce2129fc

今回はJSONファイルの読み書きを行なっていますが、呼び出す関数や渡すパラメータを変更すればテキストファイルでも画像ファイルでも同様に読み書き可能のようです。

保存忘れ防止機能を追加する

ここまででファイルの読み書きはできるようになったわけですが、今の機能だけだとファイルをダウンロードし忘れてブラウザを閉じたり別のサイトに移動したりしてしまうとデータが消えてしまいます。

保存忘れを防ぐために

  • データ変更後に保存したかどうかを確認する機能
  • 保存せずに移動しようとしたら確認ダイアログを出す機能

を追加します。

保存後の編集確認用区分を追加する

今回のツールでは下記のような「保存後に編集されたか区分(isEditedAfterSave)」を追加し、

state: {
    isEditedAfterSave: false,
},

例えば回答が追加されたらこの区分が「true」になるようにしておきます。

他のページへの遷移時にチェックを追加する

他の画面に移動しようとしている際に何かチェックを入れたい時は「beforeunload」イベントにトリガーを設定しておきます。

created () {
    window.addEventListener("beforeunload", this.confirmSave)
},

destroyed () {
    window.removeEventListener("beforeunload", this.confirmSave)
},

イベント処理関数の中で「保存後に編集されていたら確認ダイアログを表示する」処理を入れておきます。
確認メッセージを設定していますが、Chromeはダイアログを出してくれるもののメッセージは固定のものが表示されます。

confirmSave (event) {
    if (this.$store.state.isEditedAfterSave) {
        event.returnValue = "履歴を保存せずにページを離れようとしています。このまま移動しますか?"
        return "履歴を保存せずにページを離れようとしています。このまま移動しますか?"
    }
},

beforeunloadイベントについては下記のQiita記事を参考にしました。

https://qiita.com/mimoe/items/ccd57821c3ae6b4f8495

おわり

デスクトップアプリのようにファイル指定なしでワンクリックで保存できる上書き保存機能の実現は難しそうですが、ひとまずフロントエンドだけでファイルの保存と読込を行うことができました。

これでちょっとしたデータ保存ツールくらいなら面倒なサーバ運用なしで作れそうです。