連載目次
| 回 | 内容 |
|---|---|
| #1 | 企画・課題発見 |
| #2 | 要件定義 |
| #3 | 設計 |
| #4 | データ処理・ロジック実装編(この記事) |
| #5 | UI・画面実装編 |
| #6 | テスト・CI/CD構築編 |
| #7 | 開発中に遭遇した技術的問題 |
| #8 | 完成・振り返り |
はじめに
前回は設計編として、使う技術、フォルダの役割分担(app/components/lib)、データの形を決めました。
今回から実装編です。まずは画面を持たない処理を集めた lib フォルダの実装を書きます。データの型、ブラウザへの保存、文章からキーワードを自動で見つける仕組みの3つです。
開発の進め方:Issue → ブランチ → PR
実装は、作業ごとに GitHub の Issue を立てて進めました。ブランチの運用は Git Flow で、main(公開中の版)と develop(開発中の版)の2本を軸にしています。
- 作業内容を Issue に書く(概要・やること・終わりの条件)
-
developからfeature/{Issue番号}-{内容}というブランチを作って実装する - PR(プルリクエスト)を作り、
developにマージする - Issue を閉じる前に、「①苦労した点 ②工夫した点 ③こだわった点」をコメントに残す
今回の lib の実装は、たとえば次のように進みました。
| Issue | ブランチ | PR |
|---|---|---|
| #2 状態設計の実装(型定義・localStorage連携) | feature/2-state-model |
#13 |
| #7 練習画面(3モード):暗記・穴埋め・キーワード | feature/6-7-practice-screen |
#16 |
①②③のコメントは、この記事を書く材料にもなっています。
1. データの型(lib/types.ts)
#3 のクラス図を、TypeScript の型にそのまま書き起こしました。
export type ChunkStatus = "new" | "learning" | "mastered";
export interface Text {
id: string;
title: string;
rawText: string;
folderId: string | null;
bookmarked: boolean;
blockCount: number;
chunks: Chunk[];
status: ChunkStatus;
createdAt: number;
updatedAt: number;
}
-
ChunkStatusは、取れる値を3つの文字列に限定した型(ユニオン型)です。"leaning"のような打ち間違いを書くと、その場で TypeScript がエラーを出してくれます -
folderIdはstring | nullにして、「どのフォルダにも入っていない(未分類)」をnullで表します -
createdAtとupdatedAtはDate.now()のミリ秒の数値です。数値なので、引き算だけで新しい順に並べ替えられます
最初は、プロトタイプ(#2で書いた「積み上げ暗唱」)の処理を参考にしました。プロトタイプは型のない JavaScript だったので、クラス図で決めた項目とプロトタイプの実装に食い違いがないかを、1つずつ照らし合わせながら型にしていきました。
2. ブラウザへの保存(lib/storage.ts)
データの保存と読み出しは、この4つの関数だけに集めました。
const TEXTS_KEY = "anki-note-texts-v1";
export function loadTexts(): Text[] {
try {
const raw = localStorage.getItem(TEXTS_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveTexts(texts: Text[]): void {
try {
localStorage.setItem(TEXTS_KEY, JSON.stringify(texts));
} catch {
// localStorageが使えない環境では何もしない
}
}
フォルダ用の loadFolders / saveFolders も同じ形です。
読み出しは「何があっても配列を返す」
loadTexts は、次のどの場合でも必ず配列を返します。
| 状況 | 起きること | 対処 |
|---|---|---|
| まだ何も保存していない |
getItem が null を返す |
空の配列にする |
| 保存データが壊れている |
JSON.parse がエラーを投げる |
catch で空の配列を返す |
| 読めたが配列ではない | 画面側で map などが使えない |
Array.isArray で確かめる |
| プライベートモードなどで localStorage が使えない | アクセスするとエラーになる |
catch で受け止める |
ここで守りを固めておけば、呼び出す画面側は「配列が返ってくる」ことだけを前提に書けます。画面のたびにエラー処理を書く必要がありません。
キー名に「-v1」を付ける
保存するキーの名前は anki-note-texts-v1 のように、末尾にバージョン番号を付けました。将来データの形を大きく変えたときに、新しいキー(-v2)に保存すれば、古い形のデータと混ざりません。
壊れたデータのテストも書く
テストでは、正常に保存・読み出しできることに加えて、壊れたデータが入っている場合も確かめています。
beforeEach(() => {
localStorage.clear();
});
it("壊れたJSONが入っている場合は空配列を返す", () => {
localStorage.setItem("anki-note-texts-v1", "{invalid json");
expect(loadTexts()).toEqual([]);
});
テストのたびに localStorage.clear() で中身を空にして、前のテストで保存したデータが次のテストに影響しないようにしています。
3. テキストの加工(lib/textProcessing.ts)
文に分ける(splitSentences)
文章を「改行」と「。!?」で区切って、文の配列にします。
const lines = trimmed.split(/\r?\n/);
for (const line of lines) {
const parts = line.trim().split(/(?<=[。!?])\s*/);
// 空でない部分を結果に加える
}
- 普通に
split("。")で区切ると、区切り文字の「。」が消えてしまいます。正規表現の 後読み(?<=[。!?])を使うと、「。」の直後の位置で区切れるので、句読点を文の末尾に残せます - 改行も文の区切りにしています。句読点を付けずに、1行1文で入力する人もいるためです。この対応が必要だと分かったのは、実際に使ってみてからでした(#7で書きます)
-
\r?\nにしているのは、Windows の改行(\r\n)にも対応するためです
この関数は現在、タイトルを自動で作る makeTitle で使っています。タイトルを入力せずに登録すると、本文の最初の1文(長ければ22文字で切って「…」を付けたもの)がタイトルになります。
ブロックに等分する(buildChunks)
本文を文字数で数え、指定した数のブロックにほぼ均等に分けます。
const count = Math.max(1, Math.min(Math.floor(blockCount), text.length));
for (let i = 0; i < count; i++) {
const start = Math.floor((i * text.length) / count);
const end = Math.floor(((i + 1) * text.length) / count);
// text.slice(start, end) を1ブロックにする
}
「前のブロックの終わり=次のブロックの始まり」になるように位置を計算しているので、7文字を3つに分けるような割り切れない場合でも、文字が抜けたり重なったりしません。
この関数は、長文を分割して少しずつ覚える機能のために作りました。分割機能はその後廃止したので、今は常に「1ブロック=本文全体」で呼んでいます。
キーワードを自動で見つける(extractKeywordSegments)
このアプリの中心になる処理です。穴埋めモードで空欄にする言葉と、キーワードモードで表示する言葉を、文章から自動で見つけます。
/** 漢字・カタカナ・数字・英字の連続をキーワードとみなして分割する */
const KEYWORD_CHAR = /[一-龯ァ-ヶー0-9A-Za-z]/;
export type TextSegment = { text: string; isKeyword: boolean };
export function extractKeywordSegments(sentence: string): TextSegment[] {
const segments: TextSegment[] = [];
let i = 0;
while (i < sentence.length) {
const isKw = KEYWORD_CHAR.test(sentence[i]);
let j = i + 1;
while (j < sentence.length && KEYWORD_CHAR.test(sentence[j]) === isKw) j++;
segments.push({ text: sentence.slice(i, j), isKeyword: isKw });
i = j;
}
return segments;
}
処理の流れはこうです。
- 先頭の文字が「キーワードの文字(漢字・カタカナ・長音・数字・英字)」かどうかを調べる
- 同じ種類の文字が続くところまで読み進める
- そこまでを1つのかたまりとして、キーワードかどうかと一緒に記録する
- 次の文字から繰り返す
例えば「私は東京タワーに3回行った。」は、次のように分かれます。
| かたまり | 私 | は | 東京タワー | に | 3回行 | った。 |
|---|---|---|---|---|---|---|
| キーワード? | ○ | × | ○ | × | ○ | × |
日本語の文章では、意味の中心になる言葉(名詞や専門用語)の多くが漢字やカタカナで書かれ、ひらがなは助詞や送りがなに使われます。この性質を使うと、形態素解析のライブラリを入れなくても、文字の種類を調べるだけで「覚えるべき言葉」をおおまかに取り出せます。
V0の方式ではなく、自動で見つける方式にした
#3 で書いたとおり、V0で作った画面のコードでは、覚えるべき言葉を あらかじめ手で指定しておく方式 になっていました。しかし、利用者が自分の文章を貼り付けて使うアプリなので、毎回手で言葉を指定するのは手間がかかります。そこで、この関数で自動で見つける方式にしました。
1つの関数を2つのモードで使う
穴埋めとキーワードは、見せ方が逆です。
- 穴埋め:キーワードを 空欄にして、それ以外を表示する
- キーワード:キーワード だけを表示する
判定の基準は同じなので、この1つの関数の結果を、画面側で見せ方だけ変えて使っています。どちらのモードでも「何がキーワードか」が必ず一致します。
元の文に戻せることもテストする
it("元の文を全セグメントの結合で復元できる", () => {
const sentence = "私は東京タワーに3回行った。";
const segments = extractKeywordSegments(sentence);
expect(segments.map((s) => s.text).join("")).toBe(sentence);
});
画面は、この結果を順番に並べて文章を表示します。1文字でも抜けたり重なったりすると、表示される文章が元と変わってしまいます。そのため、「全部つなげると元の文に戻る」ことをテストで確かめています。
割り切っている点
文字の種類だけで判定しているので、例の「3回行」のように、送りがなの手前で切れてしまうことがあります。厳密な単語の区切りにはなりません。ここは、外部のライブラリを使わずに軽く動くことを優先して受け入れた制約です。
まとめ
- 作業ごとに Issue を立て、
feature/{番号}-{内容}ブランチ → PR →developの流れで実装した - データの形は TypeScript の型で表し、取れる値を絞って打ち間違いを防いだ
- 保存・読み出しは4つの関数に集め、何があっても配列を返すようにした
- キーワードは文字の種類で自動判定し、1つの関数を穴埋めとキーワードの2つのモードで使った
次回は UI・画面実装編です。ここで作った関数を、ホーム画面や練習画面からどう使っているかを書きます。