1. はじめに
Hyperappでアプリを開発中、課題解決の為に、
loteooさんにzacenoさんが書かれた素晴らしいページを教えてもらいました。
いつでも読み返せるように翻訳します。
本記事では原文サイトの中の、
Modular Apps [訳: モジュールアプリケーション] について翻訳したものを記述します。
完璧な翻訳ではないので、齟齬が無いよう原文とパラレルで記述します。
以下原文ページのリンク
- hypercraft - index
- cross-namespace-action-calling
- initialization
- memoization
- modular-apps
- static-vnodes
- utility-actions
- decorator-components
- stateful-components
- keying-components
- about
2. 翻訳
Modular Apps
モジュールアプリケーション
The key to building maintainable, large-scale apps is to break them down into small self-contained & reusable modules. But how?
メンテが容易に可能な、大きい規模のアプリケーションを構築するための鍵は、小さな自己完結型かつ再利用可能なモジュールに分解することです。でもどうやって?
As your app grows larger with more features & behaviors, keeping it all inside a single file eventually becomes untenable. The solution is to modularize -- i.e. to break your app into modules. A module is a file which exports some "part" of your app. The main file imports these parts and assembles them into the full app definition. Modules may in turn be assembled from sub-modules if necessary, and so on.
あなたのアプリケーションがより多くの機能とその振る舞いによって大きくなるにつれ、最終的にすべてを1ファイルの中に保持することはできなくなります。解決策は、モジュール化です。つまり、アプリをモジュールに分割することです。モジュールは、アプリの「パーツ(一部分)」を書き出すファイルです。メインファイルはこれらのパーツをインポートし、アプリケーションの完全体を組み立てます。モジュールは、必要に応じてサブモジュールから順序よく組み立てることができたりします。
In order to assemble the modules into an app definition in some general way, the modules must adhere to some conventions / patterns. For the modularization to work -- to actually be beneficial -- some strategy for what to break apart and what to keep together is necessary.
一般的な方法でモジュールからアプリケーションを組み立てるには、モジュールはいくつかの規約やパターン守らねばいけません。モジュール化が機能し、実際に効果を出すためには、何を分解し、そして何をまとめるか、戦略が必要です。
This article outlines one such method. It's a method that's worked well for me, but there are certainly other possibilities. Regardless of your preference, the techniques might be of interest for devising your own system.
この記事ではやり方の概要を説明します。私の場合は上手くいったので、きっと応用できるはずです。あなたの好みかは別として、
システムにひと工夫を加えるには、興味深いテクニックだと思います。
Let's start by examining the problem we're trying to solve:
さあ、問題の調査から始めましょう:
The Problem
Hunt-n-peck
First, consider the mental effort of maintaining an already large single-file-app.
問題
ハント・アンド・ペック
まず、しんどい事ですが、すでにデカくなってしまった単一ファイルのアプリのメンテについて考えてみましょうか。
Your app's behavior is made up of various featurs/behaviors in some combination. When you go to change a particular behavior, it is spread out in various parts of the state, actions and view respectively. You'll need to move around a lot in the file, looking for where to make changes while mentally filtering out the code that isn't relevant.
あなたのアプリの挙動は、さまざまな 機能/振る舞い が組み合わさった物から出来ています。あなたが特定の挙動を変更しようとすると、それはstate,action,viewそれぞれの様々な部分に広がります。
他に影響を与えないように気を遣いながら
修正箇所をあちこちを探しまわることになります。
So. much. foo...
As second problem is the problem of an overcrowded namespace. There may be several features that require a state-property called selected, and an action called select.
fooばっか ...
第2の問題は、沢山詰め込まれた名前空間の問題です。 selectedと呼ばれるstateプロパティとselectというアクションを必要とするいくつかの機能があるかと思います。
Once your features start multiplying, you need to start adding disambiguating prefixes/suffixes to your states & actions such as selectedFoo and selectFoo: x => ({selectedFoo: x}). And a dropdown container for selecting a foo might look like this:
ある機能が乗算をし始めます、その場合、selectedFooや**selectFoo:x =>({selectedFoo:x})**のように、stateとactionで、見分けるためにプレフィックス(接頭辞)、サフィックス(接尾辞)を追加します。fooを選択するためのドロップダウンコンテナは次のようになります。
<select onchange={ev => actions.selectFoo(ev.currentTarget.value)}>
{state.fooOptions.map(opt => (
<option selected={opt===state.selectedFoo}>{opt}</option>
))}
</select>
So much foo! The problem of all state and action sharing a namespace leads to verbose code with lots of repetitive typing, and further mental strain when scanning for the place to make your changes.
fooばっかやんけ!すべてのstateとactionが名前空間を共有する際の問題点は、それが同じタイピングを繰り返す冗長的コードの温床となり、変更を箇所を見つけ出す際にしんどくなるということです。
All together now...
A third problem is distributing the development work across a team. If everyone needs to be working in the same files the whole time, they are bound to get in eachother's way.
全部一緒...
第3の問題は、開発作業がチームをまたいで割り当てられることです。終始皆が同じファイルで作業する必要があるなら、それぞれの異なるやり方を用いる事になります。
General strategies
Slice it vertically
By breaking your app apart in modules that each encapsulate a single feature (or behavior), you ensure that when you go to fix that feature, everything you need is in one place -- and nothing you don't. No more scouting around thousands of lines of code for the one spot to make your fix.
一般的な戦略
垂直にスライスする
1つの機能(または動作)をカプセル化したモジュールにして、アプリを分割することで、機能を修正する際に必要な物はすべて1か所に収められていて、そこに余計な物はありません。もう、たった一箇所の修正箇所をコードをあらゆる場所から探すのはやめにしましょう。
Encapsulating a feature means a module needs to hold the parts of the state, actions and view relevant to it.
機能をカプセル化するということは、モジュールが関連するstate,action,viewを保持する必要があることを意味します。
Loose Coupling
By slicing "vertically" (i.e. by features/behaviors) the modules tend to become loosely coupled -- meaning they don't need to interact very much with eachother. This should always be the goal.
疎結合
垂直に(機能/挙動単位で)スライスすることによってモジュールらが疎結合になりやすく、相互作用がかなりなくなるので、これは常に目標とすべきです。
Those interactions that are necessary, are typically handled by having a parent module coordinating the interactions among child modules. Generally speaking: the looser the coupling, the flatter the module tree -- which makes debugging/developing the interactions between modules easier.
必要な相互作用とは、通常、親モジュールが子モジュール間の相互作用をうまくまとめる事です。
一般論:結合が緩くなればなるほど、モジュールツリーがフラットになり、モジュール間のやり取りに関するデバッグや開発が容易になります。
Namespacing
Each module should be free to name it's state and actions and other things whatever it wants (and however short!), without fear of the name colliding with something outside itself.
名前空間分け
各モジュールのstate,actions,その他それらが必要とする物に命名の制限はなく(でも短かく!)、
別の何かと名前が競合する恐れは気にせず外に出して使用します。
State, actions and other stuff (...I'll get to that) in child modules should be accessible via namespaces defined in the parent module.
子モジュールの中のstate、actions、その他(...使う予定のもの)は、親モジュールで定義された名前空間を介してアクセス可能でなければなりません。
Enough theory! How do we achieve this?
理論はもう十分!これをどうやって実現するのですか?
Slices to the rescue!
Hyperapp has a little-advertised feature, often referred to as "slices" (TBH I've never really liked that name), which works like this:
スライスで解決!
Hyperappには、しばしば「スライス」と言われる、ちょっとした有名な機能があります(実はこの名前、本当に好きになれずでした)。これは次のように動作します。
- Actions can be defined under a multi-leveled namespace.
-
Actions in a namespace:
-
are only provided the state from the corresponding namespace in the state tree,
-
and only the actions under the same namespace in the action tree.
-
their returned partial state updates the state tree under the corresponding namespace
-
アクションは、複数レベルの名前空間を定義できます。
-
名前空間のアクション:
-
stateツリー内の対応する名前空間からのstateのみが提供され、
-
そして、actionツリー内の同じ名前空間の下にあるアクションのみで
-
返されたパーシャルstateは、対応する名前空間の下のstateツリーを更新します
const state = {
foo: {
value: 3
},
baz: 4
}
const actions = {
foo: {
increment: by => (state, actions) => {
/*
Here, state will be the object
at state.foo,
i.e: {value: 3}
Also, actions will be the actions
at actions.foo, i.e:
{
increment: ..,,
decrement: ...,
}
Returning {value: something} from
this action updates the state immutably
so that state.foo.value === something
*/
return { value: state.value + by }
},
decrement: by => (state, actions) => {
return { value: state.value - by }
}
},
setBaz: x => ({baz: x})
}
In other words: this feature is exactly what we need for the module-namespacing issue. We could define a counter module in a file counter.js such as:
言い換えると: これこそがモジュール-名前空間化の課題に対して必要な機能です。counter.jsに次のようなカウンターモジュールを定義することができます:
export default initial => ({
state: {
value: initial
},
actions: {
increment: by => state => ({value: state.value + by})
decrement: by => state => ({value: state.value - by})
}
})
... and then instantiate and mount the module in a parent module or the main app, like so:
...そして、次のように、インスタンス化し、モジュールまたはメインアプリケーション内にマウントします。
import counter from './counter'
const foo = counter(3)
const state = {
foo: foo.state,
baz: 4,
}
const actions = {
foo: foo.actions,
setBaz: x => ({baz: x})
}
...
Modularizing the view.
ビューのモジュール化
"Slices" allows us to create modules that encapsulate related state and actions. But what about the related parts of the view?
「スライス」では、関連するstateやactionsをカプセル化したモジュールを作成できます。
しかし、ビューの関連パーツはどうでしょう?
Imagine you have this baroptions.js module:
あなたがこのbaroptions.jsモジュールを持っているとイメージしてください:
export default _ => ({
state: {
selected: null,
options: [],
},
actions: {
addOption: opt => ({options}) => ({options: options.concat(opt)})
select: opt => ({selected: opt})
}
})
and in the view you have some parts that are related to your baroptions module:
ビューでは、あなたのバーオプションのモジュールに関連するいくつかの部品があります:
const view = (state, actions) => <main>
...
<h1>Bar: {state.bar.selected}</h1>
...
<select onchange={ev => actions.bar.select(ev.currentTarget.value)}>
{state.bar.options.map(opt => (
<option selected={actions.bar.selected===opt}>{opt}</option>
))}
</select>
...
<input
type="text"
onchange={ev => actions.bar.addOption(ev.currentTarget.value)}
/>
</main>
Reusable components
Now, the first thing you're recommended to do, is to reduce repetitions by creating reusable components. A dropdown is something we might want to use elsewhere, so making a generic dropdown component makes sense:
再利用可能なコンポーネント
さて、最初にやったほうがいい事は、再利用可能なコンポーネントを作成して繰り返しを減らすことです。ドロップダウンは他でも使用したいものかもしれないので、共通的なドロップダウンのコンポーネントを作ることが理にかなった方法です:
export default props => <select onchange={ev => props.onselect(ev.currentTarget.value)}>
{options.map(opt => (
<option selected={props.selected === opt}>{opt}</option>
))}
</select>
Now the view can be written
すぐビューが書けます
import DropDown from './dropdown'
...
const view = (state, actions) => <main>
...
<h1>Bar: {state.bar.selected}</h1>
...
<DropDown
options={state.bar.options}
onselec={actions.bar.select}
selected={state.bar.selected}
/>
...
<input
type="text"
onchange={ev => actions.bar.addOption(ev.currentTarget.value)}
/>
</main>
Module Views
But authoring reusable components is only part of the story. It helps reduce repetition in your view, but it does not fix the problem you see above, which is that the view needs to know a whole lot about the inner structure of the baroptions module.
モジュールビュー
しかし、再利用可能なコンポーネントをオーサリングすることは、ストーリーの一部にすぎません。これは、ビューの中で繰り返しを減らすのに役立ちますが、上記の問題を解決するわけではなく、ビューがバーオプションモのジュールの内部構造について隅から隅まで知る必要があるという事です。
That exposes us to the risk of bugs originating far outside the module (so: hard to track down).
それによって、モジュールとは全く無関係の場所でバグが発生するリスクにさらされます。(追うのが大変なバグです)
The knowledge of how state and actions are wired into a view, should reside in the same module that defines the state and actions. The module should expose a more simple and "foolproof" access to those view parts.
stateとactionsがどのようにビューに組み込まれるかに関する情報は、そのstateとactionsを定義するモジュールに存在する必要があります。モジュールは、ビューパーツへのよりシンプルで「明瞭で確実な」アクセスを提供する必要があります。
export default _ => ({
state: {
selected: null,
options: [],
},
actions: {
addOption: opt => ({options}) => ({options: options.concat(opt)})
select: opt => ({selected: opt})
},
view: (state, actions) => ({
Banner: props => <h1>{props.name}: {state.selected}</h1>,
Input: _ => <input
type="text"
onchange={ev => actions.addOption(ev.currentTarget.value)}
/>,
Selector: _ => <DropDown
selected={state.selected}
select={actions.select}
options={state.options}
/>
})
})
This allows the main view to be written in this way:
これによりメインビューは次のように記述されます。
import baroptions from './baroptions',
...
const view = (state, actions) => {
const views = {
baroptions: baroptions.view(state.baroptions, actions.baroptions)
}
return <main>
...
<views.baroptions.Banner name="Bar" />
...
<views.baroptions.Selector />
...
<views.baroptions.Input />
</main>
}
The Module Pattern
モジュールパターン
So now we finally have the pattern of defining a module:
これで、やっとモジュール定義のパターンができました。
export default initialValues => ({
state: {...},
actions: {...},
view: (state, actions) => {...},
})
Simple and sensible, right?!
シンプルで賢い、よね?
For singleton-modules which don't need to be used multiple times with initial values, you can forgo the function-call wrapper, and simply export the object. Note that the main, top-level module of your app will always be a singleton.
複数回使用する必要のない初期値を持ったシングルトンモジュールの場合、関数呼び出しラッパーを控えて、オブジェクトをエクスポートすることができます。アプリのメインのトップレベルモジュールは常にシングルトンになります。
A module which composes other modules inside it could look like this.
内部に他のモジュールを構成するモジュールは、このように見えます。
Composing modules
モジュールの構成
import foo from './foo'
import bar from './bar'
import baz from './baz'
const modules = {
foo: foo(),
bar: bar(),
baz: baz(),
}
export default initialValues => ({
state: {
foo: modules.foo.state,
bar: modules.bar.state,
baz: modules.baz.state,
someProp: 'A'
otherProp: 42
},
actions: {
foo: modules.foo.actions,
bar: modules.bar.actions,
baz: modules.baz.actions,
someAction: _ => {...},
otherAction: _ => {...}
},
view: (state, actions) => {
const views = {
foo: modules.foo.view(state.foo, actions.foo),
bar: modules.bar.view(state.bar, actions.bar),
baz: modules.baz.view(state.baz, actions.baz),
}
return /* some combination of:
- state
- actions
- views.foo
- views.bar
- views.baz
*/
}
})
It's simple, clean and explicit. It's a lot of repetetive typing though. Surely we can write a helper to fix this? ...Of course we can!
シンプルでクリーンで明確です。タイプの繰り返しが多いですが。確実にこれを解決するhelperを書くことができますか? ...もちろん、我々はできます!
A Module Helper
モジュールヘルパー
const combineModules = tree => {
const modules = {}
for (let name in (tree.modules || {})) {
modules[name] = combineModules(tree.modules[name])
}
const state = tree.state || {}
const actions = tree.actions || {}
for (let name in modules) {
state[name] = modules[name].state || {}
actions[name] = modules[name].actions || {}
}
const view = (state, actions) => {
const subviews = {}
for (let name in modules) {
if (!modules[name].view) continue
subviews[name] = modules[name].view(
state[name],
actions[name]
)
}
return tree.view && tree.view(state, actions, subviews)
}
return {state, actions, view}
}
This little helper essentially implements the pattern above, in a recursive fashion. It expects a module defined like:
この小さなヘルパーは本質的に上記のパターンを再帰的に実装します。それは次のように定義されたモジュールが必要です。
import foo from './foo'
import bar from './bar'
const {state, actions, view} = combineModules({
modules: {
foo: foo(),
bar: bar(),
},
state: {
rootState1: 'a',
rootState2: 99,
},
actions: {
rootAction1: ...
rootAction2: ...
},
view: (state, actions, views) => (
<main>
<section class="left">
{state.rootState1}
<button onclick={actions.rootAction1}>Boo!</button>
<views.foo.InputForm onsubmit={actions.rootAction2} />
</section>
<section class="toolbar>
<views.foo.ToolbarButton />
<views.bar.ToolbarButton />
</section>
<section class="main">
<views.bar.Main />
</section>
</main>
)
})
foo() and bar() should return module objects of the same shape. Those may in turn include modules of their own. combineModules produces the total state and action trees, as well as the main view, from the provided module-tree.
**foo()とbar()**は同じ形のモジュールオブジェクトを返すべきです。それらには、自分たちのモジュール自身も順番に含まれます。 combineModulesは、提供されたモジュールツリーからメインのビューと同様に、完全なstateとactionツリーを生成します。
The third argument passed to the view, is an object with the keys foo and bar, and the values are the results of calling those modules' respective view functions. The view functions in foo and bar, in turn, recieve the views of their child-modules in the third arguments.
ビューに渡される3番目の引数は、footとbarのキーを持つオブジェクトであり、そして値は、それらのモジュールのそれぞれのビュー関数を呼び出した結果です。 fooとbarのビュー関数は、順番に、3番目の引数の中の子モジュールのビューを受け取ります。
With the total state and actions trees, plus the main view thus produced, running your app is simply a matter of passing them to app(state, actions, view, document.body)
完全なstateとactionツリー、加えてこのようにして作成されたメインビューで、アプリを実行する事は、単純にそれらを**app(state、actions、view、document.body)**に渡す事です。
Tada! -- You're running a modular Hyperapp app
おめでとう! - あなたはモジュール化されたHyperappアプリケーションを実行しています
Testability
テスト容易性
Modules using this method make good candidates for unit testing! A unit-test might look something like:
この方法を使用するモジュールは単体テストの候補になります!ユニットテストは次のようになります。
test('when foo is set to false, the Bar component renders nothing', done => {
const {state, actions, view = combineModules(myModule({foo: true}))
app(state, actions, (state, actions) => {
if (state.foo) {
actions.turnFooOff()
return
} else {
const {Bar} = view(state, actions)
assert(!Bar())
done()
}
})
})
Note: when testing a module with dependencies, you may want to mock them,
注意:依存関係のあるモジュールをテストするときは、それらをモックしたいかもしれません、
Notice the missing 4th parameter to the app call. That's usually where you tell Hyperapp where in the DOM you'd like your app rendered. When you leave it off, Hyperapp disengages the entire virtual-dom rendering routine, but it will still call the view every update That is how you can test that your module's "view" responds correctly to actions.
触れられていなかったアプリ呼び出しの際の4番目のパラメータを確認します。これは通常、HyperappにあなたがアプリケーションをDOMのどこの部分にレンダリングしたいかを伝えます。
これをオフにすると、Hyperappは全体の仮想DOMのレンダリングルーチンを切り離しますが、それでも更新の度にビューを呼び出します。
これはモジュールの「ビュー」がアクションに正しく応答するかをテストする方法です。