LoginSignup
5
6

More than 5 years have passed since last update.

Typescript, React, Redux で文字数などをカウントするwebアプリを作成

Last updated at Posted at 2016-06-25

Typescriptで文字数などをカウントするアプリを作成した際のメモ書き

(2016.06.25 OS X El Capitan)

作成するアプリ

テキストファイルをアップロードすると or 直接フォームのテキストエリアに何かテキストを入力すると、
テキストの要約統計量(文字数など)がリアルタイムで表示されるアプリを作成することを目的とする.

完成したアプリ: summarize-text-data
Material UI で少しオシャレにした: summarize-text-data-appbar

Node.js, npm パッケージ

Node.js, npm パッケージのインストール

node や typescript やプロジェクト管理用のパッケージがインストールされていない場合、
必要なパッケージのグローバルへのインストールを行う.

brew install node
npm i -g typescript typings gulp webpack

なお、グローバルな npm パッケージをすべて削除するには、以下のようにする
(http://stackoverflow.com/questions/9283472/command-to-remove-all-npm-modules-globally より).

npm ls -gp --depth=0 | awk -F/ '/node_modules/ && !/\/npm$/ {print $NF}' | xargs npm -g rm

Node.js, npm のアップデートとバージョン確認

以下のコマンドで Node.js, npm パッケージのアップデートができる.

brew update
brew upgrade
npm update -g

node のバージョン確認方法.

node -v
v6.2.2

npm でグローバルにインストールしたパッケージのバージョン確認方法.

npm ls -g --depth=0
/usr/local/lib
├── gulp@3.9.1
├── npm@3.10.2
├── typescript@1.8.10
├── typings@1.3.0
└── webpack@1.13.1

summarize-text-data プロジェクト

以下をターミナルで入力してプロジェクトのディレクトリ構造を作成.

mkdir summarize-text-data
cd summarize-text-data
mkdir src
mkdir src/actions
mkdir src/models
mkdir src/reducers
mkdir src/components
mkdir src/containers
mkdir gulp
mkdir gulp/tasks

npm init と入力して、Enter を連打(すべてデフォルトのまま).

アプリの作成には、React, Redux を使用する. ファイルアップロードには、react-dropzone を使用.
必要なモジュールと型定義ファイルのインストールを行う.

npm i react react-dom redux react-redux es6-shim react-dropzone --save-dev
typings i dt~react --save --global
typings i dt~react-dom --save --global
typings i dt~redux --save --global
typings i dt~react-redux --save --global
typings i dt~es6-shim --save --global
typings i dt~react-dropzone --save --global

プロジェクトのタスク管理を行うためのモジュールをインストール.

npm i gulp gulp-typescript gulp-plumber gulp-notify require-dir merge2 run-sequence webpack-stream browser-sync --save-dev

Atom エディタ で、プロジェクトルート(summarize-text-data/)直下に tsconfig.json の作成を行う.

tsconfig.json
{
    "compilerOptions": {
        "target": "es5",
        "sourceMap": true,
        "declaration": true,
        "module": "commonjs",
        "jsx": "react",
        "noImplicitAny": false,
        "noEmitOnError": true,
        "removeComments": true,
        "experimentalDecorators": true
    },
    "compileOnSave": false,
    "filesGlob": [
        "./typings/index.d.ts",
        "./src/*.tsx",
        "./src/**/*.ts",
        "./src/**/*.tsx"
    ],
    "files": [
    ],
    "atom": {
        "rewriteTsconfig": true
    }
}

Action の作成

このアプリで必要になる機能を列挙.

  • アップロードされたファイル内容をフォームのテキストエリアにコピーする機能
  • アップロードされたファイルがテキストファイルでなかった場合、エラーメッセージを表示する機能
  • フォームのテキストエリアの内容の要約統計量を計算して表示する機能

src/actions/types.ts にこれらの機能名を列挙しておく.

src/actions/types.ts
const COPY_FILE_CONTENTS = 'COPY_FILE_CONTENTS';
const REJECT_FILE = 'REJECT_FILE';
const SHOW_TEXT_SUMMARY = 'SHOW_TEXT_SUMMARY';

export default {
    COPY_FILE_CONTENTS,
    REJECT_FILE,
    SHOW_TEXT_SUMMARY
}

次に、action の型を決めて、action creater を作成する.
以下のような action creater を作成する.

  • copyFileContents は、アップロードされたファイル名とその内容を引数に取り、CopyFileContentsAction 型の action を生成.
  • rejectFile は、ファイルに関するエラーメッセージを引数に取り、rejectFileAction型の action を生成.
  • showTextSummary は、フォームのテキストエリアのテキストを引数に取り、showTextSummaryAction型の action を生成.

action の型の作成

src/models/ActionBase.ts
interface ActionBase {
    type: string;
}

export default ActionBase;
src/models/CopyFileContentsAction.ts
import ActionBase from './ActionBase'

interface CopyFileContents extends ActionBase {
    fileName: string;
    contents: string;
}

export default CopyFileContents;
src/models/RejectFileAction.ts
import ActionBase from './ActionBase'

interface RejectFile extends ActionBase {
    errorMessage: string;
}

export default RejectFile;
src/models/ShowTextSummaryAction.ts
import ActionBase from './ActionBase'

interface ShowTextSummary extends ActionBase {
    text: string;
}

export default ShowTextSummary;

action creaters の作成

src/actions/creaters.ts
import CopyFileContents from '../models/CopyFileContentsAction';
import RejectFile from '../models/RejectFileAction';
import ShowTextSummary from '../models/ShowTextSummaryAction';
import types from './types';

const copyFileContents = (fileName: string, contents: string): CopyFileContents => {
    return {
        type: types.COPY_FILE_CONTENTS,
        fileName,
        contents
    };
}

const rejectFile = (errorMessage: string): RejectFile => {
    return {
        type: types.REJECT_FILE,
        errorMessage
    }
}

const showTextSummary = (text: string): ShowTextSummary => {
    return {
        type: types.SHOW_TEXT_SUMMARY,
        text
    }
}

export default {
    copyFileContents,
    rejectFile,
    showTextSummary
}

Reducer の作成

状態を設計してから、アクションが状態をどう変化させるのかを Reducer に記述する.

状態の設計

アプリが持つ状態のプロパティは、以下の3つとする.

  1. アップロードされた file名 or エラーメッセージ
  2. フォームのテキストエリアに表示するテキスト
  3. フォームのテキストエリアのテキストの要約統計量

つまり、summarize-text-data は、以下のような状態を持つとする.

src/models/AppState.ts
export default class AppState {
    fileNameOrErrorMessage: string;
    text: string;
    summary: {
        character_count: number,
        character_and_space_count: number,
        word_count: number, 
        line_number: number
    }
}

action がどう状態を遷移させるのかを reducer に記述

  • copyFileContents と状態の関係から

ファイルがアップロードされると、ファイル名を表示して、内容をフォームのテキストエリアにコピーする処理が必要なので、
「アップロードされたファイルの情報を読み取って、fileNameOrErrorMessage、text を更新する処理」.

  • rejectFile と状態の関係から

ファイルのエラーメッセージを受け取り、表示する処理が必要なので、
「ファイルのエラーメッセージを受け取り、fileNameOrErrorMessage を更新する処理」.

  • showFormSummary と状態の関係から

フォームのテキストエリアの内容が変化すると、その要約統計量を計算して表示する処理が必要なので、
「テキストエリアの内容の変化に応じて、text、summary を更新する処理」.

上記の処理ができるように reducer を作成.
残念ながら AppState からプロパティを reduce することはできなかった.

src/reducers/copyFileAndShowSummary.ts
import AppState from '../models/AppState'
import CopyFileContents from '../models/CopyFileContentsAction';
import RejectFile from '../models/RejectFileAction';
import ShowTextSummary from '../models/ShowTextSummaryAction';
import types from '../actions/types';
import * as ES6 from 'es6-shim';

let initialState: AppState = {
    fileNameOrErrorMessage: 'No file selected',
    text: '',
    summary: {
        character_count: 0,
        character_and_space_count: 0,
        word_count: 0, 
        line_number: 0
    }
}

const copyFileAndShowSummary = (state: AppState = initialState, action: any) => {
    switch (action.type) {

        case types.COPY_FILE_CONTENTS:
            let copy_action: CopyFileContents = action;
            return ES6.Object.assign({}, state, {
                fileNameOrErrorMessage: copy_action.fileName,
                text: copy_action.contents
            })

        case types.REJECT_FILE:
            let reject_action: RejectFile = action;
            return ES6.Object.assign({}, state, {
                fileNameOrErrorMessage: reject_action.errorMessage
            })

        case types.SHOW_TEXT_SUMMARY:
            let summarize_action: ShowTextSummary = action;
            let line_number = summarize_action.text.split('\n').length;
            if (summarize_action.text === "") {
                line_number = 0
            }
            let char_and_space_count = summarize_action.text.length;
            // convert [\r\n\t\s]+ to spaces
            let text = summarize_action.text.replace(/[\r\n\t\s]+/g, " ").trim();
            let word_count = text.split(" ").length;
            if (text === "") {
                word_count = 0
            }
            // remove spaces
            text = text.replace(/ /g, "");
            let char_count = text.length;
            return ES6.Object.assign({}, state, {
                text: summarize_action.text,
                summary: {
                    character_count: char_count,
                    character_and_space_count: char_and_space_count,
                    word_count: word_count,
                    line_number: line_number
                }
            })

        default:
            return state

    }
}

export default copyFileAndShowSummary
src/reducers/appReducers.ts
import { combineReducers } from 'redux'
import copyFileAndShowSummary from './copyFileAndShowSummary'

const appReducers = combineReducers({
  copyFileAndShowSummary
})

export default appReducers

HTML components の作成

必要な HTML components を考える.

  1. file をアップロードするコンポネント
  2. file 内容を表示するコンポネント(onChange 機能あり)
  3. テキストの要約統計量を表示するコンポネント

1. file をアップロードするコンポネント作成

Presentational components の作成

src/components/FileDropzone.tsx
import * as React from 'react'
import * as Dropzone from 'react-dropzone';

export interface FileDropzoneProps extends React.Props<any> {
    fileNameOrErrorMessage: string,
    copyFileContentsProp: any,
    rejectFileProp: any,
    showTextSummaryProp: any
}

export default class FileDropzone extends React.Component<FileDropzoneProps, any> {

    private onDropAccepted = (files) => {
        let reader = new FileReader();
        reader.onload = (e) => {
            let fileName = files[0].name;
            let contents = reader.result;
            this.props.copyFileContentsProp(fileName, contents);
            this.props.showTextSummaryProp(contents);
        }
        reader.readAsText(files[0]);
    };

    private onDropRejected = () => {
        let errorMessage = 'This file is not valid.';
        this.props.rejectFileProp(errorMessage);
    };

    render () {
        return (
            <div>
                <Dropzone 
                    onDropAccepted = {this.onDropAccepted}
                    onDropRejected = {this.onDropRejected} 
                    accept = "text/plain" 
                    multiple = {false}
                >
                    <div>Try dropping a text file here, or click to select a file to upload.</div>
                </Dropzone>
                <div>
                    <h1>{this.props.fileNameOrErrorMessage}</h1>
                </div>
            </div>
        );
    }
}

react-dropzone では、onDropAccepted = {this.onDropAccepted} とすると、
this.onDropAccepted 関数の引数 files に、自動的にアップロードされたファイルのリストが格納されるようだ.

ハマったところは、java のように以下のような感じで関数を作成すると、
this.props.rejectFileProp が undefined となっていた.

private onDropRejected () {
    this.props.rejectFileProp(errorMessage);
};

おそらく以下のようなことが原因だろう. ここでは、アロー関数を使用することで解決した.
【TypeScript】thisの使い方にハマった!thisを保持する3つの方法

Container components

src/containers/FileDropzoneContainer.ts
import { connect } from 'react-redux';
import creaters from '../actions/creaters';
import FileDropzone from '../components/FileDropzone';
import AppState from '../models/AppState';

const mapStateToProps = (state) => {
    let this_state: AppState = state.copyFileAndShowSummary;
    return {
        fileNameOrErrorMessage: this_state.fileNameOrErrorMessage
    }
}

const mapDispatchToProps = (dispatch) => {
    return {
        copyFileContentsProp: (fileName, contents) => {
            dispatch(creaters.copyFileContents(fileName, contents))
        },
        rejectFileProp: (errorMessage) => {
            dispatch(creaters.rejectFile(errorMessage))
        },
        showTextSummaryProp: (contents) => {
            dispatch(creaters.showTextSummary(contents))
        },
    }
}

const FileDropzoneContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(FileDropzone)

export default FileDropzoneContainer

テキストファイルの内容を表示するフォームの作成

Presentational components

src/components/TextArea.tsx
import * as React from 'react'

export interface TextAreaProps extends React.Props<any> {
    showTextSummaryProp: any,
    text: string
}

export default class TextArea extends React.Component<TextAreaProps, any> {
    private showTextSummary = (e) => {
        this.props.showTextSummaryProp(e.target.value)
    };

    render () {
        return (
            <div>
                <textarea type="text" onChange={this.showTextSummary} value={this.props.text} />
            </div>
        );
    }
}

Container components

src/containers/TextAreaContainer.ts
import { connect } from 'react-redux';
import creaters from '../actions/creaters';
import TextArea from '../components/TextArea';
import AppState from '../models/AppState';

const mapStateToProps = (state) => {
    let this_state: AppState = state.copyFileAndShowSummary;
    return {
        text: this_state.text
    }
}

const mapDispatchToProps = (dispatch) => {
    return {
        showTextSummaryProp: (text) => {
            dispatch(creaters.showTextSummary(text))
        }
    }
}

const TextAreaContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(TextArea)

export default TextAreaContainer

要約統計量を表示するコンポネントの作成

Presentational components

src/components/Summary.tsx
import * as React from 'react'

export interface SummaryProps extends React.Props<any> {
    character_count: number,
    character_and_space_count: number,
    word_count: number,
    line_number: number
}

export default class Summary extends React.Component<SummaryProps, any> {

    render () {
        return (
            <div>
                <li>character count: {this.props.character_count}</li>
                <li>character and space count: {this.props.character_and_space_count}</li>
                <li>word count: {this.props.word_count}</li>
                <li>line number: {this.props.line_number}</li>
            </div>
        );
    }
}

Container components

src/containers/SummaryContainer.ts
import { connect } from 'react-redux';
import Summary from '../components/Summary';
import AppState from '../models/AppState';

const mapStateToProps = (state) => {
    let this_state: AppState = state.copyFileAndShowSummary;
    return {
        character_count: this_state.summary.character_count,
        character_and_space_count: this_state.summary.character_and_space_count,
        word_count: this_state.summary.word_count,
        line_number: this_state.summary.line_number,
    }
}

const mapDispatchToProps = (dispatch) => {
    return {
    }
}

const SummaryContainer = connect(
    mapStateToProps,
    mapDispatchToProps
)(Summary)

export default SummaryContainer

App.tsx の作成

コンポネントをまとめる App.tsx の作成.

src/components/App.tsx
import * as React from 'react';
import FileDropzoneContainer from '../containers/FileDropzoneContainer';
import TextAreaContainer from '../containers/TextAreaContainer';
import SummaryContainer from '../containers/SummaryContainer';

const App = () => (
  <div>
    <FileDropzoneContainer />
    <TextAreaContainer />
    <SummaryContainer />
  </div>
)

export default App

index.tsx, index.html の作成

src/index.tsx
import * as React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import appReducer from './reducers/appReducers';
import App from './components/App';

let store = createStore(appReducer)

render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root')
)
index.html
<html>
<head>
    <title>Summarize text Data</title>
</head>
<body>
    <h1>Summarize text Data</h1>
    <div id="root"></div>
    <script src="build.js"></script>
</body>
</html>

タスクを管理するファイルの作成

typescript をコンパイルするタスク

gulp/tasks/scripts.js
var gulp = require('gulp');
var ts = require('gulp-typescript');
var plumber = require('gulp-plumber');
var notify = require('gulp-notify');
var merge = require('merge2');

var tsProject = ts.createProject('tsconfig.json');
gulp.task('scripts', function() {
    var tsResult = tsProject.src()
        .pipe(plumber({errorHandler: notify.onError('<%= error.message %>')}))
        .pipe(ts(tsProject));

    return merge([
        tsResult.js
            .pipe(gulp.dest('dist/js')),
        tsResult.dts
            .pipe(gulp.dest('dist/dts'))
    ]);
});

コンパイルされた javascript ファイルをまとめるタスク

gulp/tasks/webpack.js
var gulp = require('gulp');
var webpack = require('webpack-stream');

gulp.task('webpack', function() {
    return gulp.src('dist/js/src/index.js')
        .pipe(webpack({
            output: {
                filename: 'build.js',
            },
            devtool: 'inline-source-map'
        }))
        .pipe(gulp.dest('.'));
});

ブラウザで表示するタスク

gulp/tasks/browser.js
var gulp = require('gulp');
var browserSync = require('browser-sync');

gulp.task('browser', function() {
    browserSync.init({
        server: '.',
        open: 'external'
    });
});

gulp.task('browser-reload', function () {
    browserSync.reload();
});

gulpfile.js の作成

gulpfile.js
var gulp = require('gulp');
var runSequence = require('run-sequence');

require('require-dir')('./gulp/tasks', {recurse: true});

gulp.task('build', function() {
    runSequence('scripts', 'webpack')
});

gulp.task('server', function() {
    runSequence('build', 'browser')
});

gulp.task('default', ['server'], function() {
    gulp.watch('src/**/*', ['build']);
    gulp.watch('*.html', ['browser-reload']);
    gulp.watch('*.js', ['browser-reload']);
});

デフォルトタスクの実行

以下を実行しておくことで、ファイルに変更があると自動で更新が行われるはず.

gulp
5
6
0

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
5
6