145
104

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

デコレータを使わずコールバック関数に依存性を注入できるDIヘルパーをたった15行のTypeScriptで作った話

Last updated at Posted at 2020-07-29

一般的にTypeScriptでDIをしようとするとクラス+デコレータを避けて通れません
しかし個人的には関数型っぽくコーディングするのが好きなのでクラスもデコレータも使わず関数だけでDIする方法を考えました
たった15行なので解説の前にコードを紹介します

type Deps<T extends Record<string, any>> = {
  [P in keyof T]: T[P] extends { _velona: boolean }
    ? (...args: Parameters<T[P]>) => ReturnType<T[P]>
    : T[P]
}

export const depend = <T extends Record<string, any>, U extends any[], V>(
  dependencies: T,
  cb: (deps: Deps<T>, ...args: U) => V
) => {
  const fn = (...args: U) => cb(dependencies, ...args)
  fn._velona = true
  fn.inject = (deps: Deps<T>) => (...args: U) => cb(deps, ...args)
  return fn
}

当然フロントでもバックでも使えます
「Velona(ヴェローナ)」と名付けてnpmにも公開済みです
https://github.com/frouriojs/velona

スターを押してもらえると嬉しいです!

基本的な使い方

上記のコードをコピペしてファイルを作るかnpmでインストールします

$ npm install velona

例えば、足し算をする関数があるとします

add.ts
export const add = (a: number, b: number) => a + b

このadd関数に依存する関数を定義します

parentFn.ts
import { add } from './add'

export const parentFn = (a: number, b: number, c: number) => add(a, b) * c

console.log(parentFn(2, 3, 4)) // 20

parentFnはこのままではテスト時に依存性(add関数)を別の関数に差し替えることができません
そこでVelona(冒頭の15行)を使います

parentFn.ts
import { depend } from 'velona'
import { add } from './add'

export const parentFn = depend(
  { add }, // 連想配列でデフォルトの依存性を注入する
  ({ add }, a: number, b: number, c: number) => add(a, b) * c // ここのaddは「注入された」ローカルスコープの関数
)

console.log(parentFn(2, 3, 4)) // 20

parentFnの呼び出し方は元と変わってません
dependを使って定義するときに第一引数に依存性を連想配列で渡し、第二引数の関数の最初の引数で受け取れます

これでDIでのテストが可能になりました
以下のコードはJestですが、どのフレームワークでも使い方は同じです

parentFn.spec.ts
import { parentFn } from './parentFn'

test('parentFnにDI出来るかどうかチェック', () => {
  const injectedParentFn = parentFn.inject({ add: (a, b) => a * b }) // 足し算を掛け算に変えてみる

  expect(parentFn(2, 3, 4)).toBe((2 + 3) * 4) // 20でPass、parentFnの結果は足し算のまま
  expect(injectedParentFn(2, 3, 4)).toBe(2 * 3 * 4) // 24でPass、parentFnの結果が掛け算に変わった
})

たったこれだけのことで、JavaScriptでよく使うコールバック関数にDIが出来るようになります

コールバック関数にDIする

DOMのクリックイベントやExpressのミドルウェアなどコールバック関数が必要になる場面はたくさんあります

export const doSomething = () => {
  console.log('Hello workd!') // <- typo
}

setTimeout(doSomething, 1000)

例えば上の例だと、期待する文字列Hello world!をdoSomethingが出力するかどうかをテストするにはconsole.logを以下のようにspyOnする必要があります

import { doSomething } from './doSomething'

test('Hello world!が出力される', () => {
  const spy = jest.spyOn(console, 'log')

  doSomething()
  expect(console.log).toHaveBeenCalledWith('Hello world!') // Error!

  spy.mockRestore()
})

DIが出来るようにVelonaを使ってみます

import { depend } from 'velona'

export const doSomething = depend(
  { print: (message: string) => console.log(message) },
  ({ print }) => print('Hello workd!') // <- typo
)

setTimeout(doSomething, 1000)

テストコードはちょっと長くなりますね

import { doSomething } from './doSomething'

test('Hello world!が出力される', () => {
  let called
  const injectedDoSomething = doSomething.inject({
    print: message => {
      called = message
    }
  })

  injectedDoSomething()
  expect(called).toBe('Hello world!') // Error!
})

結合テストでネストした関数にDIする

parentFnから3階層下のadd関数を掛け算関数に差し替えてみます

add.ts
export const add = (a: number, b: number) => a + b
grandchild.ts
import { depend } from 'velona'
import { add } from './add'

export const grandchild = depend(
  { add },
  ({ add }, a: number, b: number) => add(a, b)
)
child.ts
import { depend } from 'velona'
import { grandchild } from './grandchild'

export const child = depend(
  { grandchild },
  ({ grandchild }, a: number, b: number, c: number) => grandchild(a, b) * c
)
parentFn.ts
import { depend } from 'velona'
import { child } from './child'

export const parentFn = depend(
  {
    child,
    print: (data: number) => alert(data) // ブラウザではalertが呼ばれるが、テスト環境のNode.jsにはないのでこれもDIが必要
  },
  ({ child, print }, a: number, b: number, c: number) => print(child(a, b, c))
)

ここまででparentFnの定義が完了です
3階層下にadd関数がいる状態

ブラウザ上では以下の通り普通の関数として使えます

index.ts
import { parentFn } from './parentFn'

parentFn(2, 3, 4) // alert(20)

テスト時に最下層のaddを差し替えるには愚直にinjectの入れ子を行います

parentFn.spec.ts
import { parentFn } from './parentFn'
import { child } from './child'
import { grandchild } from './grandchild'

const injectedFn = parentFn.inject({
  child: child.inject({
    grandchild: grandchild.inject({
      add: (a, b) => a * b // 最下層のaddを掛け算に変更
    })
  }),
  print: data => data // alertはJest/Node.js環境にはないので差し替える
})

expect(injectedFn(2, 3, 4)).toBe(2 * 3 * 4) // pass

結構な手間に見えますが、エディタがサポートしてくれるので見た目ほど大きな負担にはならないです

もう一度15行のコードをおさらい

上記の機能全てがたったの15行で実現できます

type Deps<T extends Record<string, any>> = {
  [P in keyof T]: T[P] extends { _velona: boolean }
    ? (...args: Parameters<T[P]>) => ReturnType<T[P]>
    : T[P]
}

export const depend = <T extends Record<string, any>, U extends any[], V>(
  dependencies: T,
  cb: (deps: Deps<T>, ...args: U) => V
) => {
  const fn = (...args: U) => cb(dependencies, ...args)
  fn._velona = true
  fn.inject = (deps: Deps<T>) => (...args: U) => cb(deps, ...args)
  return fn
}

コピペしてもいいですが、Velonaをインストールすると共通概念として会話が成り立つのでおススメです
https://github.com/frouriojs/velona

面白いアイディアだなあと思ったらスターを押してもらえると嬉しいです!

145
104
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
145
104

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?