素のnodeでES6の多くが動く

ECMAScript 2015 (ES6) | Node.js

今、BabelでES6とJSXをトランスパイルしてますが、そのBabelを起動するGulp(gulpfile.js)についてはES5で書いてました。しかしすでにnode v5.xではV8エンジンの対応状況が進んできているためにES6の仕様の多くが動いちゃうんですね。

$ node --v8-options | grep "in progress"
  --harmony_modules (enable "harmony modules" (in progress))
  --harmony_regexps (enable "harmony regular expression extensions" (in progress))
  --harmony_proxies (enable "harmony proxies" (in progress))
  --harmony_sloppy_function (enable "harmony sloppy function block scoping" (in progress))
  --harmony_sloppy_let (enable "harmony let in sloppy mode" (in progress))
  --harmony_unicode_regexps (enable "harmony unicode regexps" (in progress))
  --harmony_reflect (enable "harmony Reflect API" (in progress))
  --harmony_destructuring (enable "harmony destructuring" (in progress))

ただ、modulesがまだダメだった。import/exportはまだin progress。でもlet/constやArrow Functionはレディなのでモジュールシステムだけrequireにしておけば他は大体ES6に書けました。ここでは出てこないけどES6-classもすでに使えるわけね。

'use strict';

const gulp = require('gulp');
const jasmine = require('gulp-jasmine');
const reporter = require('jasmine-terminal-reporter');
const jsdom = require('jsdom').jsdom;
require('babel-register');

gulp.task('test', () => {
    global.document = jsdom('');
    global.window = global.document.defaultView;
    for (let key in global.window) {
        if (global.window.hasOwnProperty(key) && !global[key]) {
            global[key] = global.window[key];
        }
    }
    gulp.src('src/**/__tests__/*.js')
        .pipe(jasmine({reporter: new reporter()}));
});

そもそもObject.assign()とかES6とは知らずに使ってた。

Reactコンポーネント内からkeyが取れない仕様だった

http://facebook.github.io/react/blog/2014/10/16/react-v0.12-rc1.html#breaking-change-key-and-ref-removed-from-this.props

すでに0.12での仕様変更でしたから、私がReactに触れてからはずっとそうなってたのですが、自分で書いたコードの謎バグに悩まされて初めて知りました。this.props.ref及びthis.props.keyはコンポーネント内から参照できない。。。常にundefined。

gulp-jasmineでES6で書いたReactコンポーネントとReactステートレスコンポネントさらにはExpressルーターを同時にテストできるようにする

一通りハマって模索した結果、それぞれしっかり原因判明やりきって解決するまでの質ではないのですが、ES6 で書いたReactコンポーネントとステートレスコンポーネントをテストするとともに、ES6で書いたExpressルーターもまとめてテストできるプロジェクト設定・構成が一つ用意できました。

まず前提。

  • ES6で書いて、Babelでトランスパイルしている
    • jestで作ったテスト環境では、SuperTestを用いたExpressルーターテストがどうにも動かなかった
    • jest.autoMockOff()をかけても、あちらこちらでモックになっちまう。
    • 先にBabelトランスパイルしてから、出力ファイルでjest実行してみてもなぜか改善せず
    • jestは難しいことを簡単にできて素敵に思っていたけど、意外に簡単と思ってたところでエラーが出始め。解決がどうにも難しくてjestにギブアップしてしまった
  • Reactステートレスコンポーネントはテストが動かない。
    • 普通のReactコンポーネントはテスト可能
    • 後で判明するけどこれはjestのせいじゃなかった

ぐるぐる回ってみて、ちょっとjestでは層が深くて追いきれなくなってきたのでJasmine2でくみなおしました。もちろんいきなり結論には辿りつけずにいろいろ入れたり外したりしましたよ。。。以下は、シンプル化したpackage.jsonです。Reactテストには、gulp-jasmineとreact-addons-test-utilsとjsdomが関わります。Expressテストの方ではsupertest。ビルド及びテストの前処理は、gulp-babelとbabel-preset-*。

{
  "name": "sample",
  "version": "0.0.1",
  "private": true,
  "dependencies": {
    "express": "^4.13.4",
    "react": "^0.14.7",
    "react-dom": "^0.14.7"
  },
  "devDependencies": {
    "babel-preset-es2015": "^6.5.0",
    "babel-preset-react": "^6.5.0",
    "gulp": "^3.9.1",
    "gulp-babel": "^6.1.2",
    "gulp-jasmine": "^2.2.1",
    "jsdom": "^8.0.2",
    "react-addons-test-utils": "^0.14.7",
    "supertest": "^1.2.0"
  },
  "babel": {
    "presets": [
      "es2015",
      "react"
    ]
  },
  "scripts": {
    "build": "gulp build",
    "start": "cd dest; node app.js",
    "test": "gulp test"
  }
}

gulpでトランスパイルもしくはテスト実行という流れで、今後、もうチョイと便利に改良していく予定。gulpfile.jsだけはES5で書いてます。

// gulpfile.js
'use strict';
var gulp = require('gulp');
var babel = require('gulp-babel');
var jasmine = require('gulp-jasmine');
var reporter = require('jasmine-terminal-reporter');
var jsdom = require('jsdom').jsdom;
require('babel-register'); // Jasmineで事前トランスパイルなしに動かすために必要

gulp.task('build', function() {
    gulp.src(['src/**/*.js', '!src/**/__tests__/*.js']) //テストは出力しない
        .pipe(babel())
        .pipe(gulp.dest('dest'));
});

gulp.task('test', function () {
    global.document = jsdom(''); // 空白文字列でOK。好きなHTMLを書いても支障なし。
    global.window = global.document.defaultView; // defaultViewにするのは最近の仕様変更。
    for (var key in global.window) {
        if (global.window.hasOwnProperty(key) && !global[key]) {
            global[key] = global.window[key]; //念のため上書きなしで参照をコピーしておく
        }
    }
    gulp.src('src/**/__tests__/*.js')  //テストの配置方法は直前まで試していたjestの名残りです。
        .pipe(jasmine({reporter: new reporter()}));
});

トランスパイルの方はいいとして、テストの方について。Reactのテストはやり方によってはDOMが要らないやり方もあるみたいですが、jestを通じて知ったTestUtilsのrenderIntoDocumentを使う直感的なビヘイビアテストをするために、JsDOMでヘッドレスなDOM環境を作ります。その際、テストのルートたるgulpタスクで作っちゃった。コード中で「typeof window === 'undefined'」とサーバ環境判定していたりするとダメですが、これだけは自分で書かないと決めりゃいいかな。jestはざっくりとその辺はうまくやってるっぽい(jest/JSDOMEnvironment.js at master · facebook/jest · GitHub と jest/NodeEnvironment.js at master · facebook/jest · GitHub)。

ちなみに、React Test Utilsのドキュメントの該当箇所「NOTE」を読んでも全くわからなかったため相当費やしましたよ。。。解決のためにReactだけでなくjestやJsDOMの見当違いなソース箇所ばかり読んでた(Test Utilities | React)。

node環境のグローバルスコープの名前であるglobalのプロパティとしてdocumentを作り、windowを付け足します。その後にfor...inループではwindowからglobalへ上書きしないようにしながら参照をコピーしました。本当はnavigatorだけ手当てすれば良いみたいですけど念のためです。次いでgulp-jasmineでsrcフォルダのテストを一切実行します。jestの名残でテストのフォルダは各所の__tests__フォルダとなってます。

buildタスク、testタスクともにBabelのプリセット設定はpackage.jsonを使ってくれてますのでES6とJSXどちらも対応しています。

'use strict';
import React from 'react';

class CheckboxWithLabel extends React.Component {
    constructor(props) {
        super(props);
        this.state = {isChecked: false};
    }

    handleChange() {
        this.setState({isChecked: !this.state.isChecked});
    }

    render() {
        const sender = this;
        return (
            <label>
                <input
                    type="checkbox"
                    checked={this.state.isChecked}
                    onChange={() => sender.handleChange()}
                />
                {this.state.isChecked ? this.props.labelOn : this.props.labelOff}
            </label>
        );
    }
}

CheckboxWithLabel.propTypes = {
    labelOn: React.PropTypes.string.isRequired,
    labelOff: React.PropTypes.string.isRequired
};

export default CheckboxWithLabel;

上記のテスト対象はjestでのチュートリアルのものです。ESLintに怒られたのでちょっと整えました。

'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-addons-test-utils';
import Express from 'express';
import SuperTest from 'supertest';
import CheckboxWithLabel from '../CheckboxWithLabel';
import {ResponsiveCol} from '../BootstrapResponsive';
import WorkableRouter from '../WorkableRouter';

describe('React Components and Express' , () => {
    it('changes the text after click', () => {
        var checkbox = TestUtils.renderIntoDocument(
            <CheckboxWithLabel labelOn="On" labelOff="Off" />
        );
        var checkboxNode = ReactDOM.findDOMNode(checkbox);
        expect(checkboxNode.textContent).toEqual('Off');
        TestUtils.Simulate.change(
            TestUtils.findRenderedDOMComponentWithTag(checkbox, 'input')
        );
        expect(checkboxNode.textContent).toEqual('On');
    });

    // 下はReactステートレスコンポーネントのために動かない。
    it('hidden setting', () => {
        const col = TestUtils.renderIntoDocument(
            <ResponsiveCol xs={{hidden: true}} sm={{hidden: true}} md={{hidden: false}}>
                <div></div>
            </ResponsiveCol>
        );
        const colNode = ReactDOM.findDOMNode(col);
        expect(colNode.getAttribute('class').trim()).toEqual('hidden-xs hidden-sm');
    });

    it('get jobs', (done) => {
        const app = Express();
        app.use(WorkableRouter);
        SuperTest(app)
            .get('/jobs')
            .expect(200, done);
    });

});

もちろんテストもjestから。これと2/10の記事に書いたReactステートレスコンポーネントのテストも並べておきます。さらにSuperTestでルータの非同期テストも。

F..

events.js:154
      throw er; // Unhandled 'error' event
      ^
Failures: 
Error: Tests failed
1) React Components and Express hidden setting
1.1) TypeError: Cannot read property 'getAttribute' of null

3 specs, 1 failure
Finished in 0.4 seconds

結果は、ES6-classのReactコンポーネントはOK、SuperTestはOK。でもReactステートレスコンポーネントはNG。TestUtils.renderIntoDocumentでnullを返してます。しばしトランスパイルされた結果を眺めていて気がつきました。そこで下記の工夫。

    it('hidden setting', () => {
        const Component = ResponsiveCol({xs: {hidden: true}, sm: {hidden: true}, md: {hidden: false}, children: ''});
        const col = TestUtils.renderIntoDocument(Component);
        const colNode = ReactDOM.findDOMNode(col);
        expect(colNode.getAttribute('class').trim()).toEqual('hidden-xs hidden-sm');
    });

3種とも通りました!ステートレスコンポーネントはこう書けばいいのね。ステートレスコンポーネントは引数にプロパティを渡す単なる関数で、戻りはReact ElementになるのでそのままrenderIntoDocumentに渡してあげればよかった!まあrenderIntoDocumentの実装が追いついていないってことで確定だと思います。

...

3 specs, 0 failures
Finished in 0.3 seconds

そろそろgulpでちゃんとしたワークフローを組むべきかな。gulpのgithubにレシピ集があった( gulp/docs/recipes at master · gulpjs/gulp · GitHub)。いろいろ参考になる。

react-router v2リリース

しばらくrc版を用いて開発していましたが、react-router v2が昨日か一昨日ぐらいにリリースされていました。ドキュメントも新しくv2になってます。

react-router/ComponentLifecycle.md at latest · rackt/react-router · GitHub

いつからあったかは定かではないですが、コンポーネントライフサイクルに関するドキュメントが良い情報。これはv2に限らず以前のバージョンでも同じ。パス遷移した時に各ページを構成するコンポーネントのどんなライフサイクルイベントが発火されるかの説明です。説明されるまでもなく慣れると体験的に知ってることなのですが、初めにこのドキュメントを読んでたら近道だったように思います。

react-router/testing.md at latest · rackt/react-router · GitHub

テストも。Jestのユースケースとして初見で参考になる。

TestUtilsが関数コンポーネントダメだって

'use strict';
/*global jest*/
jest.dontMock('../BootstrapResponsive');
const React = require('react');
const ReactDOM = require('react-dom');
const TestUtils = require('react-addons-test-utils');
import {ResponsiveCol} from '../BootstrapResponsive';
describe('Col', () => {
    it('hidden setting', () => {
        const col = TestUtils.renderIntoDocument(
            <ResponsiveCol xs={{hidden: true}} sm={{hidden: true}} md={{hidden: false}}/>
        );
        const colNode = ReactDOM.findDOMNode(col);
        expect(colNode.getAttribute('class').trim()).toEqual('hidden-xs hidden-sm');
    });
});

Jestで後付けにテスト書いたら、TestUtilsが関数コンポーネント(ステートレスコンポーネント)に対応していなくてダメだって。

Using Jest CLI v0.8.2, jasmine1
Running 1 test suite...Warning: ResponsiveCol(...): No `render` method found on the returned component instance: you may have forgotten to define `render`, returned null/false from a stateless component, or tried to render an element whose type is a function that isn't a React component.

「you may have fogotten to define 'render'」って、忘れてないよ!わざとだよ!「returned null/false from a stateless component」って。。。どう言うこっちゃ?

というところで、これはテストそのものが動かなかったけど、Jestは便利です。以下設定備忘。

babel, babel-preset-es2015, babel-preset-reactはすでに入ってる前提でJestインストール。

$ npm install --save-dev jest-cli babel-jest react-addons-test-utils

package.jsonにJestの設定追加。

{
  "babel": {
    "presets": [
      "es2015",
      "react"
    ]
  },
  "jest": {
    "scriptPreprocessor": "<rootDir>/node_modules/babel-jest",
    "unmockedModulePathPatterns": [
      "<rootDir>/node_modules/react",
      "<rootDir>/node_modules/react-dom",
      "<rootDir>/node_modules/react-addons-test-utils",
      "<rootDir>/node_modules/fbjs"
    ]
  },
  "scripts": {
    "test": "jest"
  }
}

で、あとはソースツリーの書きたいところで、「__tests__」フォルダを作ってその中に.jsファイルを置く。ファイルの中身は冒頭のようなのです。es6もJSXも設定で有効にしてます。中身がJasminなのでJasminな書き方にJest特有のモック自動生成、ReactアドオンTestUtilsのツールで仮想DOM-テストDOMの連携動作が乗っかります。

リファレンスは以下。

リファレンスまとめて気がついた。Jasminは1.3.0で古い。node_modules配下のjest-cliを見ると腹にjasmin2.3.4を抱えてたので切り替える設定がありそう。

追記

。。。切り替え設定ありました。

https://facebook.github.io/jest/docs/api.html#config-testrunner-string

さっきの設定に一行足します。

  "jest": {
    "scriptPreprocessor": "<rootDir>/node_modules/babel-jest",
    "testRunner": "<rootDir>/node_modules/jest-cli/src/testRunners/jasmine/jasmine2.js",
    "unmockedModulePathPatterns": [
      "<rootDir>/node_modules/react",
      "<rootDir>/node_modules/react-dom",
      "<rootDir>/node_modules/react-addons-test-utils",
      "<rootDir>/node_modules/fbjs"
    ]
  },

実行したら、ちゃんと2だって言ってる。今まで1.3でも支障なかったけど今日から2.3にしておこう。

Using Jest CLI v0.8.2, jasmine2
Running 1 test suite...

BootstrapのGridをReactに持ってきてみた。

material-uiを気にいって、それでReactアプリ書いてます。発展途上ではあるも開発者の方々がこまめにがっつり頑張ってくれてて、日々npm updateをかけるのが楽しみです。そんなmaterial-uiも今の所はGridレイアウトのコンポーネントはありません。Google謹製マテリアルデザインのCSSライブラリであるMaterial Design Lightは画面幅を12分割するというBootstrap近似仕様のGridレイアウトがありますので、まあ、対抗してmaterial-uiにもそのうち作られるかな。

Reactコンポーネントとしてもいくつかあるようですが、今の所良さそうなものが見つからなかったので暫定的にBootstrapを持ち込んで軽め対応してみました。スタイルはインラインで書くようにしていたので、Gridだけとはいえcssファイルに外だしちゃうのがちょっと嫌だったけど、手もかけたくなかったので素に近い使い方に止めてます。

$ npm install --save bootstrap-sass

いろいろ試行錯誤してみた結果、SASS版Bootstrapから必要箇所を取り込んで使うことにしました。上記npmインストールでnodo_modules配下にSASSソースの最新版が管理されます。

/* /src/sass/style.scss */
@import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap-sprockets";
@import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/variables";
@import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/mixins";
@import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/grid";
@import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/scaffolding";
@import "../../node_modules/bootstrap-sass/assets/stylesheets/bootstrap/responsive-utilities";

自分のスタイルシートを作って必要なだけimport。これをgulp-sassでコンパイル

// gulpfile.js
'use strict';
var gulp = require('gulp');
var sass = require('gulp-sass');

gulp.task('SASS', function() {
    gulp.src('src/sass/*.scss')
        .pipe(sass())
        .pipe(gulp.dest('dest/public/css'));
});

コンポーネントはcontainer-row-colの組み合わせ。

// BootstrapResponsive.js
'use strict';
const React = require('react');

function ResponsiveContainer(props) {
    return (
        <div className={props.fullWidth ? 'container-fluid' : 'container'}>
            {props.children}
        </div>
    );
}

ResponsiveContainer.propTypes = {
    fullWidth: React.PropTypes.bool,
    children: React.PropTypes.node.isRequired
};

function ResponsiveRow(props) {
    return (
        <div className='row'>
            {props.children}
        </div>
    );
}

ResponsiveRow.propTypes = {
    children: React.PropTypes.node.isRequired
};

function ResponsiveCol(props) {
    const className = ['xs', 'sm', 'md', 'lg'].map((type) => {
        const value = props[type];
        var result = [];
        if(value) {
            var valueSize = 0;
            if (typeof value === 'number') {
                valueSize = value;
            } else {
                const {size, offset, visible, block, inline, hidden} = value;
                valueSize = size;
                if (0 <= offset && offset <= 12) {
                    result.push('col-' + type + '-offset-' + offset);
                }
                if (visible) {
                    result.push('visible-' + type + '-' + (inline ? (block ? 'inline-block' : 'inline') : 'block'));
                }
                if (hidden) {
                    result.push('hidden-' + type);
                }
            }
            if (1 <= valueSize && valueSize <= 12) {
                result.unshift('col-' + type + '-' + valueSize);
            }
        }
        return result.length > 0 ? result.join(' ') : '';
    }).join(' ');
    return (
        <div className={className}>
            {props.children}
        </div>
    );
}

const responsivePropsType = React.PropTypes.oneOfType([
    React.PropTypes.number,
    React.PropTypes.object
]);

ResponsiveCol.propTypes = {
    xs: responsivePropsType,
    sm: responsivePropsType,
    md: responsivePropsType,
    lg: responsivePropsType,
    children: React.PropTypes.node.isRequired
};

export {ResponsiveContainer, ResponsiveRow, ResponsiveCol};

今回はステートレスなコンポーネントだったのでシンプルに関数で書いてみました。公式ドキュメントReusable Components | React)の説明以外に見ない書き方ですが不要なお決まりイディオム抜きで良い感じだと思います。今回は一発で書いちゃってますがロジックを細かく関数分割すればテストもしやすいでしょう。

およそBootstrapのGridシステムについてのドキュメント(CSS · Bootstrap #gridと CSS · Bootstrap #responsive-utilities-classes)に書かれていたものの8割ぐらいは実装しました。まあdivタグにクラス文字列埋めるだけですからね。残したのはclearfixとpull/push。やっても直接divタグ書くのと何が違うのかって感じだからもっと抽象度を高める利用アイディアなきゃいらないかなと思います。

const React = require('react');
import {Card, CardText} from 'material-ui';
import {ResponsiveContainer, ResponsiveRow, ResponsiveCol} from './BootstrapResponsive';

/*中略*/

<ResponsiveContainer fullWidth={true}>
    <ResponsiveRow>
        <ResponsiveCol xs={{size: 12}} sm={6} md={{size: 8, offset: 2}} lg={{size: 4, offset: 0}}>
            <Card>
                <CardText>
                    Lorem ipsum dolor sit amet, consectetur adipiscing elit.
                    Donec mattis pretium massa. Aliquam erat volutpat. Nulla facilisi.
                    Donec vulputate interdum sollicitudin. Nunc lacinia auctor quam sed pellentesque.
                    Aliquam dui mauris, mattis quis lacus id, pellentesque lobortis odio.
                </CardText>
            </Card>
        </ResponsiveCol>
        <ResponsiveCol xs={{hidden: true}} sm={6} md={{size: 8}} lg={{size: 4}}>
            <Card>
                <CardText>
                    Lorem ipsum dolor sit amet, consectetur adipiscing elit.
                    Donec mattis pretium massa. Aliquam erat volutpat. Nulla facilisi.
                    Donec vulputate interdum sollicitudin. Nunc lacinia auctor quam sed pellentesque.
                    Aliquam dui mauris, mattis quis lacus id, pellentesque lobortis odio.
                </CardText>
            </Card>
        </ResponsiveCol>
    </ResponsiveRow>
</ResponsiveContainer>
  • xsサイズでは全幅の一つと、もう一つは消す。
  • smサイズでは横二分割。
  • mdサイズでは左右余白2で8幅。縦に並べる。
  • lgサイズでは4幅で横に並べる。

やってることは原始的だけど意外に使える。外出しになってるBootstrapのCSSコンポーネントにインライン化できればいいんじゃないかな。css擬似要素とメディアクエリをインライン縛りにどう移植するか。。。擬似要素はspanを動的に足してくかだな。メディアクエリはScriptで幅をとって計算。。。サーバ描画でうまくないなあ。

ReactのES6-classでのコンテキスト

class PutContext extends React.Component {
    getChildContext() {
        return {color: '#03a9f4'};
    }
    render() {
        return <div>{this.props.children}</div>;
    }
}
// childContextTypesはpropTypesと同じスタイルの設定
PutContext.childContextTypes = {color: React.PropTypes.string};

class GetContext extends React.Component {
    // コンストラクタの第二引数にcontextが渡され、コンテキストを利用したstate初期化が可能
    constructor(props, context) {
        super(props, context);
        this.state = {
            color: context.color
        };
    }

    render() {
        const color = this.state.color;
        return <div style={{color: color}}>the context color is {color}.</div>;
    }
}
// contextTypesもpropTypesと同じスタイルの設定
GetContext.contextTypes = {color: React.PropTypes.string}

react/ReactComponent.js at master · facebook/react · GitHub

上記リンク先、React.Componentのソースを見るとドキュメントに明らかな第一引数のpropsだけでなくコンストラクタには第二、第三の引数があってコンテキストが渡されてきてました。よってES6-classを用いたときに従来のgetInitialState()をコンストラクタ内の処理で置き換える際にコンテキストを参照することが可能。