読者です 読者をやめる 読者になる 読者になる

かずきのBlog@hatena

日本マイクロソフトに勤めています。XAML + C#の組み合わせをメインに、たまにASP.NETやJavaなどの.NET系以外のことも書いています。掲載内容は個人の見解であり、所属する企業を代表するものではありません。

Visual Studio上のTypeScript JSXを使ってReact.js「ReactのチュートリアルをTypeScriptでリライト」

なんとなくReactのチュートリアルをTypeScript JSXでこなせるような気がしてきたので、やってみたいと思います。

facebook.github.io

プロジェクトの初期設定とかは以下の記事を参照してください。

blog.okazuki.jp

最初のコンポーネント

最初はCommentBoxという名前でHello worldをしてるような感じなのでさくっといきましょう。

/// <reference path="typings/tsd.d.ts" />

class CommentBox extends React.Component<any, any> {
    render() {
        return <div className="commentBox">
            Hello, world!I am a CommentBox.
            </div>;
    }
}

ReactDOM.render(
    <CommentBox />,
    document.getElementById("content"));

コンポーネントの組み立て

次は、コンポーネントを複数作って組み合わせるフェーズです。各コンポーネントの中身は、まだHello worldです。

/// <reference path="typings/tsd.d.ts" />

class CommentList extends React.Component<any, any> {
    render() {
        return <div className="commentList">
                Hello, world! I am a CommentList.
            </div>;
    }
}

class CommentForm extends React.Component<any, any> {
    render() {
        return <div className="commentForm">
                Hello, world! I am a CommentForm.
            </div>;
    }
}

class CommentBox extends React.Component<any, any> {
    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList />
                <CommentForm />
            </div>;
    }
}

ReactDOM.render(
    <CommentBox />,
    document.getElementById("content"));

Props を使う

Commentを定義してpropsの使い方を示している箇所です。ただ、Commentっていうクラスを定義すると重複してる(何と?)と怒られたのでCommentItemという名前のクラスにしました。そこだけオリジナルチュートリアルと違います。

interface CommentItemProps extends React.Props<any> {
    author: string;
}

class CommentItem extends React.Component<CommentItemProps, any> {
    render() {
        return <div className="commentItem">
                <h2 className="commentAuthor">{this.props.author}</h2>
                {this.props.children}
            </div>;
    }
}

コンポーネントのプロパティ

先ほど定義したCommentItemをCommentListに埋め込んでいます。

class CommentList extends React.Component<any, any> {
    render() {
        return <div className="commentList">
                <CommentItem author="Pete Hunt">This is one comment</CommentItem>
                <CommentItem author="Jordan Walke">This is *another* comment</CommentItem>
            </div>;
    }
}

Markdown の追加

markedというライブラリを使うみたいなので、パッケージマネージャーコンソールに以下のコマンドを打ち込んでJSのファイルとd.ts(あってよかった)インストールして、ソリューションエクスプローラですべてのファイルを表示するにしてから、プロジェクトに取り込みます。

PM> tsd install marked -save
PM> bower install marked

index.htmlにmarkedのjsを読み込ませます。

<!DOCTYPE html>

<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>React tutorial</title>
</head>
<body>
    <div id="content"></div>
    <script src="bower_components/jquery/dist/jquery.min.js"></script>
    <script src="bower_components/react/react.js"></script>
    <script src="bower_components/react/react-dom.min.js"></script>
    <script src="bower_components/marked/marked.min.js"></script>
    <script src="app.js"></script>
</body>
</html>

CommentItemをmarkedを使って書き直します。

class CommentItem extends React.Component<CommentItemProps, any> {
    render() {
        return <div className="commentItem">
                <h2 className="commentAuthor">{this.props.author}</h2>
                {marked(this.props.children.toString())}
            </div>;
    }
}

この状態では、サニタイズされてHTMLのタグが、そのままブラウザ上に表示されてしまうため、サニタイズしないように変更します。

class CommentItem extends React.Component<CommentItemProps, any> {
    render() {
        var rawMarkup = marked(this.props.children.toString());
        return <div className="commentItem">
                <h2 className="commentAuthor">{this.props.author}</h2>
                <span dangerouslySetInnerHTML={{ __html: rawMarkup }}></span>
            </div>;
    }
}

データモデルとの連携

まずは、データを用意します。インターフェースでタイプセーフに定義できるようにしてみましょう。

interface Data {
    author: string;
    text: string;
}

var data: Data[] = [
    { author: "Pete Hunt", text: "This is one comment" },
    { author: "Jordan Wakle", text: "This is *another* comment" }
];

そして、CommentBoxとCommentListがdataというプロパティを受け取るようになったので、変更します。

/// <reference path="typings/tsd.d.ts" />

interface Data {
    author: string;
    text: string;
}

var data: Data[] = [
    { author: "Pete Hunt", text: "This is one comment" },
    { author: "Jordan Wakle", text: "This is *another* comment" }
];


interface CommentItemProps extends React.Props<any> {
    author: string;
}

class CommentItem extends React.Component<CommentItemProps, any> {
    render() {
        var rawMarkup = marked(this.props.children.toString());
        return <div className="commentItem">
                <h2 className="commentAuthor">{this.props.author}</h2>
                <span dangerouslySetInnerHTML={{ __html: rawMarkup }}></span>
            </div>;
    }
}

interface CommentListProps extends React.Props<any> {
    data: Data[];
}

class CommentList extends React.Component<CommentListProps, any> {
    render() {
        return <div className="commentList">
                <CommentItem author="Pete Hunt">This is one comment</CommentItem>
                <CommentItem author="Jordan Walke">This is *another* comment</CommentItem>
            </div>;
    }
}

class CommentForm extends React.Component<any, any> {
    render() {
        return <div className="commentForm">
                Hello, world! I am a CommentForm.
            </div>;
    }
}

interface CommentBoxProps extends React.Props<any> {
    data: Data[];
}

class CommentBox extends React.Component<CommentBoxProps, any> {
    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.props.data} />
                <CommentForm />
            </div>;
    }
}

ReactDOM.render(
    <CommentBox data={data} />,
    document.getElementById("content"));

そして、データを使って動的にレンダリングするようにします。

class CommentList extends React.Component<CommentListProps, any> {
    render() {
        var commentNodes = this.props.data.map(x => <CommentItem author={x.author}>{x.text}</CommentItem>);
        return <div className="commentList">
                {commentNodes}
            </div>;
    }
}

サーバからのデータの取得

CommentBoxのプロパティをurlにしてしまいます。

interface CommentBoxProps extends React.Props<any> {
    url: string;
}

class CommentBox extends React.Component<CommentBoxProps, any> {
    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.props.data} />
                <CommentForm />
            </div>;
    }
}

ReactDOM.render(
    <CommentBox url="api/comments.json" />,
    document.getElementById("content"));

この状態では、まだCommentBoxにコンパイルエラーがあります。なおしていきましょう。

Reactive state

CommentBoxにStateを定義します。インターフェースを新たに切って型引数に指定します。 そしてコンストラクタでStateの初期値を指定します。

interface CommentBoxProps extends React.Props<any> {
    url: string;
}

interface CommentBoxState {
    data: Data[];
}

class CommentBox extends React.Component<CommentBoxProps, CommentBoxState> {
    constructor(props: CommentBoxProps) {
        super(props);
        this.state = { data: [] };
    }

    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm />
            </div>;
    }
}

そして、データをサーバーから読み込むようにします。

interface CommentBoxProps extends React.Props<any> {
    url: string;
}

interface CommentBoxState {
    data: Data[];
}

class CommentBox extends React.Component<CommentBoxProps, CommentBoxState> {
    constructor(props: CommentBoxProps) {
        super(props);
        this.state = { data: [] };
    }

    componentDidMount() {
        $.ajax({
            url: this.props.url,
            dataType: "json",
            cache: false,
            success: (data => this.setState({ data: data } as CommentBoxState)).bind(this),
            error: ((xhr, status, err) => console.error(this.props.url, status, err.toString())).bind(this)
        });
    }

    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm />
            </div>;
    }
}

続けて、コメントを2秒間隔で読み込む変更を加えます。

interface CommentBoxProps extends React.Props<any> {
    url: string;
    poolInterval: number;
}

interface CommentBoxState {
    data: Data[];
}

class CommentBox extends React.Component<CommentBoxProps, CommentBoxState> {
    constructor(props: CommentBoxProps) {
        super(props);
        this.state = { data: [] };
    }

    private loadCommentsFromServer() {
        $.ajax({
            url: this.props.url,
            dataType: "json",
            cache: false,
            success: (data => this.setState({ data: data } as CommentBoxState)).bind(this),
            error: ((xhr, status, err) => console.error(this.props.url, status, err.toString())).bind(this)
        });
   }

    componentDidMount() {
        this.loadCommentsFromServer();
        setInterval(this.loadCommentsFromServer.bind(this), this.props.poolInterval);
    }

    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm />
            </div>;
    }
}

ReactDOM.render(
    <CommentBox url="api/comments.json" poolInterval={2000} />,
    document.getElementById("content"));

setIntervalの第一引数のメソッドを渡すときはbindでthisを固定してあげないとエラーになります。 これで、サーバーのデータを更新すると画面が2秒以内に更新されるようになりました。

新しいコメントの追加

refsの書き方が直感的でないことを除けば普通のTypeScriptでいけます。

class CommentForm extends React.Component<any, any> {
    private handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        var author = (ReactDOM.findDOMNode(this.refs["author"]) as HTMLInputElement).value.trim();
        var text = (ReactDOM.findDOMNode(this.refs["text"]) as HTMLInputElement).value.trim();

        if (!text || !author) {
            return;
        }

        // TODO : サーバーにリクエストを送信
        (ReactDOM.findDOMNode(this.refs["author"]) as HTMLInputElement).value = "";
        (ReactDOM.findDOMNode(this.refs["text"]) as HTMLInputElement).value = "";
        return;
    }

    render() {
        return <form className="commentForm" onSubmit={this.handleSubmit.bind(this)}>
                <input type="text" placeholder="your name" ref="author" />
                <input type="text" placeholder="Say something..." ref="text" />
                <input type="submit" value="Post" />
            </form>;
    }
}

Props としてのコールバック

CommentFormのPropsにコールバックを定義して、それをhandleSubmitで呼び出します。

interface CommentFormProps extends React.Props<any> {
    onCommentSubmit: (data: Data) => void;
}

class CommentForm extends React.Component<CommentFormProps, any> {
    private handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        var author = (ReactDOM.findDOMNode(this.refs["author"]) as HTMLInputElement).value.trim();
        var text = (ReactDOM.findDOMNode(this.refs["text"]) as HTMLInputElement).value.trim();

        if (!text || !author) {
            return;
        }

        this.props.onCommentSubmit({ author: author, text: text } as Data);
        (ReactDOM.findDOMNode(this.refs["author"]) as HTMLInputElement).value = "";
        (ReactDOM.findDOMNode(this.refs["text"]) as HTMLInputElement).value = "";
        return;
    }

    render() {
        return <form className="commentForm" onSubmit={this.handleSubmit.bind(this) }>
                <input type="text" placeholder="your name" ref="author" />
                <input type="text" placeholder="Say something..." ref="text" />
                <input type="submit" value="Post" />
            </form>;
    }
}

そして、CommentBoxで定義しているCommentFormに対してonCommentSubmitを指定します。

class CommentBox extends React.Component<CommentBoxProps, CommentBoxState> {
    constructor(props: CommentBoxProps) {
        super(props);
        this.state = { data: [] };
    }

    private loadCommentsFromServer() {
        $.ajax({
            url: this.props.url,
            dataType: "json",
            cache: false,
            success: (data => this.setState({ data: data } as CommentBoxState)).bind(this),
            error: ((xhr, status, err) => console.error(this.props.url, status, err.toString())).bind(this)
        });
    }

    private handleCommentSubmit(comment: Data) {
        // TODO: サーバーに送信、リストをリフレッシュ
    }

    componentDidMount() {
        this.loadCommentsFromServer();
        setInterval(this.loadCommentsFromServer.bind(this), this.props.poolInterval);
    }

    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm onCommentSubmit={this.handleCommentSubmit.bind(this)} />
            </div>;
    }
}

Web APIをたたいてコメントを登録するようにします…が、コメント投稿APIがないのでエラーになってしまいます。

class CommentBox extends React.Component<CommentBoxProps, CommentBoxState> {
    constructor(props: CommentBoxProps) {
        super(props);
        this.state = { data: [] };
    }

    private loadCommentsFromServer() {
        $.ajax({
            url: this.props.url,
            dataType: "json",
            cache: false,
            success: (data => this.setState({ data: data } as CommentBoxState)).bind(this),
            error: ((xhr, status, err) => console.error(this.props.url, status, err.toString())).bind(this)
        });
    }

    private handleCommentSubmit(comment: Data) {
        $.ajax({
            url: this.props.url,
            dataType: 'json',
            type: 'POST',
            data: JSON.stringify(comment),
            success: (data => this.setState({ data: data } as CommentBoxState)).bind(this),
            error: ((xhr, status, err) => console.error(this.props.url, status, err.toString())).bind(this)
        });
    }

    componentDidMount() {
        this.loadCommentsFromServer();
        setInterval(this.loadCommentsFromServer.bind(this), this.props.poolInterval);
    }

    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm onCommentSubmit={this.handleCommentSubmit.bind(this)} />
            </div>;
    }
}

最適化: 先読み更新

残るは先読みですが、ここからは動作検証できてないので間違ってるかもしれません。

class CommentBox extends React.Component<CommentBoxProps, CommentBoxState> {
    constructor(props: CommentBoxProps) {
        super(props);
        this.state = { data: [] };
    }

    private loadCommentsFromServer() {
        $.ajax({
            url: this.props.url,
            dataType: "json",
            cache: false,
            success: (data => this.setState({ data: data } as CommentBoxState)).bind(this),
            error: ((xhr, status, err) => console.error(this.props.url, status, err.toString())).bind(this)
        });
    }

    private handleCommentSubmit(comment: Data) {
        var comments = this.state.data;
        var newComments = comments.concat([comment]);
        this.setState({ data: newComments } as CommentBoxState);
        $.ajax({
            url: this.props.url,
            dataType: 'json',
            type: 'POST',
            data: JSON.stringify(comment),
            success: (data => this.setState({ data: data } as CommentBoxState)).bind(this),
            error: ((xhr, status, err) => {
                this.setState({ data: comments } as CommentBoxState);
                console.error(this.props.url, status, err.toString());
            }).bind(this)
        });
    }

    componentDidMount() {
        this.loadCommentsFromServer();
        setInterval(this.loadCommentsFromServer.bind(this), this.props.poolInterval);
    }

    render() {
        return <div className="commentBox">
                <h1>Comments</h1>
                <CommentList data={this.state.data} />
                <CommentForm onCommentSubmit={this.handleCommentSubmit.bind(this)} />
            </div>;
    }
}

これで一通りTypeScriptでReactのチュートリアルのコードを書き直したことになります。最後、動作確認できないのがつらいところですが、まぁ雰囲気がつかめたのでよしとしましょう。

それにしてもrefsの書き心地だけが果てしなく悪い…。

リポジトリ

一応GitHubに公開しておきます

github.com