Make it to make it

いろいろ作ってアウトプットするブログ

Redux Toolkitのレビュー

mktmkt.hatenablog.com

前回の投稿で、従来のReact, Reduxで書いていた部分をRedux Toolkit (RTK) でリファクタしたが、今回はRTKのメソッドについてまとめていく。

Redux Toolkit (RTK) のメソッド一覧

configureStore()

configureStore()に渡すオプションの型定義を見てみる。

/**
 * Options for `configureStore()`.
 *
 * @public
 */
export declare interface ConfigureStoreOptions<S = any, A extends Action = AnyAction, M extends Middlewares<S> = Middlewares<S>> {
    /**
     * A single reducer function that will be used as the root reducer, or an
     * object of slice reducers that will be passed to `combineReducers()`.
     */
    reducer: Reducer<S, A> | ReducersMapObject<S, A>;
    /**
     * An array of Redux middleware to install. If not supplied, defaults to
     * the set of middleware returned by `getDefaultMiddleware()`.
     */
    middleware?: M;
    /**
     * Whether to enable Redux DevTools integration. Defaults to `true`.
     *
     * Additional configuration can be done by passing Redux DevTools options
     */
    devTools?: boolean | EnhancerOptions;
    /**
     * The initial state, same as Redux's createStore.
     * You may optionally specify it to hydrate the state
     * from the server in universal apps, or to restore a previously serialized
     * user session. If you use `combineReducers()` to produce the root reducer
     * function (either directly or indirectly by passing an object as `reducer`),
     * this must be an object with the same shape as the reducer map keys.
     */
    preloadedState?: DeepPartial<S extends any ? S : S>;
    /**
     * The store enhancers to apply. See Redux's `createStore()`.
     * All enhancers will be included before the DevTools Extension enhancer.
     * If you need to customize the order of enhancers, supply a callback
     * function that will receive the original array (ie, `[applyMiddleware]`),
     * and should return a new array (such as `[applyMiddleware, offline]`).
     * If you only need to add middleware, you can use the `middleware` parameter instaead.
     */
    enhancers?: StoreEnhancer[] | ConfigureEnhancersCallback;
}

reducerが必須のオプションで、それ以外のmiddleware, devTools, preloadedState, enhancersは任意となっている。

reducer部分には、複数のreducerを従来のcombineReducerの要領でオブジェクト形式で組み合わせて入れることもできれば、単体のreducerを入れることもできる。

combineReducerでラップして渡すこともできるけど不要。

export default configureStore({
  reducer: {
    todos: todosSlice.reducer,
    selectedTodo: selectedTodoSlice.reducer,
    counter: counterSlice.reducer,
  }
})

export default configureStore({
  reducer: todosSlice.reducer
})

middlewareは、何も渡さなければgetDefaultMiddleware()が適用される。

もし何らかの理由でそれらのgetDefaultMiddleware()の適用をしたくなければ、空配列を渡せばよい。

もしくはgetDefaultMiddleware()に足すかたちでミドルウェアを加えてあげてもよい。

またcombineReducerでのラップが不要であるのと同じように、applyMiddlewareでラップする必要もない。

export default configureStore({
  reducer: {
    // ...
  },
  middleware: [],
})

export default configureStore({
  reducer: {
    // ...
  },
  middleware: [...getDefaultMiddleware(), logger],
})

RTKではdevToolsも合わせてついてくるので、出し分け制御もできる。

export default configureStore({
  reducer: {
    // ...
  },
  middleware: : {
    // ...
  },
  devTools: process.env.NODE_ENV !== 'production'
})

大体ここまででほとんどカバーできるだろう。

createSlice()

createSlice()に渡すオプションの型定義を見てみる。

/**
 * Options for `createSlice()`.
 *
 * @public
 */
export declare interface CreateSliceOptions<State = any, CR extends SliceCaseReducers<State> = SliceCaseReducers<State>> {
    /**
     * The slice's name. Used to namespace the generated action types.
     */
    name: string;
    /**
     * The initial state to be returned by the slice reducer.
     */
    initialState: State;
    /**
     * A mapping from action types to action-type-specific *case reducer*
     * functions. For every action type, a matching action creator will be
     * generated using `createAction()`.
     */
    reducers: ValidateSliceCaseReducers<State, CR>;
    /**
     * A mapping from action types to action-type-specific *case reducer*
     * functions. These reducers should have existing action types used
     * as the keys, and action creators will _not_ be generated.
     * Alternatively, a callback that receives a *builder* object to define
     * case reducers via calls to `builder.addCase(actionCreatorOrType, reducer)`.
     */
    extraReducers?: CaseReducers<NoInfer<State>, any> | ((builder: ActionReducerMapBuilder<NoInfer<State>>) => void);
}

公式ドキュメントにはcreateSliceについて下記のように書いてある。

A function that accepts an initial state, an object full of reducer functions, and a "slice name", and automatically generates action creators and action types that correspond to the reducers and state.

action typeはslice名の後にスラッシュ区切りで、合わせて自動で生成してくれる。

initialStateも必須となっているので、従来のように初期のstateの型と値を与えることを忘れることもなくなる。

また、ここが一番の利点思っている点として、immerによりstateをmutableにハンドルすることができるようになる。

だた、immerを使用する際の注意点として、

  • immerはあくまで、mutableにinputされた書き方をうまくハンドリングしてimmutableに処理してくれているだけ
  • プリミティブ型に対してはmutateできない
  • stateのmutateをするか もしくは 新しいstateを返すか のどちらかのみ

もう一つ、prepareを使用することでactionに渡す前に処理を一回挟めるので、予め渡すオブジェクトを用意する場合などに使える。

Redux Toolkitを用いたリファクタ

React, Redux, TypeScriptの組み合わせだと、どうしてもファイル数が増えて、記述があちらこちらに散らばることが多々あり、リーダビリティが下がってしまう。

Redux Toolkit (RTK) を使えば、あちらこちらに記述していた書き方を集約して書ける以外にも、redux周りの面倒なパッケージのインストールやミドルウェアの部分も包含されているので、手間が省ける。

youtu.be

こちらの動画にわかりやすい比較サンプルが載っていたので、少々こちらでいじった上で、従来の書き方とRedux Toolkitを用いた書き方を比べてみる。

下記では、理解のしやすさ重視で、TODOリストのReduxを、従来の書き方とRTKでの書き方で1ファイルにまとめて見比べている。

コード

redux-original.ts

import { combineReducers, createStore, applyMiddleware } from 'redux';
import { v1 as uuid } from 'uuid';
import thunk from 'redux-thunk';
import logger from 'redux-logger';
import { composeWithDevTools } from 'redux-devtools-extension';

export interface Todo {
  id: string;
  desc: string;
  isComplete: boolean;
}

export interface State {
  todos: Todo[];
  selectedTodo: string | null;
  counter: number;
}

/**
 * Constants
 */
const CREATE_TODO = 'CREATE_TODO';
const EDIT_TODO = 'EDIT_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const DELETE_TODO = 'DELETE_TODO';
const SELECT_TODO = 'SELECT_TODO';

/**
 * Actions & Action types
 */
interface CreateTodoActionType {
  type: typeof CREATE_TODO;
  payload: Todo;
}
export const createTodoActionCreator = ({
  desc,
}: {
  desc: string;
}): CreateTodoActionType => ({
  type: CREATE_TODO,
  payload: {
    id: uuid(),
    desc,
    isComplete: false,
  },
});

interface EditTodoActionType {
  type: typeof EDIT_TODO;
  payload: { id: string; desc: string };
}
export const editTodoActionCreator = ({
  id,
  desc,
}: {
  id: string;
  desc: string;
}): EditTodoActionType => ({
  type: EDIT_TODO,
  payload: { id, desc },
});

interface ToggleTodoActionType {
  type: typeof TOGGLE_TODO;
  payload: { id: string; isComplete: boolean };
}
export const toggleTodoActionCreator = ({
  id,
  isComplete,
}: {
  id: string;
  isComplete: boolean;
}): ToggleTodoActionType => ({
  type: TOGGLE_TODO,
  payload: { id, isComplete },
});

interface DeleteTodoActionType {
  type: typeof DELETE_TODO;
  payload: { id: string };
}
export const deleteTodoActionCreator = ({
  id,
}: {
  id: string;
}): DeleteTodoActionType => ({
  type: DELETE_TODO,
  payload: { id },
});

interface SelectTodoActionType {
  type: typeof SELECT_TODO;
  payload: { id: string };
}
export const selectTodoActionCreator = ({
  id,
}: {
  id: string;
}): SelectTodoActionType => ({
  type: SELECT_TODO,
  payload: { id },
});

/**
 * Reducers
 */
const todosInitialState: Todo[] = [
  {
    id: uuid(),
    desc: 'Learn React',
    isComplete: true,
  },
  {
    id: uuid(),
    desc: 'Learn Redux',
    isComplete: true,
  },
  {
    id: uuid(),
    desc: 'Learn Redux-ToolKit',
    isComplete: false,
  },
];

type TodoActionTypes =
  | CreateTodoActionType
  | EditTodoActionType
  | ToggleTodoActionType
  | DeleteTodoActionType;
const todosReducer = (
  state: Todo[] = todosInitialState,
  action: TodoActionTypes
) => {
  switch (action.type) {
    case CREATE_TODO: {
      const { payload } = action;
      return [...state, payload];
    }
    case EDIT_TODO: {
      const { payload } = action;
      return state.map((todo) =>
        todo.id === payload.id ? { ...todo, desc: payload.desc } : todo
      );
    }
    case TOGGLE_TODO: {
      const { payload } = action;
      return state.map((todo) =>
        todo.id === payload.id
          ? { ...todo, isComplete: payload.isComplete }
          : todo
      );
    }
    case DELETE_TODO: {
      const { payload } = action;
      return state.filter((todo) => todo.id !== payload.id);
    }
    default: {
      return state;
    }
  }
};

type SelectedTodoActionTypes = SelectTodoActionType;
const selectedTodoReducer = (
  state: string | null = null,
  action: SelectedTodoActionTypes
) => {
  switch (action.type) {
    case SELECT_TODO: {
      const { payload } = action;
      return payload.id;
    }
    default: {
      return state;
    }
  }
};

const counterReducer = (state: number = 0, action: TodoActionTypes) => {
  switch (action.type) {
    case CREATE_TODO: {
      return state + 1;
    }
    case EDIT_TODO: {
      return state + 1;
    }
    case TOGGLE_TODO: {
      return state + 1;
    }
    case DELETE_TODO: {
      return state + 1;
    }
    default: {
      return state;
    }
  }
};

const reducers = combineReducers({
  todos: todosReducer,
  selectedTodo: selectedTodoReducer,
  counter: counterReducer,
});

/**
 * Store
 */
export default createStore(
  reducers,
  composeWithDevTools(applyMiddleware(thunk, logger))
);

redux-rtk.ts

import {
  configureStore,
  createSlice,
  getDefaultMiddleware,
  PayloadAction,
} from '@reduxjs/toolkit';
import { v1 as uuid } from 'uuid';
import logger from 'redux-logger';

export interface Todo {
  id: string;
  desc: string;
  isComplete: boolean;
}

export interface State {
  todos: Todo[];
  selectedTodo: string | null;
  counter: number;
}

const todosInitialState: Todo[] = [
  {
    id: uuid(),
    desc: 'Learn React',
    isComplete: true,
  },
  {
    id: uuid(),
    desc: 'Learn Redux',
    isComplete: true,
  },
  {
    id: uuid(),
    desc: 'Learn Redux-ToolKit',
    isComplete: false,
  },
];

const todosSlice = createSlice({
  name: 'todos',
  initialState: todosInitialState,
  reducers: {
    create: {
      reducer: (
        state,
        {
          payload,
        }: PayloadAction<{ id: string; desc: string; isComplete: boolean }>
      ) => {
        state.push(payload);
      },
      prepare: ({ desc }: { desc: string }) => ({
        payload: {
          id: uuid(),
          desc,
          isComplete: false,
        },
      }),
    },
    edit: (state, { payload }: PayloadAction<{ id: string; desc: string }>) => {
      const matchedTodo = state.find((todo) => todo.id === payload.id);
      if (typeof matchedTodo !== 'undefined') {
        matchedTodo.desc = payload.desc;
      }
    },
    toggle: (
      state,
      { payload }: PayloadAction<{ id: string; isComplete: boolean }>
    ) => {
      const matchedTodo = state.find((todo) => todo.id === payload.id);
      if (typeof matchedTodo !== 'undefined') {
        matchedTodo.isComplete = payload.isComplete;
      }
    },
    remove: (state, { payload }: PayloadAction<{ id: string }>) => {
      const index = state.findIndex((todo) => todo.id === payload.id);
      if (index !== -1) {
        state.splice(index, 1);
      }
    },
  },
});

const selectedTodoSlice = createSlice({
  name: 'selectedTodo',
  initialState: '',
  reducers: {
    select: (state, { payload }: PayloadAction<{ id: string }>) => payload.id,
  },
});

const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {},
  extraReducers: {
    [todosSlice.actions.create.type]: (state) => state + 1,
    [todosSlice.actions.edit.type]: (state) => state + 1,
    [todosSlice.actions.toggle.type]: (state) => state + 1,
    [todosSlice.actions.remove.type]: (state) => state + 1,
  },
});

export const {
  create: createTodoActionCreator,
  edit: editTodoActionCreator,
  toggle: toggleTodoActionCreator,
  remove: deleteTodoActionCreator,
} = todosSlice.actions;
export const { select: selectTodoActionCreator } = selectedTodoSlice.actions;

const reducer = {
  todos: todosSlice.reducer,
  selectedTodo: selectedTodoSlice.reducer,
  counter: counterSlice.reducer,
};

const middleware = [...getDefaultMiddleware(), logger];

export default configureStore({
  reducer,
  middleware,
});

RTKメモ

  • RTKをreactプロジェクトに導入する場合に必要な最低のパッケージは、@reduxjs/toolkitreact-reduxだけでこと足りる。
    • reduxミドルウェアも内包しているので
      (RTKのpackage.jsonを見ればわかる)
  • RTKでの非同期処理はredux-thunkを用いて行われている
  • RTKではimmerを内包しているので、mutableな形でstateの更新ができる
  • RTKでreducer, acitionsなどを記述する際は、大体createSlice一本でまとまる
  • RTKではinitialStateのフィールドが必須になっているので、従来のような定義漏れが起こりにくい
  • RTKでstateがプリミティブ型の場合は、mutateする形ではなく、従来のreduxのかたちでimmutableに書く
  • RTKでreducerに渡す際に、initialStateが上記の例のようにimpureな副作用をともなうファンクションなどを含んでいる場合、直下のreducerのメソッド内に、さらにreducerprepareメソッドを書くことで、扱うことができるようになる

React x TypeScriptの組み合わせミニマルサンプル

こちらのBen Awadのサンプルがよかったのでメモしておく。

github.com

ディレクトリ構造

.
├── package.json
├── public
│   └── index.html
├── src
│   ├── App.tsx
│   ├── Counter.tsx
│   ├── ReducerExample.tsx
│   ├── TextField.tsx
│   ├── index.tsx
│   └── react-app-env.d.ts
├── tsconfig.json
└── yarn.lock

コード

Render propsと型推論の活用

index.tsx

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(<App />, document.getElementById("root"));

App.tsx

import React from "react";
import { Counter } from "./Counter";

// props
// hooks
// render props

const App: React.FC = () => {
  return (
    <div>
      <Counter>
        {({ count, setCount }) => (
          <div>
            {count}
            <button onClick={() => setCount(count + 1)}>+</button>
          </div>
        )}
      </Counter>
    </div>
  );
};

export default App;

Render propsでcounterの実装をしている。

Counter.tsx

import React, { useState } from "react";

interface Props {
  children: (data: {
    count: number;
    setCount: React.Dispatch<React.SetStateAction<number>>;
  }) => JSX.Element | null;
}

export const Counter: React.FC<Props> = ({ children }) => {
  const [count, setCount] = useState(0);

  return <div>{children({ count, setCount })}</div>;
};

childrenに関数と引数を渡すというもの。

setCountのようなセッターの型をマニュアルで記述するのは大変だが、VSCodeのホバー時に型推論をサジェストしてくれるので、それをペーストする。

useReducerの使用例

ReducerExample.tsx

import React, { useReducer } from "react";

type Actions =
  | { type: "add"; text: string }
  | {
      type: "remove";
      idx: number;
    };

interface Todo {
  text: string;
  complete: boolean;
}

type State = Todo[];

const TodoReducer = (state: State, action: Actions) => {
  switch (action.type) {
    case "add":
      return [...state, { text: action.text, complete: false }];
    case "remove":
      return state.filter((_, i) => action.idx !== i);
    default:
      return state;
  }
};

export const ReducerExample: React.FC = () => {
  const [todos, dispatch] = useReducer(TodoReducer, []);

  return (
    <div>
      {JSON.stringify(todos)}
      <button
        onClick={() => {
          dispatch({ type: "add", text: "..." });
        }}
      >
        +
      </button>
    </div>
  );
};

useState, useRefの使用例

TextField.tsx

import React, { useState, useRef } from "react";

interface Person {
  firstName: string;
  lastName: string;
}

interface Props {
  text: string;
  ok?: boolean;
  i?: number;
  fn?: (bob: string) => string;
  person: Person;
  handleChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

interface TextNode {
  text: string;
}

export const TextField: React.FC<Props> = ({ handleChange }) => {
  const [count, setCount] = useState<TextNode>({ text: "hello" });
  const inputRef = useRef<HTMLInputElement>(null);
  const divRef = useRef<HTMLDivElement>(null);

  return (
    <div ref={divRef}>
      <input ref={inputRef} onChange={handleChange} />
    </div>
  );
};

学びの記録『りあクト!』第11章 Redux でグローバルな状態を扱う

github.com

useSelector, useDispatchを用いたReduxの書き方

ディレクトリ構造

container, presentationalディレクトリで分けて、atomic design風に分ける。

.
├── App.css
├── App.tsx
├── components
│   ├── container
│   │   ├── molecules
│   │   │   └── ColorfulBeads.tsx
│   │   └── organisms
│   │       └── CounterBoard.tsx
│   └── presentational
│       ├── molecules
│       │   ├── ColorfulBeads.css
│       │   └── ColorfulBeads.tsx
│       └── organisms
│           ├── CounterBoard.css
│           └── CounterBoard.tsx
├── index.tsx
└── redux
    ├── actions.ts
    └── reducer.ts

コード

index.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import { Provider } from 'react-redux'

import { counterReducer, initialState } from './redux/reducer'
import { App } from './App'
import 'semantic-ui-css/semantic.css'

const store = createStore(counterReducer, initialState)

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root'),
)

App.tsx

import React from 'react'

import { EnhancedCounterBoard as CounterBoard } from './components/container/organisms/CounterBoard'
import { EnhancedColorfulBeads as ColorfulBeads } from './components/container/molecules/ColorfulBeads'

import './App.css'

export const App: React.FC = () => (
  <div className="container">
    <header>
      <h1>Beads counter</h1>
    </header>
    <CounterBoard />
    <ColorfulBeads count={0} />
  </div>
)

App.tsxにcontainerコンポーネントCounterBoard.tsx, ColorfulBeads.tsxを読み込む。

src/components/container/organisms/CounterBoard.tsx

import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

import { ICounterState } from '../../../redux/reducer'
import { decrement, increment, add } from '../../../redux/actions'
import { CounterBoard } from '../../presentational/organisms/CounterBoard'

export const EnhancedCounterBoard: React.FC = () => {
  const count = useSelector<ICounterState, number>((state) => state.count)
  const dispatch = useDispatch()

  return (
    <CounterBoard
      count={count}
      decrement={() => dispatch(decrement())}
      increment={() => dispatch(increment())}
      add={(amount) => dispatch(add(amount))}
    />
  )
}

src/components/container/molecules/ColorfulBeads.tsx

import React from 'react'
import { useSelector } from 'react-redux'

import { ICounterState } from '../../../redux/reducer'
import { ColorfulBeads } from '../../presentational/molecules/ColorfulBeads'

export const EnhancedColorfulBeads: React.FC<{ count: number }> = () => {
  const count = useSelector<ICounterState, number>((state) => state.count)

  return <ColorfulBeads count={count} />
}

containerコンポーネント側でuseSelector, useDispatchを利用してRedux storeとdispatcherとのやり取りをしている。

Redux actions, reducerは以下のようになっている。

src/redux/actions.ts

export enum CounterActionType {
  ADD = 'counter/ADD',
  DECREMENT = 'counter/DECREMENT',
  INCREMENT = 'counter/INCREMENT',
}

export interface ICounterActionDecrement {
  type: CounterActionType.DECREMENT
}

export interface ICounterActionIncrement {
  type: CounterActionType.INCREMENT
}

export interface ICounterActionAdd {
  type: CounterActionType.ADD
  amount: number
}

export const decrement = (): ICounterActionDecrement => ({
  type: CounterActionType.DECREMENT,
})

export const increment = (): ICounterActionIncrement => ({
  type: CounterActionType.INCREMENT,
})

export const add = (amount: number): ICounterActionAdd => ({
  type: CounterActionType.ADD,
  amount,
})

export type CounterActions =
  | ReturnType<typeof decrement>
  | ReturnType<typeof increment>
  | ReturnType<typeof add>

src/redux/reducer.ts

import { Reducer } from 'redux'
import { CounterActions, CounterActionType as Type } from './actions'

export interface ICounterState {
  count: number
}

export const initialState: ICounterState = { count: 0 }

export const counterReducer: Reducer<ICounterState, CounterActions> = (
  state: ICounterState = initialState,
  action: CounterActions,
): ICounterState => {
  switch (action.type) {
    case Type.ADD:
      return {
        ...state,
        count: state.count + action.amount,
      }
    case Type.DECREMENT:
      return {
        ...state,
        count: state.count - 1,
      }
    case Type.INCREMENT:
      return {
        ...state,
        count: state.count + 1,
      }
    default: {
      const _: never = action

      return state
    }
  }
}

presentationalコンポーネント

src/components/presentational/organisms/CounterBoard.tsx

import React from 'react'
import { Card, Statistic, Button } from 'semantic-ui-react'

const BULK_UNIT = 10

interface ICounterBoardProps {
  count: number
  decrement: () => void
  increment: () => void
  add: (amount: number) => void
}

export const CounterBoard: React.FC<ICounterBoardProps> = ({
  count = 0,
  decrement,
  increment,
  add,
}) => (
  <Card>
    <Statistic className="number-board">
      <Statistic.Label>count</Statistic.Label>
      <Statistic.Value>{count}</Statistic.Value>
    </Statistic>
    <Card.Content>
      <div className="ui two buttons">
        <Button color="red" onClick={decrement}>
          -1
        </Button>
        <Button color="green" onClick={increment}>
          +1
        </Button>
      </div>
      <div className="fluid-button">
        <Button fluid color="grey" onClick={() => add(BULK_UNIT)}>
          +{BULK_UNIT}
        </Button>
      </div>
    </Card.Content>
  </Card>
)

src/components/presentational/molecules/ColorfulBeads.tsx

import React from 'react'
import { SemanticCOLORS, Container, Label } from 'semantic-ui-react'

import './ColorfulBeads.css'

// equivalent to https://lodash.com/docs/#range
// see also https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/from
const range = (n: number): number[] =>
  n < 0 ? [] : Array.from(Array(n), (_, i) => i)

const colors: SemanticCOLORS[] = [
  'red',
  'orange',
  'yellow',
  'olive',
  'green',
  'teal',
  'blue',
  'violet',
  'purple',
  'pink',
  'brown',
  'grey',
  'black',
]

export const ColorfulBeads: React.FC<{ count: number }> = ({ count = 0 }) => (
  <Container className="beads-box">
    {range(count).map((n) => (
      <Label circular color={colors[n % colors.length]} key={n} />
    ))}
  </Container>
)

Redux toolkitの導入

Redux toolkitを用いれば、DUCKSパターンを用いたシンプルなRedux構造を考えることができる。

また、Redux toolkit提供メソッドの一つであるcreateSliceを用いれば、

  • initial state
  • reducer
  • action types
  • actions

などをまとめて一つのファイル内に記述できる。

ディレクトリ構造

.
├── App.css
├── App.tsx
├── components
│   ├── container
│   │   ├── molecules
│   │   │   └── ColorfulBeads.tsx
│   │   └── organisms
│   │       └── CounterBoard.tsx
│   └── presentational
│       ├── molecules
│       │   ├── ColorfulBeads.css
│       │   └── ColorfulBeads.tsx
│       └── organisms
│           ├── CounterBoard.css
│           └── CounterBoard.tsx
├── features
│   └── counter.ts
└── index.tsx

コード

src/index.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import { configureStore } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'

import { counterSlice } from './features/counter'
import { App } from './App'
import 'semantic-ui-css/semantic.css'

const store = configureStore({ reducer: counterSlice.reducer })

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root'),
)

src/App.tsx

import React from 'react'

import { EnhancedColorfulBeads as ColorfulBeads } from './components/container/molecules/ColorfulBeads'
import { EnhancedCounterBoard as CounterBoard } from './components/container/organisms/CounterBoard'

import './App.css'

export const App: React.FC = () => (
  <div className="container">
    <header>
      <h1>Beads counter</h1>
    </header>
    <CounterBoard />
    <ColorfulBeads count={0} />
  </div>
)

src/features/counter.ts

counterに関連するreduxコードがこちらのファイルに全てまとまる。

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

export interface ICounterSlice {
  count: number
}

const initialState: ICounterSlice = { count: 0 }

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    decremented: (state) => ({ ...state, count: state.count - 1 }),
    incremented: (state) => ({ ...state, count: state.count + 1 }),
    added: (state, action: PayloadAction<number>) => ({
      ...state,
      count: state.count + action.payload,
    }),
  },
})

src/components/container/organisms/CounterBoard.tsx

import React from 'react'
import { useDispatch, useSelector } from 'react-redux'

import { ICounterSlice, counterSlice } from '../../../features/counter'
import { CounterBoard } from '../../presentational/organisms/CounterBoard'

export const EnhancedCounterBoard: React.FC = () => {
  const count = useSelector<ICounterSlice, number>((state) => state.count)
  const dispatch = useDispatch()
  const { decremented, incremented, added } = counterSlice.actions

  return (
    <CounterBoard
      count={count}
      decrement={() => dispatch(decremented())}
      increment={() => dispatch(incremented())}
      add={(amount) => dispatch(added(amount))}
    />
  )
}

src/components/container/molecules/ColorfulBeads.tsx

import React from 'react'
import { useSelector } from 'react-redux'

import { ColorfulBeads } from '../../presentational/molecules/ColorfulBeads'
import { ICounterSlice } from '../../../features/counter'

export const EnhancedColorfulBeads: React.FC<{ count: number }> = () => {
  const count = useSelector<ICounterSlice, number>((state) => state.count)

  return <ColorfulBeads count={count} />
}

TypeScriptで使いまわしやすいコードを書く

Node.js環境上でCSVファイルを取り込んで何かしらの加工をして吐き出すプログラムを書きながら、使い回しのし易いTypeScriptの書き方を学ぶ。

セットアップ

Packages

yarn add -D @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser concurrently eslint eslint-config-prettier eslint-config-standard-with-typescript eslint-plugin-import eslint-plugin-node eslint-plugin-promise eslint-plugin-standard nodemon prettier rimraf typescript

npm scripts

  "scripts": {
    "start:build": "tsc -w",
    "start:run": "nodemon build/index.js",
    "start": "concurrently yarn:start:*",
    "clean": "rimraf build"
  },

CSVファイル

一例としてサッカーの試合結果一覧のCSVデータを利用する。

football.csv

10/08/2018,Man United,Leicester,2,1,H,A Marriner
11/08/2018,Bournemouth,Cardiff,2,0,H,K Friend
11/08/2018,Fulham,Crystal Palace,0,2,A,M Dean
11/08/2018,Huddersfield,Chelsea,0,3,A,C Kavanagh
11/08/2018,Newcastle,Tottenham,1,2,A,M Atkinson
11/08/2018,Watford,Brighton,2,0,H,J Moss
11/08/2018,Wolves,Everton,2,2,D,C Pawson
12/08/2018,Arsenal,Man City,0,2,A,M Oliver
12/08/2018,Liverpool,West Ham,4,0,H,A Taylor
12/08/2018,Southampton,Burnley,0,0,D,G Scott
18/08/2018,Cardiff,Newcastle,0,0,D,C Pawson
18/08/2018,Chelsea,Arsenal,3,2,H,M Atkinson
18/08/2018,Everton,Southampton,2,1,H,L Mason
18/08/2018,Leicester,Wolves,2,0,H,M Dean
18/08/2018,Tottenham,Fulham,3,1,H,A Taylor
18/08/2018,West Ham,Bournemouth,1,2,A,S Attwell
19/08/2018,Brighton,Man United,3,2,H,K Friend
19/08/2018,Burnley,Watford,1,3,A,P Tierney
19/08/2018,Man City,Huddersfield,6,1,H,A Marriner
20/08/2018,Crystal Palace,Liverpool,0,2,A,M Oliver

最初のコード

マンUの試合結果を取得してくるロジックをハードコードしたもの。

src/index.ts

import * as fs from 'fs'

const matches = fs
  .readFileSync('football.csv', {
    encoding: 'utf-8',
  })
  .split('\n')
  .map((row: string): string[] => row.split(','))

let manUnitedWins = 0

for (const match of matches) {
  if (match[1] === 'Man United' && match[5] === 'H') {
    manUnitedWins++
  } else if (match[2] === 'Man United' && match[5] === 'A') {
    manUnitedWins++
  }
}

console.log(`Man United won ${manUnitedWins} games`)

リファクタ1

enumを用いるのと、別のロジッククラスを用意してリファクタする。

src/index.ts

import { CsvFileReader } from './CsvFileReader'

const reader = new CsvFileReader('football.csv')
reader.read()

enum MatchResult {
  HomeWin = 'H',
  AwayWin = 'A',
  Draw = 'D',
}

let manUnitedWins = 0

for (const match of reader.data) {
  if (match[1] === 'Man United' && match[5] === MatchResult.HomeWin) {
    manUnitedWins++
  } else if (match[2] === 'Man United' && match[5] === MatchResult.AwayWin) {
    manUnitedWins++
  }
}

console.log(`Man United won ${manUnitedWins} games`)

src/CsvFileReader.ts

import * as fs from 'fs'

export class CsvFileReader {
  data: string[][] = []

  constructor(public filename: string) {}

  read(): void {
    this.data = fs
      .readFileSync(this.filename, {
        encoding: 'utf-8',
      })
      .split('\n')
      .map((row: string): string[] => row.split(','))
  }
}

いつenumを使うべきか?

  • Follow near-identical syntax rules as normal objects
  • Creates an object with the same keys and values when converted from TS to JS
  • Primary goal is to signal to to other engineers that these are all closely related values
  • Use whenever we have a small fixed set of values that are all closely related and known at compile time

enumを導入するかどうかの実例

  • カラーピッカーで色指定をするとき:yes
    • 既に決まっていてわかっているから
  • 動画配信サイトのカテゴリを指定するとき:no
    • 変わり続けてアップデートされるものだから
  • メニュー上のドリンクのサイズ:yes
    • 選択肢が数種類しかないので
  • 1750年以降の年数:no
    • 値の数が大きすぎる
  • テキストメッセージの既読フラグ:yes
    • 想定しうるステータスが決まっているから

React hooksのミニマルサンプル

リファクタプロセス

hooksを一つずつ導入

useState

カウンターアプリを、まずはstate hookを使って書いてみる。

import React, { useState } from 'react'
import ReactDOM from 'react-dom'

const Counter: React.FC = () => {
  const [count, setCount] = useState(0)
  const reset = (): void => setCount(0)
  const increment = (): void => setCount((c) => c + 1)

  return (
    <div>
      <div className="number-board">
        <span>count</span> <span>{count}</span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
        <button type="button" onClick={increment}>
          +1
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Counter />
  </React.StrictMode>,
  document.getElementById('root'),
)

useEffect

上記を応用したカウントダウンタイマー。

import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'

const Timer: React.FC<{ limit: number }> = ({ limit }) => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const reset = (): void => setTimeLeft(limit)
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const timerId = setInterval(tick, 1000)
    return () => clearInterval(timerId)
  }, [])

  useEffect(() => {
    if (timeLeft === 0) setTimeLeft(limit)
  }, [timeLeft, limit])

  return (
    <div>
      <div className="number-board">
        <span>time</span> <span>{timeLeft}</span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Timer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

useMemo

メモ化を導入してみる。

import React, { useState, useEffect, useMemo } from 'react'
import ReactDOM from 'react-dom'

const getPrimes = (maxRange: number): number[] =>
  // @ts-expect-error
  [...Array(maxRange + 1).keys()].slice(2).filter((n) => {
    for (let i = 2; i < n; i += 1) {
      if (n % i === 0) return false
    }
    return true
  })

const Timer: React.FC<{ limit: number }> = ({ limit }) => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const primes = useMemo(() => getPrimes(limit), [limit])
  const reset = (): void => setTimeLeft(limit)
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const timerId = setInterval(tick, 1000)
    return () => clearInterval(timerId)
  }, [])

  useEffect(() => {
    if (timeLeft === 0) setTimeLeft(limit)
  }, [timeLeft, limit])

  return (
    <div>
      <div className="number-board">
        <span>time</span>{' '}
        <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}>
          {timeLeft}
        </span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Timer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

useCallback

setTimeLeft(limit)を呼んでるところ、reset()に共通化する。

import React, { useState, useEffect, useMemo } from 'react'
import ReactDOM from 'react-dom'

const getPrimes = (maxRange: number): number[] =>
  // @ts-expect-error
  [...Array(maxRange + 1).keys()].slice(2).filter((n) => {
    for (let i = 2; i < n; i += 1) {
      if (n % i === 0) return false
    }
    return true
  })

const Timer: React.FC<{ limit: number }> = ({ limit }) => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const primes = useMemo(() => getPrimes(limit), [limit])
  const reset = (): void => setTimeLeft(limit)
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const timerId = setInterval(tick, 1000)
    return () => clearInterval(timerId)
  }, [])

  useEffect(() => {
    if (timeLeft === 0) reset()
  }, [timeLeft, limit])

  return (
    <div>
      <div className="number-board">
        <span>time</span>{' '}
        <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}>
          {timeLeft}
        </span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Timer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

eslint(react-hooks/exhaustive-deps)を入れていると、勝手にdependencies arrayの中身を正しいものにサジェストしてくれる。

React Hook useEffect has a missing dependency: 'reset'. Either include it or remove the dependency array.

そちらを修正すると、今度はresetメソッドに下線が引かれてuseCallback()を使うように促される。

The 'reset' function makes the dependencies of useEffect Hook (at line 26) change on every render. To fix this, wrap the 'reset' definition into its own useCallback() Hook.

これらを修正すると、下記のようになる。

import React, { useState, useEffect, useMemo, useCallback } from 'react'
import ReactDOM from 'react-dom'

const getPrimes = (maxRange: number): number[] =>
  // @ts-expect-error
  [...Array(maxRange + 1).keys()].slice(2).filter((n) => {
    for (let i = 2; i < n; i += 1) {
      if (n % i === 0) return false
    }
    return true
  })

const Timer: React.FC<{ limit: number }> = ({ limit }) => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const primes = useMemo(() => getPrimes(limit), [limit])
  const reset = useCallback((): void => setTimeLeft(limit), [limit])
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const timerId = setInterval(tick, 1000)
    return () => clearInterval(timerId)
  }, [])

  useEffect(() => {
    if (timeLeft === 0) reset()
  }, [timeLeft, limit, reset])

  return (
    <div>
      <div className="number-board">
        <span>time</span>{' '}
        <span style={{ color: primes.includes(timeLeft) ? 'pink' : '' }}>
          {timeLeft}
        </span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

ReactDOM.render(
  <React.StrictMode>
    <Timer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

Custom hookでロジックを分離・再利用

Custom hookを使った上で、containerとpresentational componentに分ける。

App.tsx

import React from 'react'
import ReactDOM from 'react-dom'
import { useTimer } from './hooks'

interface IPropsTimer {
  timeLeft?: number
  isPrime?: boolean
  reset?: () => void
}

const Timer: React.FC<IPropsTimer> = ({
  timeLeft = 0,
  isPrime = false,
  reset = () => undefined,
}) => {
  return (
    <div>
      <div className="number-board">
        <span>time</span>{' '}
        <span style={{ color: isPrime ? 'pink' : '' }}>{timeLeft}</span>
      </div>
      <div className="buttons">
        <button type="button" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

interface IPropsEnhancedTimer {
  limit: number
}

const EnhancedTimer: React.FC<IPropsEnhancedTimer> = ({ limit }) => {
  const [timeLeft, isPrime, reset] = useTimer(limit)

  return <Timer timeLeft={timeLeft} isPrime={isPrime} reset={reset} />
}

ReactDOM.render(
  <React.StrictMode>
    <EnhancedTimer limit={60} />
  </React.StrictMode>,
  document.getElementById('root'),
)

hooks.ts

import { useState, useEffect, useMemo, useCallback, useRef } from 'react'

const getPrimes = (maxRange: number): number[] =>
  // @ts-expect-error
  [...Array(maxRange + 1).keys()].slice(2).filter((n) => {
    for (let i = 2; i < n; i += 1) {
      if (n % i === 0) return false
    }
    return true
  })

export const useTimer = (limit: number): [number, boolean, () => void] => {
  const [timeLeft, setTimeLeft] = useState(limit)
  const primes = useMemo(() => getPrimes(limit), [limit])
  const timerId = useRef<NodeJS.Timeout | undefined>()
  const reset = useCallback(() => setTimeLeft(limit), [limit])
  const tick = (): void => setTimeLeft((t) => t - 1)

  useEffect(() => {
    const clearTimer = (): void => {
      if (typeof timerId.current === 'undefined') {
        return
      }
      clearInterval(timerId.current)
    }

    reset()
    clearTimer()

    timerId.current = setInterval(tick, 1000)

    return clearTimer
  }, [limit, reset])

  useEffect(() => {
    if (timeLeft === 0) reset()
  }, [timeLeft, reset])

  return [timeLeft, primes.includes(timeLeft), reset]
}

TypeScriptのデザインパターン

デザインパターン1

Google Maps JavaScript APIを利用して、Google Mapsのヘルパークラスと、そのヘルパークラスに渡されるインスタンス生成用のクラスを複数用意するパターン。

.
├── Company.ts -- Creates instance to pass to CustomMap.ts
├── CustomMap.ts -- Google Maps API helper class
├── User.ts -- Creates instance to pass to CustomMap.ts
└── index.ts -- Entry point

イントロ

tsconfig.jsonはこんな感じ。

{
  "compilerOptions": {
    "outDir": "./dist/",
    "sourceMap": true,
    "strict": true,
    "noImplicitReturns": true,
    "noImplicitAny": true,
    "module": "es6",
    "moduleResolution": "node",
    "target": "es5",
    "allowJs": true,
    "allowSyntheticDefaultImports": true
  },
  "include": ["./src/**/*"]
}

Google Mapはシンプルにindex.htmlにscriptタグでCDNで読み込む。

<script src="https://maps.googleapis.com/maps/api/js?key=XXXXXXXXXXXX"></script>

経度や緯度を含めたダミー情報を読み込む用にはfakerというパッケージを使う。 また、Google Maps JavaScript APIを使用する際の型のサポートを行うパッケージも追加する。

yarn add faker && yarn add -D @types/faker @types/googlemaps

コード

ヘルパークラスCustomMap.ts

APIを直接扱うためのクラス。

export interface IMappable {
  location: {
    lat: number
    lng: number
  }
  markerContent(): string
  color: string
}

export class CustomMap {
  private googleMap: google.maps.Map

  constructor(elId: string) {
    const $mapEl = document.getElementById(elId)

    if ($mapEl === null) {
      throw new Error('Map is null or has not been rendered!')
    }

    $mapEl.style.minHeight = '500px'

    this.googleMap = new google.maps.Map($mapEl, {
      zoom: 1,
      center: {
        lat: 0,
        lng: 0,
      },
    })
  }

  addMarker(mappable: IMappable): void {
    const marker = new google.maps.Marker({
      map: this.googleMap,
      position: {
        lat: mappable.location.lat,
        lng: mappable.location.lng,
      },
    })

    marker.addListener('click', () => {
      const infoWindow = new google.maps.InfoWindow({
        content: mappable.markerContent(),
      })

      infoWindow.open(this.googleMap, marker)
    })
  }
}

private googleMap: google.maps.Map

インスタンス変数の型をprivateで指定することで、クラスを使用する際に不用意に書き換えされないようにする。不用意にアクセスして書き換えようとすると、以下のようなエラーが表示される。

(property) CustomMap.googleMap: google.maps.Map<Element>
Property 'googleMap' is private and only accessible within class 'CustomMap'.ts(2341)

addMarker(mappable: IMappable): void

Google Map上にマーカーを表示するためのメソッド。 mappable に緯度、経度、マーカーの中身を返す関数を渡す。

インスタンス生成用クラスUser.ts

適当なユーザー名と緯度・経度を生成する。

import faker from 'faker'
import { IMappable } from './CustomMap'

export class User implements IMappable {
  name: string
  location: {
    lat: number
    lng: number
  }
  color: string = 'userDefaultColor'

  constructor() {
    this.name = faker.name.firstName()
    this.location = {
      lat: parseFloat(faker.address.latitude()),
      lng: parseFloat(faker.address.longitude()),
    }
  }

  markerContent(): string {
    return `
      <div>
        <p>User name: ${this.name}</p>
      </div>
    `
  }
}

インスタンス生成用クラスCompany.ts

適当な会社名と緯度・経度を生成する。

import faker from 'faker'
import { IMappable } from './CustomMap'

export class Company implements IMappable {
  companyName: string
  catchPhrase: string
  location: {
    lat: number
    lng: number
  }
  color: string = 'companyDefaultColor'

  constructor() {
    this.companyName = faker.company.companyName()
    this.catchPhrase = faker.company.catchPhrase()
    this.location = {
      lat: parseFloat(faker.address.latitude()),
      lng: parseFloat(faker.address.longitude()),
    }
  }

  markerContent(): string {
    return `
      <div>
        <p>Company name: ${this.companyName}</p>
        <small>Catchphrase: ${this.catchPhrase}</small>
      </div>
    `
  }
}

export class User implements IMappable

この書き方で、インスタンス生成用のクラスの補助的なインターフェースのような役割を付与することができる。

export interface IMappable {
  location: {
    lat: number
    lng: number
  }
  markerContent(): string
  color: string
}

となっているので、これらのインスタンス変数とメソッドは必須になる。 それ以外はoptionalで必要な場合に指定するかたち。

エントリーポイントindex.ts

import { User } from './User'
import { Company } from './Company'
import { CustomMap } from './CustomMap'

const user = new User()
const company = new Company()
const customMap = new CustomMap('app')

customMap.addMarker(user)
customMap.addMarker(company)

書き方メモ

当たり前といえば当たり前だが、典型的なTypeScriptファイルは下記のような構成となる。

- ファイル上部
  - クラスを扱うためのインターフェース
- ファイル下部
  - クラス定義

デザインパターン2

Bubble sortという最も単純なソートのアルゴリズムを利用して、number, string, node (独自numberのコレクション配列) のソートを行う例。

.
├── CharactersCollection.ts -- Sort array of strings
├── LinkedList.ts -- Sort array of collections of numbers
├── NumbersCollection.ts -- Sort array of numbers
├── Sorter.ts -- Common sorting algorithm
└── index.ts -- Entry point

イントロ

tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true
  }
}

typescript以外に必要なパッケージをインストール。こちらの例では実行環境はNode上のみ。

yarn add -D concurrently nodemon

npm scripts

concurrentlyを使用してtscでJSにコンパイルしながらwatchを行うと同時に、nodemonでリアルタイムにterminal上にprint outする。

"start:build": "tsc -w",
"start:run": "nodemon build/index.js",
"start": "concurrently yarn:start:*",

Bubble Sort

配列の中を走査しながら前の項目と後ろの項目を比較し、指定のソートを行うというアルゴリズム。コードとしては微妙だが、forループを2重で書くことで実現できる。

for (let i = 0; i < length; i++) {
  for (let j = 0; j < length - i - 1; j++) {
    if (this.collection[j] > this.collection[j + 1]) {
      const leftHand = this.collection[j]
      this.collection[j] = this.collection[j + 1]
      this.collection[j + 1] = leftHand
    }
  }
}

コード

Abstract class Sorter.ts

Abstract classを使用することで、sortロジックのみをコンパクトに抽出して使用することができる。

export abstract class Sorter {
  abstract length: number
  abstract compare(leftIndex: number, rightIndex: number): boolean
  abstract swap(leftIndex: number, rightIndex: number): void

  sort(): void {
    const { length } = this

    for (let i = 0; i < length; i++) {
      for (let j = 0; j < length - i - 1; j++) {
        if (this.compare(j, j + 1)) {
          this.swap(j, j + 1)
        }
      }
    }
  }
}

NumbersCollection.ts

数字の配列をsortする。

import { Sorter } from './Sorter'

export class NumbersCollection extends Sorter {
  constructor(public data: number[]) {
    super()
  }

  get length(): number {
    return this.data.length
  }

  compare(leftIndex: number, rightIndex: number): boolean {
    return this.data[leftIndex] > this.data[rightIndex]
  }

  swap(leftIndex: number, rightIndex: number): void {
    const leftHand = this.data[leftIndex]
    this.data[leftIndex] = this.data[rightIndex]
    this.data[rightIndex] = leftHand
  }
}

CharactersCollection.ts

文字列の配列をsortする。

import { Sorter } from './Sorter'

export class CharactersCollection extends Sorter {
  constructor(public data: string) {
    super()
  }

  get length(): number {
    return this.data.length
  }

  compare(leftIndex: number, rightIndex: number): boolean {
    return this.data[leftIndex].toLowerCase() > this.data[rightIndex].toLowerCase()
  }

  swap(leftIndex: number, rightIndex: number): void {
    const characters = this.data.split('')

    const leftHand = characters[leftIndex]
    characters[leftIndex] = this.data[rightIndex]
    characters[rightIndex] = leftHand

    this.data = characters.join('')
  }
}

LinkedList.ts

数字のコレクションの配列をsortする。

import { Sorter } from './Sorter'

class Node {
  next: Node | null = null

  constructor(public data: number) {}
}

export class LinkedList extends Sorter {
  head: Node | null = null

  add(data: number): void {
    const node = new Node(data)

    if (!this.head) {
      this.head = node
      return
    }

    let tail = this.head
    while (tail.next) {
      tail = tail.next
    }

    tail.next = node
  }

  get length(): number {
    if (!this.head) {
      return 0
    }

    let length = 1
    let node = this.head
    while (node.next) {
      length++
      node = node.next
    }

    return length
  }

  at(index: number): Node {
    if (!this.head) {
      throw new Error('Index out of bounds')
    }

    let counter = 0
    let node: Node | null = this.head
    while (node) {
      if (counter === index) {
        return node
      }

      counter++
      node = node.next
    }

    throw new Error('Index out of bounds')
  }

  compare(leftIndex: number, rightIndex: number): boolean {
    if (!this.head) {
      throw new Error('List is empty')
    }

    return this.at(leftIndex).data > this.at(rightIndex).data
  }

  swap(leftIndex: number, rightIndex: number): void {
    const leftNode = this.at(leftIndex)
    const rightNode = this.at(rightIndex)

    const leftHand = leftNode.data
    leftNode.data = rightNode.data
    rightNode.data = leftHand
  }

  print(): void {
    if (!this.head) {
      return
    }

    let node: Node | null = this.head
    while (node) {
      console.log(node.data)
      node = node.next
    }
  }
}

エントリーポイントindex.ts

import { NumbersCollection } from './NumbersCollection'
import { CharactersCollection } from './CharactersCollection'
import { LinkedList } from './LinkedList'

const numbersCollection = new NumbersCollection([10, 3, -5, 0, 10000])
numbersCollection.sort()
console.log(numbersCollection.data)

const charactersCollection = new CharactersCollection('Xaayb')
charactersCollection.sort()
console.log(charactersCollection)

const linkedList = new LinkedList()
linkedList.add(500)
linkedList.add(-10)
linkedList.add(-3)
linkedList.add(4)

linkedList.sort()
linkedList.print()

👇ログ結果

# numbersCollection
[ -5, 0, 3, 10, 10000 ]

# charactersCollection
CharactersCollection { data: 'aabXy' }

# linkedList
-10
-3
4
500

書き方メモ

Interfaces

  • Sets up a contract between different classes
  • Use when we have very different objects that we want to work together
  • Promotes loose coupling

Inheritance / Abstract classes

  • Sets up a contract between different classes
  • Use when we are trying to build up a definition of an object
  • Strongly couples classes together

👉抽象化したビジネスロジックを含んだ抽象化クラスを、使用するクラス側ででextendsさせて使用する