Open sousuke0422 opened 1 year ago
幻?のVuex 5があって、それとほぼ同じなのがPiniaなんですね。 移行は実践してくださる方がいれば前向きに検討したいと考えています。
ざっと眺めた感じ大変になりそうだったのは次の2点です。
Fluxではなくなるらしく、mutationが消えると同時にdispatchも消える(ただの関数になる)のですが、まあこの影響はあまりない気がします。むしろ楽になりそう。
ちなみに、複数のstoreがお互いのstateを見るのってどうやって実装すればいいかってご存知だったりしますか・・・? @sousuke0422 ざっと見た感じ、ここにできると書かれてるけどわからなかったので・・・。
どちらかというと前向きに検討したいと思ってる理由をメリデメとして列挙してみます。
TypeScriptがちゃんとサポートされてること、順番を気にせず書けることが嬉しいポイントです。 ただまあVOICEVOXは自作ラッパーでそのあたり解決してるので、移行はマストではないなという気持ちです。 これからの新規参入にとってはPiniaのが直感的そう。
もしよかったら詳しい方からコメントお願いできるととても参考になるので、ぜひ・・・!
@Segu-g さん Command周りどうなりそうかちょっと聞いてみたいです。mutationが消えるので・・・。
@yamachu さん ProxyStore周りがちょっと気になってるのですが、問題になりそうでしょうか。
@MT224244 さん 型周りやstore分割など、全体的な所感を聞いてみたいです。
おーーー!!Cookbookの方にあったんですね、見落としてました。 store内で他storeのstateを使う場合はfunction内のみという制約、なるほどぉ。Ref使うとかの手もありそうだし、この辺のデファクトは変わってきそう感。
piniaでは同期的にデータの整合性を保つためのmutationに相当する方法として$patchがあるようです。 もし、現行のコマンドシステムをそのままpiniaに移行するならば、この$patchに渡す関数をimmerに通して履歴に入れるcreateCommandMutation相当のラッパーを作れば良さそうです
懸念としては、
逆にストアを分割すればMUTEXを細かく設定して、コマンドを依存ごとに並列に実行するなどもできるかもしれません。
seguさんありがとうございます!!
$patch
をラップする感じ、なるほどです!!
$patchはstore単位でしか更新できないので、storeの分割はそれぞれが整合性として独立する要素が分割上限になる
こちらの懸念については、微妙な策かもですがundo/redoしたい全stateだけを持つstoreを作るとかで解決できるかもと思いました!
piniaについてちょっと調べてみたのですが、メリットに上げているmutation/action/getterの順番を気にせず書ける
は、公式であまり(というかかなり)推されてない感がありました。
もし導入を検討するにしても、時間をおいてかなり様子を見たほうが良いのかなという気持ちでいます。
前のコメントで
patchを適用と同時にcommandsを変更するためにはcommandsと変更するストアが同一のストアである必要がある
と書きましたがpiniaは同期的にstateを更新できるようなので、非同期的なコードを挟まない限り整合性は保証されそうでした。
piniaへの移行、現実的なのかどうか若干見積もれないですね…。 コマンドはstate統一か何かしらの制約でなんとかなるとして、規模が規模なので…。
ちょっとずつ移行する方法も思いつかない。うーん。
undo/redoも含め、piniaを用いたときの設計を考えてました!!
vuexからの移行を考えると、fluxじゃなくなる点も考慮ポイントっぽかったです。 といってもviewからstateを直接変えられないようにすれば十分なので、stateにreadonlyを付けてreturnすれば良さそう?
コマンドに関してはstoreを跨ぐときのことを考える必要がありそうです。
全ストアを1箇所に集めた最強ストアを作るのと、小分けのストアに対して頑張って$patch
を適用するの、どちらで行くかって感じかなと!
↓なんとなくこんな感じかなというコードを書いてみました。(文法正しくないので動かないです)
const commandStore = (otherStores: Store[]) => {
const redoCommands = Command[];
const undoCommands = Command[];
const createCommand = (stores: Store[], mutation: Mutation) => {
// どういう実装?
// immerのcreateDraftを作る場合(できる?)
return () => {
const drafts = stores.map((store) => immer.createDraft(store.state));
mutation(...drafts);
const redoPatches1, undoPatchs1 = immer.finishDraft(drafts[0]);
const redoPatches2, undoPatchs2 = immer.finishDraft(drafts[1]);
redoCommands.push(Command(redoPatches1 + redoPatches2)); // まとめれる?
undoCommands.push(Command(undoPatchs1 + undoPatchs2));
store1.$patch(() => {apply(redoPatches1)})
store2.$patch(() => {apply(redoPatches2)})
};
// パッチを分解して各Storeに渡す場合
return () => {
const redoPatches, undoPatchs = immer.produceWithPatches(mutation);
redoCommands.push(Command(redoPatches));
undoCommands.push(Command(undoPatchs));
const redoPatches1 = filter(redoPatches)
store1.$patch(() => {apply(redoPatches1)})
const redoPatches2 = filter(redoPatches)
store2.$patch(() => {apply(redoPatches2)})
};
}
return {
createCommand,
}
}
const store1 = (storeName: string) => {
const commandStore = useCommandStore();
const getters = {}; // ちゃんと型つく?
const actions = {}; // ちゃんと型つく?
const state = reactive({
count: 0,
})
const mutationA = (state) => {state.count++};
actions.commandActionS = commandStore.createCommand(this, (draft) => { draft.count++ });
actions.commandActionT = commandStore.createCommand(this, (draft) => { mutationA(draft) });
// コードジャンプはちゃんとここになる?
actions.actionX = () => {this.$patch(() => state.count++ );};
actions.actionY = () => {this.$patch((state) => mutationA(state));};
getters.getterX = computed(() => state.count * 10); // WritableComputedは禁止
return {
...readonly(state), // ちゃんとreactiveに解体できる?
...getters,
...actions,
}
}
const store2 = (storeName2: string) => {
const commandStore = useCommandStore();
const store1 = useStore1();
const state = reactive({
count2: 0,
})
actions.commandActionU = createCommand(
[this, store1],
(draft, draft1) => {draft.count2++; draft1.count++;},
);
return {
...readonly(state),
...actions,
}
}
メモ
@Hiroshiba
上のコードだとcreateCommand
はstore自身を引数として与える必要がありますが、compositionAPIではstore自身を参照することができないようです
解決策としては
$patch
をsetup funcitonで定義するcommand
をaction
ではなく只のfunctionとして定義する。
などがあると思います。composition styleはmutation/action/getterの順番を気にせず書ける
といった目的があると思いますが、やはり面倒が多そうといったイメージになってしまいますね・・・
@Segu-g あ~~~ なるほどです!!
storeを別のstoreでラップする
を拡張して、commandの操作対象なstateだけcommandStoreに乗っけるという手もありそうだなと思いました。
これだとstateが複数storeに分断されることを考えなくてすみますし、どれがcommandの対象なのかがわかりやすいですし。
(初期化用途など、command操作なしでstateを変える手を用意しないとですが)
他の手としては、将来$patch
が実装されることを見越して$patchをsetup funcitonで定義する
も全然ありに思います。
書式合ってない気がしますが、こんな感じでしょうか。
function patchFactory<S>(s: S) {
return (func: (s: S) => void) => {
func(s)
}
}
const $patch = patchFactory(state)
次手としてstoreを別のstoreでラップする
もありに思います。stateだけ持つstoreを毎store作る感じですよね。
object形式で書く
はまたVuexの苦しい感じに戻ってしまうので避けたいですね・・・。
(commandをactionではなく只のfunctionとして定義する。
はちょっとイメージできなかったです)
$patchをsetup funcitonで定義する
は型的に面倒な気がしたので storeを別のstoreでラップする
の形式で小さめのサンプルを書いてみました。
https://github.com/Segu-g/pinia-composition-command-mre/blob/master/src/stores/textStore.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { defineCommand } from './command';
export const useText = defineStore('text', () => {
const text = ref('');
return {
text,
};
});
export const useTextCommand = defineStore('textCommand', () => {
const textStore = useText();
const changeText = defineCommand({ textStore }, ({ textStore }, text) => {
textStore.text += text;
});
return { changeText };
});
@Segu-g おー!!!!! コードも読みました、かなりスッキリしている印象です!!! いくつかこうできたら最高だなという設計上の希望が思いついたのでちょっと列挙しています 🙇
defineCommand
内でuseCommand
を実行しちゃってる点
defineCommand
の第1引数にcommandStore
を渡す設計でもいいかもuseCommand
内でuseStoreしているものがコマンドに対応していますが、それが型からは分からないかも?pushCommand
が露出してしまっている点
$pushCommand
などとして特殊なものだということがわかるようにするといいかもstate
をstoreの外から直接変更不可にできない点
1~3はなんとかなりそうなのですが、4だけは解決策を思いつかないでいます。。
全storeを1ファイルにまとめて、state
定義用のstore
にはexport
をつけないようにし、全state
をuseしたあとreadonlyを付けてreturnすれば行けるかもですが・・・。
@Hiroshiba
defineCommand内でuseCommandを実行しちゃってる
defineCommand
自体がuse系関数の感覚で書いてましたが確かに命名的にも非自明でしたね。毎回
const commandStore = useCommand();
const commandA = defineCommand(commandStore, ()=>{})
のようにするのも冗長かなと感じたので、
const { defineCommand } = useCommandContext();
と書けるくらいのヘルパー関数は書いてもいいと思うのですがどうでしょうか?
Storeがコマンドに対応しているかどうかわからない点
undo, redoの時はcommandから各ストアの$patch
を呼ぶ必要があるため逆参照用の登録が必要になっちゃうんですよね・・・とりあえず以下のように登録する配列を定義して、その中のストアしか渡せないように型パズルしました。
const getUseStoreArr = () => [useCounter, useText];
stateをstoreの外から直接変更不可にできない点
プロパティを(型的に)Readonlyにすること自体はreturn { ...toRefs(readonly(state)) };
だったり以下みたいな型書き換えを使えばできます。
// storeHelper.ts
import { StoreDefinition } from 'pinia';
import type { DeepReadonly } from 'ts-essentials';
type ToReadonlyStoreDefinition<SD> = SD extends StoreDefinition<
infer Id,
infer S,
infer G,
infer A
>
? StoreDefinition<Id, DeepReadonly<S>, G, A>
: SD;
export function toReadonlyStoreDefinition<SD>(useStore: SD) {
return useStore as ToReadonlyStoreDefinition<SD>;
}
// index.ts
import { useCounter as _useCounter } from './countStore';
import { useText as _useText } from './textStore';
import { toReadonlyStoreDefinition } from './storeHelper';
export const useCounter = toReadonlyStoreDefinition(_useCounter);
export const useText = toReadonlyStoreDefinition(_useText);
今気づいたのですが今のコードはmutationの型がDraft<State> => void
なのでDraft
の効力でreadonly
が無効化されてますね... readonlyなプロパティをdefineCommand
で変更してしまう可能性がありそうです。
PS
Draftを外したところちゃんとReadonlyなStoreはmutation内でも操作できないことを確認しました。 なのでReadonlyを付けるならCommandStore(wrapしてるStore)でstateを再exportするか、index.tsなど別の場所で型を変えるなどの方法があると思います。
useCommandContext(); と書けるくらいのヘルパー関数は書いてもいいと思うのですがどうでしょうか?
なるほどコンポーザブルなんですね! 良さそうなのかなと思いました!!
stateをstoreの外から直接変更不可にできない点
プロパティを(型的に)Readonlyにすること自体はreturn { ...toRefs(readonly(state)) };だったり以下みたいな型書き換えを使えばできます。
stateの方のstoreだけ型を置き換えたりラップしたりするのなるほどです!! 利用側は2つインポートする必要がありますが、良さそうに思いました!
(ちょっと別の方法としては、今stateを持っている方のStoreを_useTextStateStore
とかにして、コマンドがある側をuseText
などとし、useText
側でstateをreadonlyにしてreturnするのもありかなとかちょっと思いました。
ただこの方法だとstate1つ1つををreturnに書いていく必要があって定義側がめんどくさいですね!!)
なんかpiniaに置き換えること自体は通れる道な気がしました!! その付随効果として何があるかをちょっと考えてみました。
$patch
で書いたところでaction相当になるstore/type.ts
にインターフェースと実装が1つずつある形でした$patch
ぐらい?1と2に問題があるかどうかをいろんな人に聞いてみるフェーズかなと思いました!!
ちゃんと検証は済んでないですが1の問題は多分大丈夫だと思います。
javascriptだとその言語的な特性から(piniaがWorkerとか建ててなければですが)実行されるスレッドは常に1つで、 それらはawaitなどのコールーチンの境界にならないと手放されない筈です。 なのでdefineCommand内のコードは同期的に実行され、他のコマンドと変更順序が変化する余地はおそらく無いです!
そもそもvuexでcreateCommandAcitonが上手くいかない原因がcommitしたpatchが適応されるタイミングが非同期であることだったので、$patchで同期的に状態が書き換えられるpiniaでは問題にならないでしょう。
なるほどです!!! ということはまあ以前と一緒の使い勝手になる感じですかね・・・!! (action内のcommit/state変更のタイミングによって前後する可能性があるというだけ)
(ちょっと別の方法としては、今stateを持っている方のStoreを_useTextStateStoreとかにして、コマンドがある側をuseTextなどとし、useText側でstateをreadonlyにしてreturnするのもありかなとかちょっと思いました。 ただこの方法だとstate1つ1つををreturnに書いていく必要があって定義側がめんどくさいですね!!)
そもそもスプレッドするために付けなくちゃいけない storeToRefs
を付けたら$id
とかの後天的なプロパティも消えてくれたのでこの方式で書きたいと思います。
return { commandChangeText, ...storeToRefs(textStore) };
storeToRefs良いですね!!
ルールを整備したりしないと結構危うい気がしてきたのでちょっと考えてみました!
.act
でaction、.mut
でmutationが返すとかで、実装としての候補はこんな感じ・・・?
個人的にはとりあえず1でいいのかなとかちょっと思ってます。 ちょっと人を信用しすぎてるかもしれないですが。。 😇 (あとESLintで抑止できるんじゃないかな~~と思ってます)
上記のコメントを踏まえて少しサンプルを書いてみました。 https://github.com/Segu-g/voicevox/blob/feature/pinia-mutation-patch-style/src/pinia-stores/preset.ts
方針としてはstateを持つstoreは$patch利用のためにusePresetStateStore
のように分割し、presetStoreでuseStoreAsState
ヘルパー関数を介して用います。
useStoreAsState
はstateを定義したstoreからMutationやreadonlyなstoreを取り出すためのヘルパーで、先ほどの方針でいけば (2. $patchのみでstate変更できるようにする) の方を実装したものになります。
Mutationの定義はdefMut
で. Actionの定義はdefAct
を通して定義することが可能であり以下のような形になります。
export const usePresetStore = defineStore("preset", () => {
const { state, defMut, defAct } = useStoreAsState(usePresetState);
// getter
const defaultPresetKeySets = computed(() => {
return new Set(Object.values(state.defaultPresetKeys));
});
// action
const setDefaultPresetMap = defAct(
async ({
defaultPresetKeys,
}: {
defaultPresetKeys: Record<VoiceId, PresetKey>;
}) => {
window.electron.setSetting("defaultPresetKeys", defaultPresetKeys);
setDefaultPresetMapMut.act({ defaultPresetKeys });
}
);
// mutation
const setDefaultPresetMapMut = defMut(
(
state,
{ defaultPresetKeys }: { defaultPresetKeys: Record<VoiceId, PresetKey> }
) => {
state.defaultPresetKeys = defaultPresetKeys;
}
);
return { ...storeToRefs(state) , defaultPresetKeySets, setDefaultPresetMap };
}):
また別の話になりますがMutation
の定義としてstore
は引数から与えられたもののみを対象として操作を記録する必要がありますが、Mutation
から他のstore
をgetする時にも引数からstore
を参照する必要があって面倒なことに気づきました。
これはMutaiton
の実行中に参照しているstate
が書き換えられない場合は問題ないのですが, command
の場合はstate
の変更の適応タイミングが遅いので、過去の値を参照してしまう可能性が出てきてしまいます。
Command
の引数に取るMutation
は複数のState
を取れる型定義になっているので問題ないのですが、外のstore
を引数を介さずに参照してしまう危険性は高そうだなと思いました。
const changeText = defineCommand({ textStore, hogeStore }, ({ textStore, hogeStore }, text) => {
textStore.text += text;
hogeStore.count += 1;
});
ちょっといろいろ考えたのですが全然まとまってないけど一旦ちょっとコメントだけ・・・!
今のVuexラッパーみたいにして書いて、pinia用のobjectにする
functionName: {
mutation: (state) => {},
action: ( {state, actions, mutations} ) => {}
}
mutをstateStore側に書く。 (他のstateが見れないという課題がある)
module直下にmutationを書き、他stateも渡すようにする (@Segu-g さんの案)
prefixかsuffixにact
やmut
を必須に。
別のアイデアが思い浮かんだのでメモ。
piniaのdefine関数内でmutationやactionを定義するとき、専用の型を使うようにする。
ESLintでmutationが正しそうなことを確認する。
(例:state
という言葉を使用禁止、外部変数のキャプチャ禁止など)
ESLintで使用禁止や外部変数の利用禁止するのはChatGPTくんに聞いた感じできるかも・・・? https://chat.openai.com/share/cce984ef-c391-47a5-ab4e-e79d17b60ca7
@Segu-g
pinia化ですが、記憶がまだ残っているうちにundo/redo部分まで進めたい気持ちがあります・・・!
アイデア5
の方針で行きつつ、ESLintでmutationに制限をつけるのは後回しにするという流れで行くと一旦試作ができるかもと思ったのですが、どうでしょう・・・? 👀
@Segu-g pinia化、まだ記憶が残っているうちに進めておかないと多分もったいないので、ちょっと危機感を感じ始めました・・・! せっかくだから引き継いでみようかなと思ったのですが、コマンド付きmutationがやっぱり鬼門な感じでしょうか? であればより制約を強めて、storeがそれぞれ分かれているタイプを諦めて全ストアを統一し、あとは各単元ごとにmutationなりactionなりを実装する形とか目指すとかもありかも?
ESLintの設定を頑張ろうとしてみてたのですが、動かす方法がよくわかりませんでした・・・。
とりあえず、ローカルのESLintルールを作るのはeslint-plugin-local-rules
を使うのが良さそう、というところまで進みました 😇
https://github.com/Hiroshiba/voicevox/commit/f9a2dde47e5c0676be157c3e9a35f0b486bcd9e1
@Hiroshiba 何度か書いていたのですが、今まで出た全ての要望を全て満たすようなラッパーを書こうとすると、設計力不足で過剰に複雑なインターフェースになってしまいますね...
とりあえず書いてみたものがこちらになります
stateを定義するときはdefineCommandableState
を用いて, その出力を元にuseContext
を叩くことでdefMut
やdefGet
等の補助関数を定義できる形です。
import { defineStore, storeToRefs } from 'pinia';
import { defineCommandableState } from './command';
import { useStore } from '@/vuex-store';
export const CountState = defineCommandableState({
id: 'count/state',
state: () => ({
counter: 0,
}),
});
export const useCount = defineStore('count', () => {
const { state, defMut, asCmd } = CountState.useContext();
const increment = defMut((state) => {
state.counter += 1;
});
const countUpWithVuex = () => {
const vuexStore = useStore();
vuexStore.commit('increment');
};
return {
state: storeToRefs(state),
commandIncrement: asCmd(increment),
countUpWithVuex,
};
});
このブランチではdefGet
やdefMut
はあくまで型を補完するための補助関数でしかなく, defMut
の戻り値は引数の関数(state, ...payload) => void
がそのまま返されるようにしました。
これらの関数をGetterやActionとして扱うには同じくuseContext()
から与えられるgetRef(getter)
やasAct(mutation)
を経由する必要があります。
提示したサンプル例ではdefMut
で定義したincrement
をasCmd
でCommand化してexportしています.
インターフェースを改良しました.
asAct
やgetRef
の代わりにgetter.get
やmutation.commit()
で直接RefやActionとして呼べるようにしました
https://github.com/Segu-g/pinia-composition-command-mre/tree/feature/defGetdefMut/ver1
export const TextState = defineCommandableState({
id: 'text/state',
state: () => ({
text: '',
name: '',
}),
});
export const useText = defineStore('text', () => {
const { state, defGet, defMut, asCmd } = TextState.useContext();
// mutation
const changeTextMut = defMut((state, text: string) => {
state.text = text;
});
const changeNameMut = defMut((state, text: string) => {
state.name = text;
});
// action
const changeTextAndName = (text: string) => {
changeNameMut.commit(text);
changeNameMut.commit(text);
};
// command
const commandChangeText = asCmd(changeTextMut);
// getter
const textGet = defGet((state) => state.text);
const isTextSameToName = defGet((state) => state.name == textGet(state));
return {
state: storeToRefs(state),
changeTextMut,
changeNameMut,
changeTextAndName,
commandChangeText,
textGet,
isTextSameToName,
};
});
ありがとうございます!!!!!!すごく勉強になりました!!!
defCmdとasCmdがありますが、defCmdは結構リッチなのとasCmdでの書き換えが割と想像しやすいので、シンプルにasCmdの方だけでもいいかもと思いました!
あとdefGetは他のstoreのstateやgetterを使えることがわかるように、stateを与えなくても良いようにできるかも?
getCharacterInfo
でgetterからgetterを呼ぶ需要はあるので、そこは残すと嬉しそう!
mutationの中でaction(呼んではいけない関数)を実行しているのかどうかが分かりにくいかもと思いました。 以下ちょっと考えてみ案です。
案
pinia化の流れですが、audio.tsの置き換えはコマンドを一気に置き換えないといけない、つまり3000行にわたる全てのコードの置き換えを一気にやる必要がある気がしています。
これを実現するのはかなり大変で、多分1回audio.ts
を通行止めにして1週間ほど停止させ、その期間に一気に置き換えることを目標にするのが良い気がしました!
この点で一番困りそうなのが、API構成周りが現状のコードと合いそうかどうかという点です。 別リポジトリに書いている時には気づかなかった問題があるかもと感じています。
そこで提案なのですが、1回実際に一部のコマンドを実装してみて、それをプルリクエストにしていただいてレビューし、そこが固まってからaudio.ts
全体を変更していく方針はどうでしょうか?
多段階になってしまうのでお手間いただく感じになってしまうのですが、一気に置き換えるよりは心理的にもやりやすいのかなと思った次第です。(お互いに・・・!!)
コードを読んでいましたが、コマンドの中だとAudioItemを足すCOMMAND_REGISTER_AUDIO_ITEM
と、消すCOMMAND_MULTI_REMOVE_AUDIO_ITEM
辺りが簡単なように見えました。
https://github.com/VOICEVOX/voicevox/blob/37d52de4647b7e19bbac7d42cde06deb295c5050/src/store/audio.ts#L1850
この2つだけとりあえず実装してみてプルリクを出していただく・・・という進め方はいかがでしょうか・・・!! @Segu-g
実装の元となったリポジトリは以下になります. https://github.com/Segu-g/pinia-composition-command-mre/blob/feature/defGetdefMut/ver2/src/stores/textStore.ts
defCmd
はaction内で呼んでいるcommit
をmutation
とcommand
で切り替える需要が大きいため残しています。
前との差分としては
defGet
, defMut
, defAct
, defCmd
などの型を変更
.func
で元の関数が得られる.get()
, mutation
は.commit()
, actionは.dispatch()
でstateを注入した関数を呼べるreadonly
は安全ではないのでBrandを用いてreadonly
なstateをwritableなstateに代入できないようにです
おおお、ありがとうございます!!!!!
typescriptのreadonlyは安全ではないのでBrandを用いてreadonlyなstateをwritableなstateに代入できないように
素晴らしいと思います!!!!
ESLintで独自の型チェックを細かくする方法が分からなかったのですが、こちらに頂いたプルリクエストがとても参考になると思うのでメモです!!
内容
vuexをやめてpiniaに移行します。 vuexはメンテナンスモードへなったそうです。
Pros 良くなる点
typescriptとの相性が良くなり開発体験が良くなる。
Cons 悪くなる点
そこそこ大きな改修が必要。
実現方法
VOICEVOXのバージョン
0.?.0
OSの種類/ディストリ/バージョン
その他