しかたらむーの てっくぶろ

組み込みソフトの古参エンジニアがモダンなフロントエンドエンジニアに転生 その進化の記録です

React + Typescript で 『レスポンシブなスクローラブルリスト』をやってみる

はじめに

このところ、更新が滞っていましたが、ようやく少し落ち着いてきたので、研鑽したいと思います。

ここ3回ほど、Vue を離れて React を勉強していました。前回、前々回で『スクローラブルリスト』を題材に VueReact の違いを実感しました。 今回はそれをさらに進めて React + Typescript で『スクローラブルリスト』に挑戦してみたいと思います。

ReactTypescript を使うための環境構築とプロジェクトの作成からはじまり、前々回のコードをベースに Typescript に対応するための修正を加えていく進め方で展開したいと思いますので、しばしお付き合いください。

出来上がりはこんな感じ!

作るのは『スクローラブルリスト』なので出来上がりも前々回のものと同じです。 一応、こんな感じです。

f:id:Shikataramuno:20190324134106p:plain

前々回の記事はこちら
techblo.shikataramuno.com

今回のコード(React + Typescript版)はここです。
GitHub - Shikataramuno/responsive-scrollable-grid-react-ts: スクローラブルリストをReact + Typescriptでやってみる

Reactの開発環境

まずは ReactTypescript を使う環境の構築ですが、create-react-app がインストールされていればOKです。インストール方法は以前の記事を参考にしてください。

techblo.shikataramuno.com

Typescriptを使うプロジェクトの作成

create-react-app がインストールされていれば、次のコマンド一発で Typescript を使う React のプロジェクトをつくることができます。とっても便利ですね!

create-react-app project_name --typescript

コマンドを実行するとこんな具合にメッセージが表示されプロジェクト一式が自動生成されます。

PS C:\Shikataramuno_GitHub> create-react-app my-app --typescript

Creating a new React app in C:\Shikataramuno_GitHub\my-app.

(node:7204) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.
Installing packages. This might take a couple of minutes.
Installing react, react-dom, and react-scripts...

yarn add v1.3.2
(node:8676) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.
[1/4] Resolving packages...
warning react-scripts > fsevents@2.0.6: Please update: there are crash fixes
[2/4] Fetching packages...
info fsevents@2.0.6: The platform "win32" is incompatible with this module.
info "fsevents@2.0.6" is an optional dependency and failed compatibility check. Excluding it from installation.
info fsevents@1.2.7: The platform "win32" is incompatible with this module.
info "fsevents@1.2.7" is an optional dependency and failed compatibility check. Excluding it from installation.
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
warning Your current version of Yarn is out of date. The latest version is "1.16.0" while you're on "1.3.2".
info To upgrade, download the latest installer at "https://yarnpkg.com/latest.msi".
success Saved 224 new dependencies.
├─ @babel/generator@7.4.4
├─ @babel/helper-define-map@7.4.4
├─ @babel/helper-replace-supers@7.4.4
・・・・
<中略>
・・・・
├─ yargs-parser@11.1.1
└─ yargs@12.0.5
Done in 158.87s.
We detected TypeScript in your project (src\App.test.tsx) and created a tsconfig.json file for you.

Your tsconfig.json has been populated with default values.

Initialized a git repository.

Success! Created my-app at C:\Shikataramuno_GitHub\my-app
Inside that directory, you can run several commands:

  yarn start
    Starts the development server.

  yarn build
    Bundles the app into static files for production.

  yarn test
    Starts the test runner.

  yarn eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:

  cd my-app
  yarn start

Happy hacking!
PS C:\Shikataramuno_GitHub>

自動生成されるフォルダとファイルの構造はこんな感じです。

C:\SHIKATARAMUNO_GITHUB\MY-APP
│  .gitignore
│  package.json
│  README.md
│  tsconfig.json
│  yarn.lock
│
├─node_modules
│
├─public
│      favicon.ico
│      index.html
│      manifest.json
│
└─src
        App.css
        App.test.tsx
        App.tsx
        index.css
        index.tsx
        logo.svg
        react-app-env.d.ts
        serviceWorker.ts

React版の移植

前々回のプロジェクトから src 以下の MemberList.js と MemberList.css を src にコピーします。
コピーするコードは以下の内容です。

import React, { Component } from 'react';
import {Row, Col, Form, ProgressBar} from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import './MemberList.css';

export default class MemberList extends Component {

  columns = ['id', 'name', 'admin', 'address', 'progress'];
  memberList = [];
  constructor(props) {
    super(props);
    this.state = {
      members: [],
      sortOrders: {},
      sortKey: ""
    }
   }

  filter = (e) => {
    const filter = e.target.value;
    let members = this.memberList;
    members = members.filter((member) => {
      return Object.keys(member).some((key) => {
        return String(member[key]).indexOf(filter) > -1;
      });
    });
    this.setState({members: members})
  }
  sortBy = (name) => {
    let sortOrders = this.state.sortOrders
    sortOrders[name] = sortOrders[name] * -1;
    let members = this.state.members;
    members = members.slice().sort((a,b) => {
      a = a[name]
      b = b[name]
      return (a === b ? 0 : a > b ? 1 : -1) * sortOrders[name];
    });
    this.setState({sortKey: name});
    this.setState({sortOrders: sortOrders});
    this.setState({members: members})

  }
  handleAdminChanged = (member) => {
    console.log(member);
    let list = this.state.members;
    const target = list.find(rec => {
      return rec.id === member.id
    })
    target.admin = !target.admin;
    this.setState({members: list})
  }

  dhead = () => {
    return (
      <div className="pc table-row header">
      ・・・中略・・・
      </div>
    );   
  }
  dlist = () => {
    return (
      <div className="data-field">
      ・・・中略・・・
      </div>
    );
  }
  list = () => {
    return (
      <div className="container-fluid">
        <this.dhead />
        <this.dlist />
      </div>
    );
  }
  render () {
    return <this.list/>
  }
  componentDidMount () {
    let orders = {}
    this.columns.forEach((key) => {
      orders[key] = 1;
    })
    this.memberList = [
      {id: 1, name: 'aaaa', admin: true, progress: 10, address: 'aaaa@shikataramuno.com'},
      ・・・中略・・・
      {id: 26, name: 'zzzz', admin: true, progress: 80, address: 'zzzz@shikataramuno.com'}
    ];
    this.setState({sortOrders: orders}); 
    this.setState({members: this.memberList});
  }
}

次に、src以下にmodelフォルダを作成し、Memer.js sorOrders.js をコピーして ~.ts に拡張子を変更します。 ビルドしてみましょう。

次のようなエラーが発生すると思いますが、いかがですか?

C:/Shikataramuno-GitHub/my-app/src/MenberList.tsx
TypeScript error in C:/Shikataramuno-GitHub/my-app/src/MenberList.tsx(10,15):
Parameter 'props' implicitly has an 'any' type.  TS7006

     8 |   columns = ['id', 'name', 'admin', 'address', 'progress'];
     9 |   memberList = [];
  > 10 |   constructor(props) {
       |               ^
    11 |     // super(...arguments);
    12 |     super(props);
    13 |     this.state = {

これは MemberList のコンストラクタ仮引数の型が暗黙的に any (なんでもOK)になってるのが原因です。エラーが出て当然ですね!

Typescript を使う意義の一つは Javascript で静的型付け言語と同じ様な実装を可能にするとことにあります。すなわち、ビルド段階で静的に型チェックを行い、実行時エラーを極力なくするのが目的の一つです。 その意味では、このエラーが出ないとそれはそれで困る。っというわけです。

ですので、全ての変数宣言、仮引数宣言で型を明示する様にしてやればエラーは解消できます。
では、型を明示するとはどうすれば良いのでしょうか?

対象となる変数には2種類あります。一つは Typescript で定義されているプリミティブな変数、もう一つはクラスや配列を格納する変数です。

プリミティブ変数の型宣言

プリミティブな変数の型を明示するには、変数宣言の後ろに : 型名 を付けます。

具体的には
20行目のconst filter = …const filter: string = …
30行目のlet sortOrders = …let sortOrder: number = …
といった具合です。

オブジェクト変数の型宣言

オブジェクト変数の型を明示するには、2段階の定義が必要になります。
まず、クラスベースのオブジェクト指向言語における interface と同じ要領で interface でオブジェクトの型を宣言し、プリミティブ変数と同じ要領でオブジェクト変数の宣言時に型を明示します。

Memberオブジェクトを例に、まず src/model/Member.ts にて

interface MemberProps {
  id: number;
  name: string;
  address: string;
  admin: boolean;
  progress: number;
}

で 'interface' で型を宣言したうえで、src/MemerList.tsx で Member.tsx をインポートし

let member: Member = new Member(・・・

handleAdminChanged = (member: Member) => {

といった具合にオブジェクト変数の型を明示します。

Typescript対応版のコード

上記の要領で変数、仮引数宣言で型を明示したコードはこんな感じです。

import * as React from 'react';
import {Row, Col, Form, ProgressBar} from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import Member from './model/Member';
import SortOrders from './model/SortOrders';
import './MemberList.css';
import { FormControl } from 'react-bootstrap';

interface State {
  members: Member[],
  sortOrders: SortOrders,
  sortKey: string
}
interface Props {}
interface DHeadProps {
  sortKey: string;
  sortOrders: SortOrders;
  filter(event: React.FormEvent): void;
  sortBy(name: string): void; 
}
interface DListProps {
  members: Member[];
  handleAdminChanged(member: Member): void;
}
export default class MemberList extends React.Component<Props, State> {
  columns: string[] = ['id', 'name', 'admin', 'address', 'progress']
  memberList: Member[] = []
  constructor(props: Props) {
    super(props);
    this.state = {
      members: [],
      sortOrders: new SortOrders({id: 1, name: 1, admin: 1, progress: 1, address: 1}),
      sortKey: ""
    }
   }

  filter = (event: React.FormEvent) => {
    const filter: string = (event.target as HTMLInputElement).value;
    let members: Member[] = this.memberList;
    members = members.filter((member: Member) => {
      return member.isIncluded(filter)
    });
    this.setState({members: members})
  }
  sortBy = (name: string) => {
    let order: number = this.state.sortOrders.getOrder(name);
    console.log('order : ' + order);
    let members: Member[] = this.state.members;
    members = members.slice().sort((a: Member,b: Member) => {
      const aVal: string = a.getValue(this.state.sortKey);
      const bVal: string = b.getValue(this.state.sortKey);
      return (aVal === bVal ? 0 : aVal < bVal ? -1 : 1) * order;
    });
    this.state.sortOrders.selectKey(name);
    this.setState({sortKey: name});
    this.setState({members: members})
  }
  handleAdminChanged = (member: Member) => {
    console.log(member);
    let list: Member[] = this.state.members;
    const target: Member = list.find((rec) => {
      return rec.id === member.id
    })!
    target!.admin = !target!.admin;
    this.setState({members: list})
  }
  dhead = (props: DHeadProps) => {
    return (
      <div className="pc table-row header">
        <Row>
          <Form.Label className="title" >メンバ一覧 </Form.Label>
        </Row>
        <Row className='query-box'>
          <Col xs={2}>
            <Form.Control as="input" type="text" id="search" className="filter" placeholder="フィルタ文字列"
              onChange={(e: React.FormEvent) => props.filter(e)}/>
          </Col>
          <Col xs={10}></Col>
        </Row>
        <div className="wrapper attributes header">
          {
            this.columns.map((name: string, col: number) => {
              const className: string = props.sortKey === name ? "active " + name : name
              const arrow =
                props.sortKey === name ? 
                (props.sortOrders.getOrder(name) > 0? <span className={"arrow asc"}></span> : <span className="arrow dsc"></span>) :
                "";
              return (
                <div className={className} key={col}
                  onClick={() => props.sortBy(name, e)}>
                  {name}
                  {arrow}
                </div>
              )
            })
          }
        </div>
      </div>
    );   
  }
  dlist = (props: DListProps) => {
    return (
      <div className="data-field">
        {
          props.members.map((member: Member, row: number) => {
            return (
             <div className="table-row wrapper attributes data" key={row}>{
                this.columns.map((name: string, idx: number) => {
                  if(name === "admin") {
                    return (
                      <div className={name} key={idx}>
                        <Form.Check type="checkbox" checked={member.isAdmin()}
                          onChange={() => props.handleAdminChanged(member)} />
                      </div>
                    )
                  } else if(name === "progress") {
                    return (
                      <div className={name} key={idx}>
                        <ProgressBar variant="success" now={member.getProgress()} label={`${member.getValue(name)}%`}/>
                      </div>
                    )
                  } else {
                    return (
                      <div className={name} key={idx}>
                        {member.getValue(name)}
                      </div>
                    )
                  }
                })
              }</div>
            );
          })
        }
      </div>
    );
  }
  list = () => {
    return (
      <div className="container-fluid">
        <this.dhead 
          sortKey={this.state.sortKey}
          sortOrders={this.state.sortOrders}
          filter= {this.filter}
          sortBy= {this.sortBy}
        />
        <this.dlist
          members = {this.state.members}
          handleAdminChanged={this.handleAdminChanged}
        />
      </div>
    );
  }
  render () {
    return <this.list/>
  }
  componentDidMount () {
    let list: Member[] = [
      new Member({id: 1, name: 'aaaa', admin: true, progress: 10, address: 'aaaa@shikataramuno.com'}),
      ・・・中略・・・
      new Member({id: 26, name: 'zzzz', admin: true, progress: 80, address: 'zzzz@shikataramuno.com'})
    ];
    this.setState({members: list});
  }
}

変数の型宣言に加えて、2点ほどポイントがあります。

メソッドの型を指定

18,19,23行目で dheaddlist に渡す引数プロパティの型宣言に MemberListクラスのメソッドの型としてシグニチャを宣言しています。

メソッドのシグニチャメソッド名(仮引数名: 仮引数の型, ...): 戻り値の型で指定します。
この様に関数に対しても静的な型付けをする必要があるので、インターフェースを変更する機会が多いとコードの修正良も増えてしまうので、初期段階の設計が肝になりそうですね。

イベントの型

37行目でフィルタ文字列の入力イベントハンドラーである filter メソッドの引数にイベントオブジェクトを定義していて、79行目のdheadメソッドの JSXで呼び出しています。

ハンドラーで入力文字列を取得するには、イベントオブジェクトを経由し、event.target.value にアクセスします。そのため、イベントオブジェクトに適切な型を指定しておかなければなりません。

React ではブラウザ毎の違いをなくすため、ブラウザ間で共通のイベント型を定義しています(詳しくは以下を参照)。 ここでは 入力フォームのイベント型(React.FormEvent型)でイベントオブジェクトの型を定義しています。キャプチャするイベントに応じて適切に型を指定する必要があります。

SyntheticEvent – React

出来上がり!

src/model/Member.ts、sortOrder.ts にも型宣言のための同様の修正を加えます。
npm start でビルドとローカルテストサイトを立ち上げます。

如何ですか?エラーなくスクローラブルリストが表示され、ソートやフィルタの操作ができましたでしょうか?

謝辞

今回、ReactTypescript に挑戦してみました。

Vue よりも、型の扱いが厳格な印象をうけました。特にイベントハンドラprops など型を明示しないとビルドが通りません。 Typescriptのメリットを生かすことを考えると、厳格な型の指定は重要ですが、変更にともなう影響範囲(修正範囲)が広がる傾向があるように思いました。

次回は別の題材で更に Reactを深堀していみたいと思います。
長々と書きましたが、最後までお付き合いいただきありがとうございました。
ReactTypescript を始める際の参考になれば幸いです。ではでは…