Laravel + React +SQLite(Wasm 版)でローカル・ブックマーク機能をつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、この間知ったのですが、どうやらJavaScriptwasmWebassembly)であることができるのを知りました。

それが・・・・・・

SQLiteを操作することができる

というものです。

つまり、wasmを使うことで「ブラウザだけでSQLite」を動かすことができるんですね。

📝 参考ページ: WebAssemblyとは?

そこで❗

なんか面白そうだったので、「Laravel + React」の環境で使ってみることにしました。

機能は「(ウェブなのに)ローカルだけで完結するブックマーク」です。(需要は度外視ですが、もしかすると秘密のブックマークなんかにも使えるかもですね👍)

ぜひ何かの参考になりましたら嬉しいです。😄✨

「TikTok で英語リスニング
トレーニング中。
海外案件狙うぞ❗(できれば円安中に…)」

パッケージをインストールする

では、今回の機能で利用するパッケージ2つをインストールしておきましょう。
以下のコマンドを実行してください。

npm i sql.js vite-plugin-static-copy --save-dev

Vite で sql-wasm.wasm がコピーできるようにする

パッケージがインストールできたら、次にwasmファイルがインターネット上からアクセスできるようにファイルをコピーできるようにします。

vite.config.js

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import react from '@vitejs/plugin-react';
import { viteStaticCopy } from 'vite-plugin-static-copy' // 👈 ここを追加しました

export default defineConfig({
    plugins: [
        laravel({
            input: 'resources/js/app.jsx',
            ssr: 'resources/js/ssr.jsx',
            refresh: true,
        }),
        react(),
        viteStaticCopy({ // 👈 ここを追加しました
            targets: [
                {
                    src: 'node_modules/sql.js/dist/sql-wasm.wasm',
                    dest: 'wasm'
                }
            ]
        })
    ]

    // 省略

});

これでsql.jsの中にあるsql-wasm.wasmがビルドする度にコピーされる(=つまり、パッケージにアップデートがあっても自動で新しいものに置き換わる)ということになります 👍

では、早速以下のコマンドでファイルをコピーしてみましょう。

npm run build

すると、以下のようにファイルがコピーされますので、「http://*******/build/wasm/sql-wasm.wasm」でアクセスできるようになります。

ルートをつくる

では、準備は完了しましたので、ここからはコードを書いていきます❗

まずはルートです。

routes/web.php

// 省略

Route::get('wasm_sqlite_bookmark', fn() => Inertia::render('WasmSqliteBookmark/Index'));

ビューをつくる

では、今回のメインどころのビューです。
ちょっとコードが長いですが、そこまで複雑な部分はないのでぜひ順に見てください。

resources/js/Pages/WasmSqliteBookmark/Index.jsx

import initSqlJs from 'sql.js'
import {useEffect, useRef, useState} from "react";

export default function Index() {

    // DB
    const db = useRef(null);
    useEffect(() => {

        initializeDb();

    }, []);
    const initializeDb = (data = null) => {

        // Wasm の指定
        const config = {
            locateFile() {

                return  '/build/wasm/sql-wasm.wasm'

            }
        }

        // SQLite を初期化
        initSqlJs(config)
            .then(SQL => {

                db.current = new SQL.Database(data);

                if(! data) { // 初期の場合はテーブルを作成

                    const sql = `CREATE TABLE bookmarks (
                                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                                    title varchar(255) NOT NULL,
                                    url varchar(255) NOT NULL,
                                    created_at datetime NOT NULL
                                );`
                    db.current.run(sql);

                }

                loadBookmarks();

            });

    };

    // ブックマーク
    const [bookmarks, setBookmarks] = useState([]);
    const handleFileSelected = e => { // ファイルが選択されたとき

        const files = e.target.files;

        if(files.length > 0) {

            const file = files[0];
            const reader = new FileReader();
            reader.onload = () => {

                const data = new Uint8Array(reader.result); // ファイルを読み込んでデータ化
                initializeDb(data); // DB を初期化

            }
            reader.readAsArrayBuffer(file);

        }

    };
    const loadBookmarks = () => { // SQLite からブックマークを読み込む

        let newBookmarks = [];
        const stmt = db.current.prepare('SELECT id, title, url, created_at FROM bookmarks;');
        stmt.getAsObject();

        while(stmt.step()) {

            const row = stmt.getAsObject();
            newBookmarks.push(row);

        }

        setBookmarks(newBookmarks);
    };

    // パラメータ
    const [title, setTitle] = useState('');
    const [url, setUrl] = useState('');
    const handleSaveBookmarkClick = () => {

        // バリデーションは省略してます

        const sql = `INSERT INTO
                        bookmarks (title, url, created_at)
                        VALUES (?, ?, datetime('now'));`;
        const params = [title, url];
        db.current.run(sql, params);

        // パラメータを初期化
        setTitle('');
        setUrl('');

        loadBookmarks();

    };

    // ブックマークのエクスポート
    const handleDownloadClick = () => {

        const data = db.current.export();
        const blob = new Blob([data], {
            type: 'application/octet-stream'
        });
        const downloadUrl = window.URL.createObjectURL(blob);
        const link = document.createElement('a');
        const dateTime = new Date().toLocaleString().replace(/:/g, '-');
        const filename = `bookmark_${dateTime}.sqlite`;

        // ダウンロード
        link.href = downloadUrl;
        link.download = filename;
        document.body.appendChild(link);
        link.style = 'display:none';
        link.click();
        link.remove();

        setTimeout(() => {

            return window.URL.revokeObjectURL(downloadUrl); // メモリ解放

        }, 1000);

    };

    return (
        <div className="p-5">
            <div className="mb-3">
                <button type="button" className="text-white bg-blue-700 font-medium rounded text-sm px-3 py-2.5 mr-2 mb-2" onClick={handleDownloadClick}>
                    ブックマーク(SQLite)をダウンロード
                </button>
                <label className="text-white bg-gray-700 font-medium rounded text-sm px-3 py-2.5 mr-2 mb-2 cursor-pointer">
                    <input
                        type="file"
                        className="hidden"
                        onChange={handleFileSelected} />
                    ファイルからブックマークを読み込む
                </label>
            </div>
            <hr />
            <div className="px-3 py-5">
                {bookmarks.length > 0 && (
                    bookmarks.map(bookmark => (
                        <div key={bookmark.id} className="mb-3">
                            &#x1F4DD; <a href={bookmark.url} className="text-blue-700 font-medium font-bold" target="_blank">{bookmark.title}</a>
                        </div>
                    ))
                ) || (
                bookmarks.length === 0 && (
                    <small>ブックマークが見つかりません。以下から追加してください。</small>
                ))}
            </div>
            <div className="border border-1 bg-gray-200 p-3">
                <small>ブックマークを追加する</small>
                <div className="mb-3">
                    <input type="text" value={title} onChange={e => setTitle(e.target.value)} placeholder="タイトル" />
                </div>
                <div className="mb-3">
                    <input type="text" value={url} onChange={e => setUrl(e.target.value)} placeholder="URL" />
                </div>
                <button
                    type="button"
                    className="text-white bg-orange-500 font-medium rounded text-sm px-2 py-1.5 mr-2 mb-2"
                    onClick={handleSaveBookmarkClick}>
                    追加する
                </button>
            </div>
        </div>
    );

}

では、重要な部分をひとつずつ紹介してきます。

DB

initSqlJs()は、sql.jsの関数です。

これを呼び出すことで、SQLiteが使えるようになるのですが、そのためには先ほどViteの方で自動コピーできるようした「wasmファイル」を指定しないといけません。

それが、「Wasm の指定」にあるlocateFileです。

そして、initializeDb()は、以下2つのパターンで実行されます。

  • はじめにページにアクセスされたとき
  • すでに保存したブックマークファイル(SQLite)が選択されたとき

そのため、引数の初期値はnullになっています。(つまり、ファイルが選択されたときはdataに中身が入っている状態になります)

ブックマーク

このセクションは、SQLiteからブックマークのデータを読み込む機能になります。

後で「ブックマークのエクスポート」部分にも出てきますが、SQLiteの保存データはUintのため、読み込んだファイルは形式を変換するようになっています。

そして、SQLiteからブックマーク・データを読み込むのがloadBookmarks()です。

といっても、ここはSQL文を発行してbookmarksの中身をsetBookmarks()で変更しているだけです。

パラメータ

ここは、新しいブックマークをSQLiteへ追加する部分です。

流れとしては、以下のとおりです。

  1. INSET 文を発行してデータを追加
  2. パラメータはもういらないので初期化
  3. SELECT 文を発行して新しいデータを読み込む

※ なお、コメントにも書いてありますが、バリデーションは全く書いてません。ご注意ください。m(_ _)m

ブックマークのエクスポート

wasmはブラウザ上で動いていますので、ページがリロードされるとその内容は失われてしまいます。

そのため、追加したブックマークをファイルとして保存するのがこの部分です。

コードは少し長いですが、DBからデータを取得し、それをJavaScriptダウンロードできるようにしているだけです。

なお、「メモリ解放」の部分は気をつけてください。
どうもこれがないとメモリが開放されず、最悪の場合ブラウザが動かなくなってしまうようです。(キャッチ・アンド・リリースです!)

以下のページがとても参考になりました。
ありがとうございます!

📝 参考ページ: JavaScript】 createObjectURL()した後にrevokeObjectURL()が必要な理由

これで作業は完了です❗
お疲れ様でした😄👍

テストしてみる

では、実際にテストしてみましょう❗

まずはViteを起動して「http://******/wasm_sqlite_bookmark」へアクセスします。

すると、以下のように表示されます。

では、まだブックマークがないので以下のように入力して「追加する」ボタンをクリックしてみましょう。

すると・・・・・・

はい❗
新しいリンクが登場しました。

では、この要領で3つほどブックマークを追加してみます。

※ 最後だけはこのブログでブックマークしました 👍

そして、ブックマークのダウンロード(エクスポート)ができるかをボタンをクリックして確認してみましょう。

どうなるでしょうか・・・・・・

はい❗
ダウンロードが自動で開始され、ブラウザに表示がでました。

では、一旦ページをリロードし、初期化します。

そして、ファイルからブックマークを読み込むため、ボタンをクリック&先ほどダウンロードしたSQLiteのファイルを選択してみます。

うまくいくでしょうか・・・・・・

はい❗
先ほど追加した内容を復活させることができました。

すべて成功です😄✨

デモページを用意しました

せっかくなのでデモページを用意しました。
ぜひ実際に試してみてください。

📝 デモページ

※ 冒頭でも書きましたが、サーバー側にデータが送られることはありませんので、秘密のブックマークとして普段遣いしてもらっても問題ありません。👍

企業様へのご提案

今回のようにwasmWebassembly)を使うと、以下のメリットがあります。

  • コードが分離されているのでセキュリティに強い
  • バイナリ化されている(機械が読みやすい内容になっている)ので高速に実行できる

もしWebassemblyを使った開発をご希望でしたら、いつでもお気軽にご相談ください。

お待ちしております。😄✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はWebassemblySQLiteを使ってブックマーク機能をつくってみました。

個人的にはWebassemblyWasmerを使って「どんな環境でも実行できる」ようできるため、注目しているテクノロジーのひとつです。

さらに、Webassemblyは最近人気になっているRust言語からでもつくることができる(※以下ページを参考にしてみてください)ので、これからの流れにのっているんじゃないかと考えています。

📝 参考ページ: WebAssembly を Rust でつくり、JavaScript でつかう

未来予想はそこまで得意じゃないですけど、ぜひ皆さんも研究してみてくださいね。

ではでは〜❗


「口呼吸 → 鼻呼吸
で体調復活❗」

このエントリーをはてなブックマークに追加       follow us in feedly