JavaScriptのyield*は移譲先ジェネレーターがreturnした値はyieldしない

本記事は https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/yield*#%E4%BE%8Byield* 式自体の値 に例示されているものを改めて考えつつ説明したものです。

https://github.com/susisu/tskaigi2025 のコードリーディングをしていて、ここがわからなくてしばらく詰まっていた。

it("ジェネレータから yield された Promise が fulfill されたらその位置から再開する", async () => {
  function* myFunc(): Comp<number> {
    const a = yield* waitFor(Promise.resolve(1));
    const b = yield* waitFor(Promise.resolve(2));
    return a + b;
  }
  const promise = run(myFunc());
  await expect(promise).resolves.toEqual(3);
});

JavaScriptには yield* という構文がある。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/yield*

これはジェネレーターの中で使い、別のiterableに制御を移譲するために使う。とてもわかりやすい例があったので引用させてもらう。
https://stackoverflow.com/questions/17491779/delegated-yield-yield-star-yield-in-generator-functions

function* someGenerator() {
    yield 0;
    yield [1, 2, 3];
    yield* [4, 5, 6];
}

for (v of someGenerator()) {
    console.log(v);
}
// 0, [1, 2, 3], 4, 5, 6

yield [1, 2, 3];[1, 2, 3] をそのままyieldする。一方で yield* [4, 5, 6];[4, 5, 6] がiterableなオブジェクトならば、その生成値を1つずつyieldする。移譲すると書いたが、someGeneratorの呼び出し側から見ると、内側の内側にあるiterable([4, 5, 6] のこと)を透過的に扱えるとも言えそうだ。

ところでジェネレーターの中ではyieldの他にreturnも使える。returnに到達すると値を返しつつジェネレーターは終了する。

const gen = function* () {
  yield 1;
  return 2;
}()

console.log(gen.next()); // { done: false, value: 1 }
console.log(gen.next()); // { done: true, value: 2 }
console.log(gen.next()); // { done: true, value: undefined }

では yield* で移譲されたジェネレーターがreturnしたとき何が起きるのか。僕はなんとなくreturnされた値も透過的に外側から触れると思っていたが、触れない。

const g_inner = function* () {
    yield 1;
    return 2;
}();

const g_outer = function* () {
    const a = yield* g_inner;
    yield 3;
    return 4;
}()

console.log(g_outer.next()); // { done: false, value: 1 }
console.log(g_outer.next()); // { done: false, value: 3 }
console.log(g_outer.next()); // { done: true, value: 4 }

なんとなく出てくる値は 1, 2, 3, 4 になりそうな気がしたが(俺だけか?)、正解は1, 3, 4だ。MDNに

yield* は式であり、文ではありません。そのため、値に評価されます。

とサラリと書かれている通り、g_innerのreturnで返される値はg_outerのaに束縛される。それだけで、g_outerの呼び出し側には返されない。

ここで逆に考えてみる。移譲先のreturnで停止すると仮定すると、どのような問題が起きるだろうか?

まず、yieldの機能として、呼び出し側からnextに引数を与えることで、値を受け渡すことができる。

const gen = (function* () {
  const a = yield 1;
  const b = yield 2;
  return a + b;
})();

console.log(gen.next());     // { value: 1, done: false }
console.log(gen.next(10)); // { value: 2, done: false }
console.log(gen.next(20)); // { value, 30, done: true}

初回呼び出し後にyield 1;で停止しているジェネレーターに対して10を与えて再開させ、次にyield 2;で停止しているジェネレーターに20を与えて再開させている。3回目のnext呼び出しの後はreturn a+bに到達して30を返して終了する。

これは yield* で移譲された内側のジェネレーターにも機能するのだが、returnで停止すると仮定してシミュレーションしてみる。

const g_inner = function* () {
    const a = yield 1; // aに10が入る
    return 2; // ここで停止すると仮定
}();

const g_outer = function* () {
    const x = yield* g_inner;
    yield 3;
    return 4;
}()

console.log(g_outer.next());     // { done: false, value: 1 }
console.log(g_outer.next(10)); // { done: false, value: 2 } が返ると仮定する
console.log(g_outer.next(20)); // { done: false, value: 3 } このnextに渡した20はどこへ…?
console.log(g_outer.next());     // { done: true, value: 4 }

return 2で停止すると仮定すると、そのときnextに渡された値はどこに行くのだろう?xにはg_innerから返された値が入ることになっているので行き場がない。捨てるしかない。そうなると、g_outerを利用する側は中でg_innerに処理を移譲しているなんて本来知る必要がないのに、それを知らないと何回目かのnextに渡した値が勝手に捨てられてしまうことになる。インターフェースとしての安定性が失われている。

そういう意味で、returnでは停止しないのは筋が通っている。割と非直感的だと思ったんだけど、MDNだとサラッと1行説明されて例が1つあるだけで終わるので難しかった。普段からジェネレーターを使ってる人(いますか?)には自明な動作なのかもしれない。

一般のジェネレーターにおいてreturnは停止して値を返すという点でyieldと同じように見えるが、その見方は間違っているのだろう。yieldが明確に停止を意図した機能である一方で、returnは終了だから結果的に停止に見えてしまっているだけで、その本質はただ値とともに処理を返すことだけだ。yield* のようにその後も外側のジェネレーターに制御を戻して続行できるのであれば停止しないように見えるのは必然なのだろう。

20250529 『モナ王』/sort-package-json3.2.0でソートアルゴリズムが変わったっぽい

項目 内容 得点 換算点
睡眠時間 7時間19分 100 13.0/13.0
起床 8:08 93 7.4/8.0
散歩 実施・ゴミ拾いあり 100 5.0/5.0
朝食の栄養カバレッジ 3色カバー 100 5.0/5.0
体操 実施 100 5.0/5.0
労働 passion: 85点, discipline: 75点 80 19.2/24.0
ジム 有酸素 100 12.0/12.0
勉強会 参加 100 12.0/12.0
個人開発 実施 100 7.0/7.0
あすけん - 80 7.2/9.0
総合 1日の総合評価 - 93

今日の仕事は並列度が高くてあまり集中できなかったな。

『モナ王』

『モナ王』を食べた。『チョコモナカジャンボ』の隣に並んでいたらまず選ばない商品だが、だからこそ食べておくかということで食べた。結論から言うと特に選ぶ理由はない。チョコの風味が要らない、パリパリが要らない、そんなムーディーな気分の日ならいいかもしれない。

sort-package-json3.2.0でソートアルゴリズムが変わったっぽい

sort-package-jsonというライブラリがある。その名の通りpackage.jsonをソートすることで秩序をもたらすツールである。最近npmのソートアルゴリズムに合わせるということで変更が入ったのだが

https://github.com/keithamus/sort-package-json/pull/358

これがyarnのソートアルゴリズムとは違っており、しかも間の悪いことにyarnは yarn install を実行するたびにpackage.jsonをソートし直すので、sort-package-jsonとの共存が実質的に不可能である(開発中に yarn install をするたびに意味のない差分が発生し、commitするときにlint-stagedで消える)。じゃあsort-package-jsonの代わりに yarn install を使えばいいのでは?とも思うが、コマンドの意味・副作用からしてかなり正しくない度が高い解決だ。苦しい。

https://github.com/keithamus/sort-package-json/issues/363

ソートアルゴリズムの違いというのはどうやらこういう違いのようだ。

https://github.com/keithamus/sort-package-json/issues/355#issuecomment-2848325132

// yarn
> ["a", "b", "A", "B"].sort()
[ 'A', 'B', 'a', 'b' ]
// npm, sort-package-json
> ["a", "b", "A", "B"].sort((a,b) => a.localeCompare(b, "en"))
[ 'a', 'A', 'b', 'B' ]

実はnpmではパッケージ名に大文字を使ってはいけない。昔は使えたのかも知れないが、今は大文字名でinstallしようとすると404で弾かれる。だからこの問題は内製パッケージを使っていたりエイリアスを付けていたりしないと表面化しにくい(一応 -_ とかでも差が出てしまうらしい?)。

まあそこまで重大な違いではないように思えるが、sort-package-jsonは結構な人気があるパッケージなので、そこそこ困っている人がいるかもしれない。

20250422 フロントエンドDDDの記事を読んで思うこと

項目 内容 得点
睡眠時間 6時間39分 86
起床 7:47 100
散歩 実施・ゴミ拾いなし・犬遭遇3匹(プープーパピ?) 130
朝食の栄養カバレッジ 3色カバー 100
体操 実施 100
労働 した・passion: 75点, discipline: 85点 80
ジム 休養日 100
勉強会 参加 100
個人開発 実施 100
あすけん - 22
総合 1日の総合評価 88

今日はよく眠れた。そのせいか労働は特に気合が入っていたというわけでもないが自然体でスムーズによく進んだ。そうそう、これが僕の本当の能力よ。

いい気分になったのでお菓子を大量に食べてしまった。すまん、あすけん女。

イーサネットフレーム

個人開発ではパケットキャプチャを進めた。久々にRustのBoxやdynのことを思い出していた。Rustはコンパイル時にあらゆるデータのサイズが決まらなくてはいけないので、関数の返り値としてトレイトを返すことは原則できない。なぜならトレイトはその実装ごとに型とサイズが違うからだ。しかしその返り値への参照を返すという作りにすれば参照のサイズは固定だから問題なく、その方法を使ってトレイトを返り値にするときに dyn traitname と書くのだ。

まあなんか書くときの感覚よりもメモリ管理のルールが優先されるのはいかにもRustだよね。

今日は生のイーサネットフレームを得るところまで成功した。 u8[] で得られたのでそのままGeminiに放り込んでみたら普通にパースできてすごい。

フロントエンドDDDの記事を読んで思うこと

そう言えば今日は フロントエンドDDD という記事を読んだ。クラスなりなんなりで豊かなデータ構造を作ってそこにドメインロジックを集約するという考え方はとても良く、業界でも重厚なフロント作っている方だと自認している私としてもぜひ見習いたい考え方だと思った。

一方でこの記事が改めて注目を集めるほどに珍しいのは、フロントエンドの世界はどうしてもバックエンドからの通信でシリアライズされてメソッドが消えたり、Reactベースの世界観になるとクラスの差分検出がめんどいというところもあったりする。『コア価値であるロジックがフロントエンドの技術変遷の影響を受けすぎない』で述べられていることを裏返せば、通信やフレームワークの事情先行で作っていくとプリミティブ中心に薄く薄く…ということになりがち。そういうところを上手く分離して丁寧に構築する作り方をしてもなおメリットが上回るような重いものを作っていて、難なくそれを扱えるチームの熟練度があってこそかなあと思う。

関連記事: https://panda-program.com/posts/clean-architecture-and-frontend

『生成AIの作業領域との分離』という切り口は考えたことがなかったが確かにその通りだ。賢い。

import typeならallowImportingTsExtensionsは不要

import { foo } from "./foo.ts";

のように .ts を付けてimportするためにはtsconfig.jsonでallowImportingTsExtensionsがtrueになっている必要がある。しかしこのオプションはnoEmitも同時にtrueにしなければならない。その理由はuhyo氏が解説している。トランスパイルしたら.tsファイルじゃなくなるのでimportできなくなって意味ない(そこの辻褄合わせまでtscはやりません)のでトランスパイルしないでくださいということ。

https://zenn.dev/uhyo/articles/rewrite-relative-import-extensions-read-before-use#--allowimportingtsextensions-%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3

じゃあトランスパイル後に消えていいimport、つまりimport typeならよくね?と思って調べたら、実際そうだった。

// allowImportingTsExtensionsがfalseでもエラーにならない
import type { foo } from "./foo.ts";

https://github.com/microsoft/TypeScript/pull/54746

これに気づいたのは、vibe codingで軽めに作っているアプリケーションにテストを追加しようとしたときだ。現状nodeのビルトインテストフレームワークと--experimental-strip-typesを使うとjestやらvitestやらがなくてもそれっぽいテストが作れる(jestのためにトランスパイルとか、結構複雑だったよね)。

https://blog.koh.dev/2024-10-23-nodejs-builtin-test-typescript/

しかしnodeとして実行するならimportには拡張子が必要であり

https://zenn.dev/mizchi/articles/experimental-node-typescript#%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E8%A7%A3%E6%B1%BA

拡張子をつけると今度は「allowImportingTsExtensionsをtrueに旋回!」と怒られる。うーん、このルールの意図を考えればimport typeにすれば通るんじゃね?と思ったら通ったという次第。import typeにできる箇所で良かった。というかそうでなければそもそもtscだけではビルドできなかったんだけどな。

nodeのドキュメントにもimport typeはstripすると読める説明が書いてあった。

https://nodejs.org/api/typescript.html#importing-types-without-type-keyword

Due to the nature of type stripping, the type keyword is necessary to correctly strip type imports.

TypeScriptとの微妙な距離感が面白かった。

https://nodejs.org/api/typescript.html#importing-types-without-type-keyword

As in JavaScript files, file extensions are mandatory in import statements and import() expressions: import './file.ts', not import './file'.

The tsconfig.json option allowImportingTsExtensions will allow the TypeScript compiler tsc to type-check files with import specifiers that include the .ts extension.

あくまで拡張子付きのimportという文法が先にあり、それをTypeScriptが許可するかはオプション次第ですね〜というスタンスが感じられ、なるほど言語を作っている方からはそういう見方になるんだなあと思った。

20250420 回転寿司/gpt-4.1-miniの印象/prismaでgroupBy

遅めに起きて布団カバーを洗濯した。また冬が終わったので毛布類をコインランドリーで洗った。待つ間にくら寿司で豪遊(1100円)。

回転寿司

回転寿司というのは、極みである。食事の進行中に1品ずつ注文を受けてオンデマンドで調理される自由度の高さ、それを支えるweb注文とコンベヤ輸送という温かみの欠片もない高度な技術によるオペレーション、射幸心を煽るびっくらぽん。揚げ物スイーツラーメンなんでもありの無文化性。そんなに美味しくない寿司、腹に貯まる米による満腹感。誰も触らないせいで回転レーンで干からびていく寿司(そしてそのせいで一層誰も触らなくなる)。

食べる分量が空腹である入店時には決まらないという性質上食べ過ぎにくいというのはなかなか良いところだと思う。

洗いたての毛布を持ち帰って神の昼寝。起きたら友人が麻雀で大負けしていた。

gpt-4.1-miniの印象

またいろんなLLMを試しながら個人開発。これ自分じゃメンドクセぇ〜って思うところはやっぱりLLMにも任せられないね。今日の感触としてはこんな感じ。

  • gemini2.5: やたらと作業ログをコメントで残す。やめろと言ってもやめない。diffツールの使い方が下手で何度もやり直し金ばかりかかる。触るなと言ったところを触る。
  • gpt-4.1-mini: 頭も記憶力も悪いが時間をかけて誘導すれば一応仕事はできる

僕は安くてそこそこ使えるやつに興味があり、その点ではgpt-4.1-miniは良い。claudeはお高くて使いづらいんだけど優秀であるということを痛感。gemini2.5は高いし評判も良かったけど使ってみたらそれほどでもなかった。

prismaでgroupBy

具体的にやった作業はprismaのクエリいじり。アニメの作品(work)とエピソード(episode)が一対多対応であるという前提で、ある条件を満たすようなepisodeを2つ以上持つworkを抽出したい。生SQL(を使えるprisma API)だと

const works = await prisma.$queryRaw`
  SELECT w.*
  FROM work w
  JOIN episode e ON e.work_id = w.id
  WHERE e.some_condition = true
  GROUP BY w.id
  HAVING COUNT(e.id) >= 2
`;

のようにwhere→group by→havingの流れで2回絞り込みを行うことで実現するらしいのだが、これをprismaに持っていくと

const works = await prisma.episode.groupBy({
  by: ['workId'],
  where: {
    some_condition: true,
  },
  _count: {
    workId: true,
  },
  having: {
    workId: {
      _count: {
        gte: 2,
      },
    },
  },
});

となり、返り値の型が { _count: { workId: number }, workId: number } になる。つまり SELECT w.* が再現されずworkIdしか取れない。

既知のissueとしてはこの辺りが近い話に思われるが、いずれも対応される雰囲気がない。
https://github.com/prisma/prisma/issues/24816
https://github.com/prisma/prisma/discussions/6517

まあしないだろうなという感覚もわかる。prismaはそもそも様々なデータベースを隠蔽する抽象化の役割も持っており、各データベースのある程度細かい機能に逐一対応するのは無理だ。TypeScriptとの堅固な統合が持ち味であることを考えれば難易度は一層高い。

ある程度複雑なクエリ、パフォーマンスチューニングが求められるクエリは生SQLのAPIを使ってくださいよということなのだろう。

YouTubeの広告ブロッカーブロッカーの研究

自由研究 としてYouTubeが広告ブロッカーを検知する仕組みを調べていた。有名な広告ブロッカーを狙い撃ちにしているのかと思いきや、僕が 研究用 に作った簡易的なブロッカーにもちゃんと反応していた。

https://www.youtube.com/s/player/5ae7d525/player_ias.vflset/ja_JP/base.js より抜粋

    g.E.onClick = function(k) {
        k && k.preventDefault();
        var p, t;
        dDL(k, {
            contentCpn: (t = (p = this.api.getVideoData(1)) == null ? void 0 : p.clientPlaybackNonce) != null ? t : ""
        }) === 0 ? this.api.zb("onAbnormalityDetected") : (SA.prototype.onClick.call(this, k),
        this.api.zb("onAdSkip"),
        this.api.onAdUxClicked(this.componentType, this.layoutId))
    }

クリックイベントkをdDLに渡し、その返り値が0ならonAbnormalityDetectedイベントが発行され、その結果広告ブロッカーやめろ画面に遷移する。

    dDL = function(k, p) {
        var t = 1;
        k.isTrusted === !1 && (t = 0);
        a3("ISDSTAT", t);
        qO(t, "i.s_", {
            triggerContext: "sk",
            metadata: p
        });
        return t
    }

dDLの実装を見るとisTrustedがfalseなら0が返る。
https://developer.mozilla.org/ja/docs/Web/API/Event/isTrusted

ユーザーを意図しない操作から守るためにブラウザが提供している機能なので、これを出し抜くのは結構難しい。相当な荒業でやる方法はあるようだが、addEventListenerが実行される前にinjectせねばならずなかなか難易度が高い。
https://qiita.com/i11u/items/0a6d38966c75314bacba

Chrome extensionのdeclarativeNetRequestを利用してbase.jsがダウンロードされるときに該当箇所を書き換えてしまえばいいのではと思ったのだが、preload+service workerが使われている影響かbase.jsのGETを捕捉できなかった。あとはプロキシサーバーを立ててbase.jsだけすり替えるとかできるのかなあ。なんならChromiumをforkしてisTrusted機能を殺してしまうのもありかもしれない。Chrome拡張という配布のしやすい形を諦めるなら選択肢はいろいろある。

あくまで 研究 ですからね。私欲のために実行しませんよ。こういう研究をしていると広告が流れてくれた方が都合がいいのだが、そう思っているときに限って意外なほどに流れない。こんなに頻度低かったかな…?と思ってしまい、それなら別にいいやとなってしまった。逆物欲センサー(だいぶ昔の言葉になってしまったな)。

webサービスをどう設計するかは作り手の自由だが、それと同時に我々が自分の所有する計算機でどのような計算を行うかも自由だ。つまりはそういうことだ。

こういう漠然とやりたいことはあるけどどうやるべきかもどう書くべきかもわからない状況、雑にCline+Claudeに書かせておいてよくわからないところはCopilotに説明させてあーそういう意味ねと納得したりしている。Clineが大きなスコープでの作業を得意とする一方でCopilotはインライン操作が充実していて細かい部分について質問しやすい作りになっている。LLMの特性と言うよりはプロダクトとUIの特性だね。

20250222 Cline+Claudeやってみた

昨日は国外出張から戻った友人の無事を祝って御徒町で飲んでいた。飲みはとても楽しかったのだが寒さと胃腸の弱さ(普段は全く自覚しないが飲み屋でだけ覿面に弱くなる)ゆえに酷い胃もたれを起こしてしまい、帰って苦しんで寝ていた。寝るタイミングを逃し、寝ないまま今日に突入。午前に用事を済ませ、昼はもつ煮屋で食べてきた。スタミナもつ煮ってなんだろう、大きいのかな?と思ったらニンニクモリモリで、なるほど写真じゃわからんかった!!となった。

mizchi氏の魂がまた震えているらしいのでとりあえず真似してCline+Claudeを導入してみた。Clineは任意のLLMのサポートを得ながらvscodeを自動操作するvscode拡張で、単なるコード生成を超えてシェルやpuppeteerまで操作して勝手に動作確認までやり始める。マジですごい。ClineのバックエンドになるLLMは幅広く選べるのだが、おすすめはClaudeらしい(ローカルollamaは遅すぎて無理だった。GitHub CopilotはVS Code LM APIという仕組みを通じて使えるのだが、トークンが激増するらしくすぐrate limitになった)。Claudeを使いたいときはClaudeに直接金を払ってもいいし、OpenRouterというサービスを使ってもいい。OpenRouterは様々な商用LLMを統一的なインターフェースでアクセスできるように集約したもので、ここにお金を入れておくと使ったLLMに使った分だけ支払われる。若干手数料は乗るのだが、AIアシスト開発はいろいろなモデルを試す段階だと思うので便利。Claudeとdeepseekを比べてみたけどやっぱりClaudeの方が強かった。

とりあえず20ドル入れていろいろ遊んでみたのだが、使い方によって課金額が結構違う。コンテキストをリセットしないまま長話をするとトークンが嵩んで高くつくようだ。その辺りに少し慣れた状態で、日々の日記のアレを生成するサービスを作らせてみた。20コミット1000行分くらいのちょっとしたもので3~4ドルくらいかな。Claudeとの1回の応答で0.02ドルくらい。

https://chao7150.github.io/discipline/

(以下はサンプルです)

項目 内容 得点
起床 7:30 100
散歩 実施・ゴミ拾いあり・犬0匹() 100
朝食 三色食品群のうち0色カバー 0
体操 実施 100
労働 passion: 0点, discipline: 0点() 0
ジム 有酸素+筋トレ 100
勉強会 実施 100
個人開発 実施 100
総合 1日の総合評価 59

Claudeはとても賢い。曖昧な自然言語で指示を出してもかなり正確に意図を読み取ってくれるという点で対人間インターフェースは非常に優秀。知識も豊富でライブラリの使い方とかもよく知っている。コードの整理・美学みたいなものは弱く、場当たり的な構造を作りがちに見える(まあ1つずつ指示を受けながら作っていくという条件なら人間でもそうなるかもね)。Claudeを活用してある程度まで野放図に成長してしまったアプリケーションに、後から人間の開発者が複雑な機能を追加するとなると辛いだろうな。

これ仕事で使えるようになったら俺の仕事何が残るんだろう。大きいアプリケーションを作ろうとなったらやっぱり人間エンジニアが整理しないと破綻するのかなあ。なんかそれも希望的観測のように思える。非エンジニアが直接プロンプトを叩いて(しかも何回細かい修正指示を出しても不満を言わない)かなりの精度のものが作れるだろうし、そんな時代にコードを美しく整理するということに意味があるんだろうか。衝撃的な体験だった。

リストのツールチップをgridで作る/ローカルLLM環境構築

リストのツールチップをgridで作る

リストの項目をクリックすることで詳細情報が展開されるタイプのUI(これツールチップって言うんか…?)。

  • その項目の近くに表示されてほしい
  • 詳細情報が表示されることでリストの配置が乱れてほしくない

という前提を置くと、position: absolteで配置するのがシンプルだろう。しかしabsoluteによる配置物はレスポンシブ化が難しい。詳細情報のwidthを固定すると画面の横幅が狭いとき小さくなってくれずにはみ出すし、逆に親であるリストに合わせて100%とするとリストの大きさを先に確定させる必要があり、リストの隣にも表示物があるので不都合だった。

いろいろ考慮した結果gridで詳細情報表示領域を確保してやるのがいい感じだった。まず4行のgridを作る(画像では5行あるがここでは関係ない目的で1つ多い。気にしないで)。詳細情報を表示する前の各項目は3行だけ利用する(grid-column: span 3;)。

■■■□
■■■□
■■■□
■■■□
■■■□

そしてある項目の詳細情報を表示するときは、HTMLでその項目の直後に1行n列(図ではn=3)の要素を置く(grid-column-start: 4; grid-row: span 3; 4行目に3列分使って要素を配置する)。


■■■□
■■■□
■■■▤ <選択
■■■▤
■■■▤

cssは本当に難しい。各要素の大きさがどこで決まっているのかわからない、ググったときに情報の質がピンキリ、プログラミングよりも1つの問題に対して様々な解決法があり良くない意味での創意工夫の余地が大きい。さらに個人開発で言うと終わりがない。いつまでも触れてしまう。僕はあまり考え過ぎたくなくてGoogle検索をひたすらパクるという方針でやっているのだが、Google検索のUIも少しずつ変化しているのでな…。

ローカルLLM環境構築

AI系の開発をやってみたいけどOpenAIに金払いたくないのでローカルマシンでLLMを動かすというのにチャレンジしていた。動かす事自体はそう難しくなくollama(LLM界のdocker cliみたいなやつ)を入れて、huggingface(LLM界のdockerhubみたいなやつ)からpullしてきてrunすれば済む。そこにwebuiをつけたりAPIアクセスしたりweb検索エンジンと連携させたり…みたいな細かい手間もあるが別にAIだから難しいということはない。

難しかったのは個人所有程度のマシンでLLMを動かしても速度も質も全然ダメというところだ。browser-useでURLを指定してこれを開けと指示するだけでもCPU100%で30分間かかってまだ終わらない。こういうのをやってみるとChatGPTがいかにものすごいか(LLMを実用レベルにするためにどれだけの半導体と電力をつぎ込んでいるのか)というのがわかる。

また今アツい分野であるだけに技術の進み方も速く、今日入れてみたパッケージが今日の更新で壊れてて3時間前にissueが立ってリアルタイムで解決法が共有されているという光景を目にした。不安定ではあるがダイナミックな活力がある(他分野から見るとwebフロントもこう見えるんだろうか、最近はさすがにすこし落ち着いていると思うが)。

ReactでdetailsをoutSideClickで閉じるやつ

自作のアニメ視聴管理webサービス watch-duty-manager にこのようなメニューUIがある。ハンバーガーを押すとメニューが開く。これはHTMLのdetails/summaryタグで実現している。

こういうメニューはメニューの外側をクリックすると閉じるのが一般的だ。しかしdetailsタグは標準でそのような挙動をしない(まあメニューにだけ使われるものではないからね)。summary(ハンバーガー部分)をもう一度押すことで閉じることができる。外側クリックで閉じる方法を適当にググってみると

  • detailsが開いているときはcssでsummaryを画面全体を覆うように巨大化させる
  • JavaScriptのクリックイベントを全て拾ってなんとかする

という2つの方針が見つかった。1はなかなか賢いと思うものの、真面目に作り込もうと思うとなかなかバギーな部分もありそうで(特にレイヤー順とか)やめて2をやることにした。

みんな大好き react-use に useClickAway というカスタムフックがある。refで参照されたHTML要素の外側がクリックされたときに指定されたコールバックを実行する。実装としては愚直に2をやっているだけ。

// https://github.com/streamich/react-use/blob/ad33f76dfff7ddb041a9ef74b80656a94affaa80/src/useClickAway.ts#L16-L22

const handler = (event) => {
  const { current: el } = ref;
  el && !el.contains(event.target) && savedCallback.current(event);
};
for (const eventName of events) {
  on(document, eventName, handler);
}

これを使って

function Component() {
  const ref = useRef<HTMLDetailsElement>(null);
  useClickAway(ref, () => {
    if (ref.current && ref.current.open) {
      ref.current.removeAttribute("open");
    }
  })
  return (
    <details ref={ref}>
      <summary>summary</summary>
      details
    </details>
  )
}

てな感じでやれば一応動くものは作れる。作れるのだが、この作りだとdocument内のどこをクリックしても、画面内のこのコンポーネントの数だけ ref.current && ref.current.open の評価が走る。実際のユースケースでは100個くらいの表示はあり得る。まあ実際のところ100個あろうと大したコストではなく、使用感に影響を与える16msには届かないのだが、個人開発だからそういう現実的な損得はおいといて、もっと効率的な作りを追求することにした。

この作りの何が無駄かと言えば同時に開くdetailsは1個しかないのに画面内のdetails全てに対して同じ判定関数を与えていることだ。そこでデフォルトではなにもしない関数を渡しておいて、detailsが開いたときだけuseClickAwayに渡す関数を差し替えることにした。

const noOpCallback = () => {};

const useCloseDetailsOnClickAway = () => {
  const ref = useRef<HTMLDetailsElement>(null);
  const closeCallback = useCallback(() => {
    if (ref.current) {
      ref.current.removeAttribute("open");
    }
  }, [ref.current]);
  const [onClickAway, setOnClickAway] = useState({ f: noOpCallback });
  const onToggle = useCallback<React.ReactEventHandler<HTMLDetailsElement>>(
    (_) => {
      if (ref.current === null) {
        return;
      }
      if (ref.current.open) {
        setOnClickAway({ f: closeCallback });
      } else {
        setOnClickAway({ f: noOpCallback });
      }
    },
    [],
  );
  useClickAway(ref, onClickAway.f);
  return { ref, onToggle };
};

function Component() {
  const { ref, onToggle } = useCloseDetailsOnClickAway();
  return (
    <details ref={ref} onToggle={onToggle}>
      <summary>summary</summary>
      details
    </details>
  )
}

その結果できたカスタムフックがこれだ。detailsのtoggleイベントを監視して、開いたものだけuseClickAwayのコールバック関数をcloseCallbackに差し替えている。

ちなみに数字上の速度差はなかったです。たぶんReactやV8がなんとかしてるんでしょ。

 

20250105 作らされたいもの/RimWorld終盤の資産管理と人生訓

作らされたいもの

作りたいものねぇ〜とか言っていたら昔作ったwebアプリケーションをまだ使ってくれている友人から機能の要望が来て、若干の修正と膨大なリファクタリングが発生していて楽しい。しかし連休の終盤に楽しくて夜ふかししているのは頭が悪い。

昔作ったものだから結局コンフォートゾーンに逆戻りしているということではあるんだけど…まあ何もしないよりはマシかな。TypeScript+prismaでデータベースを複雑にいじくるところで(mysqlだとcreateManyAndReturnが使えません)、fp-tsで型をガチガチに固めているので絶対間違ったプログラムを書けないのだが、だからと言って正しいプログラムが書けるというわけでもなく苦労している。fp-tsの型エラーは原因箇所と遠く離れた場所に出るので何を間違ったのか探すのにコツがいる。好きな思想のライブラリではあるんだけどやっぱり言語機能の支援なしにこういうことをやろうとするとどこかに無理が生じるのよね…。

本日

今日からジムが開いていたので行って全身シバいてきた。少し衰えていたが想定内だ。NHKラジオニュースではUSスチールの買収差し止めが大きく扱われていた。大変だ。昼は久々に餃子の王将に行ってみたが、日高屋よりは美味しいだろうと期待しすぎた結果思ったほどじゃないな…となった。かぼちゃを買って帰宅。

RimWorld終盤の資産管理と人生訓

RimWorldは佳境である。いよいよ宇宙船の研究が一通り完了し、後は地道にウランやらプラスチールやら先進コンポーネントやらをかき集める段階に入った。ここの進め方がなかなか難しい。コロニー経営は軌道に乗っていて金も食料も有り余っているのだが、希少資源はそうそう見つからないので金余りの状態になりやすく、そのせいでゲームシステムによって敵の襲撃が強力になってしまう。かと言って金を捨てるといざ希少資源が見つかったときに買えない。つまりゲームシステムにバレない場所に資金を移動させる必要があるのだ。

一つの方法としてはキャラバンに乗せて適当な人員にコロニー外を徘徊させるというのがある。物理的な資産隠しだ。しかしこれはあまりにもゲームを騙してる感が強くポリシーに反するのでダメ。他の方法としては近隣の他勢力に配っちゃうというのがある。もちろんその資金は消滅するのだが、その分友好度が上昇し、いざというときに助けに来てくれたりキャラバンを派遣してくれたりする。

人を働かせて金を集めるシステムを構築したら次はその金を隠したり配ったりというのは嫌なリアリティがあるが、溜め込むくらいなら人に配ってしまえというのはなかなかポジティブな人生訓ではないだろうか。資産を持つことはそれ自体がリスクである。価値は損耗・変動するし、奪われる可能性もある(現代日本の銀行だって1000万円までしか保証してくれないぞ)。それに対して、ロマンティックに言えば友情、システマティックに言えば互恵性の規範に投資するという道もあるぞということをこのゲームは提示しているのだ(厳密に考えると金銭投げつけは友情なのか?という疑問は新たに浮かんでくるが…)。

ちなみに住人の一人であるElk(39歳独身男性・社交能力1)は性欲が強すぎて人妻を含む周囲の女性に声をかけまくり、当然のごとく振られ続けた結果メンタルを悪化させ暴れだしたので逮捕・収監と相成りました。そのうち復帰はさせようと思うけどもう女性と顔を合わせないように深夜シフト固定かなあ。これはネガティブな人生訓。