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 の作成を行う.
{
"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 にこれらの機能名を列挙しておく.
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 の型の作成
interface ActionBase {
type: string;
}
export default ActionBase;
import ActionBase from './ActionBase'
interface CopyFileContents extends ActionBase {
fileName: string;
contents: string;
}
export default CopyFileContents;
import ActionBase from './ActionBase'
interface RejectFile extends ActionBase {
errorMessage: string;
}
export default RejectFile;
import ActionBase from './ActionBase'
interface ShowTextSummary extends ActionBase {
text: string;
}
export default ShowTextSummary;
action creaters の作成
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つとする.
- アップロードされた file名 or エラーメッセージ
- フォームのテキストエリアに表示するテキスト
- フォームのテキストエリアのテキストの要約統計量
つまり、summarize-text-data は、以下のような状態を持つとする.
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 することはできなかった.
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
import { combineReducers } from 'redux'
import copyFileAndShowSummary from './copyFileAndShowSummary'
const appReducers = combineReducers({
copyFileAndShowSummary
})
export default appReducers
HTML components の作成
必要な HTML components を考える.
- file をアップロードするコンポネント
- file 内容を表示するコンポネント(onChange 機能あり)
- テキストの要約統計量を表示するコンポネント
1. file をアップロードするコンポネント作成
Presentational components の作成
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
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
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
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
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
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 の作成.
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 の作成
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')
)
<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 をコンパイルするタスク
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 ファイルをまとめるタスク
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('.'));
});
ブラウザで表示するタスク
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 の作成
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