プログラミングとSEOと暇つぶし

駆け出しエンジニアdallPのブログです。元SEOコンサルタントです。プログラミング、SEO、アフィリエイト、お金などについて役に立つかもしれない情報をやりたいように書きます。

MeteorJSとReactを勉強してみる その8: ユーザーアカウント機能を追加してみよう【公式翻訳】

MeteorJSを勉強してみるシリーズ第八弾です。

やっとユーザーアカウントを追加する段階に来ましたね。 しかもMeteorではユーザーアカウント機能をデフォルトで用意してくれているため、一から自分で作るよりも圧倒的に簡単に機能を追加できます。


ユーザーアカウント機能を追加する

Meteorはアカウント機能とログインユーザーインターフェースを備えており、マルチユーザー機能をアプリに追加することも簡単にできてしまいます。

なお現状ではこのUIコンポーネントはMeteor独自のUIエンジンである「Blaze」を使用しています。Reactを使ったコンポーネントはまだ用意されていませんが、将来的には実装されるかもしれません。

アカウント機能とそのUIを使えるようにするには、関連するパッケージをプロジェクトに追加しなくてはなりません。

アプリのディレクトリに行き、下記のコマンドを打ちましょう。

meteor add accounts-ui accounts-password

BlazeコンポーネントをReactで包む

Blazeのaccounts-uiUIコンポーネントをReactで使うには、Reactコンポーネントでラップする必要があります。

そのためには、まずAccountsUIWrapperというReactコンポーネントを新しいファイルで作ってあげましょう。

imports/ui/AccountsUIWrapper.js

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Template } from 'meteor/templating';
import { Blaze } from 'meteor/blaze';
 
export default class AccountsUIWrapper extends Component {
  componentDidMount() {
    // Use Meteor Blaze to render login buttons
    this.view = Blaze.render(Template.loginButtons,
      ReactDOM.findDOMNode(this.refs.container));
  }
  componentWillUnmount() {
    // Clean up Blaze view
    Blaze.remove(this.view);
  }
  render() {
    // Just render a placeholder container that will be filled in
    return <span ref="container" />;
  }
}

今作成したこのコンポーネントをApp.jsで読み込んであげましょう。

imports/ui/App.js

before

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted: false,
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    let filteredTasks = this.props.tasks;

    if (this.state.hideCompleted) {
      filteredTasks = filteredTasks.filter((task) => !task.checked);
    }

    return filteredTasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List ({this.props.incompleteCount})</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>

        </header>
        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch(),
    incompleteCount: Tasks.find({ checked: { $ne: true } }).count(),
  };
})(App);

after

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';
import AccountsUIWrapper from './AccountsUIWrapper.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted: false,
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    let filteredTasks = this.props.tasks;

    if (this.state.hideCompleted) {
      filteredTasks = filteredTasks.filter((task) => !task.checked);
    }

    return filteredTasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List ({this.props.incompleteCount})</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <AccountsUIWrapper />

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>

        </header>
        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch(),
    incompleteCount: Tasks.find({ checked: { $ne: true } }).count(),
  };
})(App);

Then, add the following code to configure the accounts UI to use usernames instead of email addresses:

さらに、imports配下にstartupディレクトリを作成し、その中にaccounts-config.jsを作成しましょう。 これはaccounts-uiのログイン時にメールアドレスではなくユーザーネームを使用するようにするための設定です。

imports/startup/accounts-config.js

import { Accounts } from 'meteor/accounts-base';
 
Accounts.ui.config({
  passwordSignupFields: 'USERNAME_ONLY',
});

We also need to import that configuration code in our client side entrypoint:

この設定コードはクライアント側のエントリーポイント(client/main.js)で読み込んであげましょう。

client/main.js

before

import React from 'react';
import { Meteor } from 'meteor/meteor';
import { render } from 'react-dom';

import App from '../imports/ui/App.js';

Meteor.startup(() => {
  render(<App />, document.getElementById('render-target'));
});

after

import React from 'react';
import { Meteor } from 'meteor/meteor';
import { render } from 'react-dom';

import '../imports/startup/accounts-config.js';
import App from '../imports/ui/App.js';

Meteor.startup(() => {
  render(<App />, document.getElementById('render-target'));
});

ユーザーに関連する機能を追加する

これでユーザーはアカウントを作成し、アプリにログイン出来るようになりました。 とても素晴らしいのですが、ログインできるだけでは便利じゃありませんね。 ということで、二つ新しい機能を追加しましょう。

  • ログイン中のユーザーにのみタスク入力欄を表示する
  • どのユーザーがどのタスクを作ったのかを表示する

これらを実装するため、タスクコレクションに二つの新しいフィールドを持たせましょう。

  1. owner: タスクを作ったユーザーのid
  2. username: タスクを作ったユーザーのユーザーネーム

ここでは、タスクを表示するたびに毎回ユーザーを探しにいかなくても良いように、タスクオブジェクトに直接ユーザーネームを保存します。

まず、タスク追加時に上記のフィールドを保存するために、App.jshandleSubmitイベントハンドラに追記しましょう。

現在ログインしているユーザーの情報を取得するため、データコンテナにも追記します。

そして入力フォームを表示するのはログインしているユーザーだけ、という条件を付け加えましょう。

imports/ui/App.js

before

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';
import AccountsUIWrapper from './AccountsUIWrapper.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted: false,
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    let filteredTasks = this.props.tasks;

    if (this.state.hideCompleted) {
      filteredTasks = filteredTasks.filter((task) => !task.checked);
    }

    return filteredTasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List ({this.props.incompleteCount})</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <AccountsUIWrapper />

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form> : ''

        </header>
        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch(),
    incompleteCount: Tasks.find({ checked: { $ne: true } }).count()
  };
})(App);

after

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Meteor } from 'meteor/meteor';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';
import AccountsUIWrapper from './AccountsUIWrapper.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted: false,
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date(),
      owner: Meteor.userId(),
      username: Meteor.user().username
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    let filteredTasks = this.props.tasks;

    if (this.state.hideCompleted) {
      filteredTasks = filteredTasks.filter((task) => !task.checked);
    }

    return filteredTasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List ({this.props.incompleteCount})</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <AccountsUIWrapper />

          {
            this.props.currentUser ?
            <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
              <input
                type="text"
                ref="textInput"
                placeholder="Type to add new Tasks."
              />
            </form> : ''
          }

        </header>
        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch(),
    incompleteCount: Tasks.find({ checked: { $ne: true } }).count(),
    currentUser: Meteor.user()
  };
})(App);

最後に、各タスクの左側に、そのタスクを追加したユーザーのユーザーネームを表示するためのコードを追記しましょう。

imports/ui/Task.js

before

import React, { Component } from 'react';

import { Tasks } from '../api/tasks.js';

// Task component - represents a single todo item
export default class Task extends Component {
  toggleChecked() {
    // set the checked property to the opposite of its current value
    Tasks.update(this.props.task._id, {
      $set: { checked: !this.props.task.checked },
    })
  };

  deleteThisTask() {
    Tasks.remove(this.props.task._id);
  };

  render() {
    // give tasks a different className when they are checked off,
    // so that we can style them nicely in CSS.
    const taskClassName = this.props.task.checked ? 'checked' : '';

    return (
      <li className={ taskClassName }>
        <button className="delete" onClick={ this.deleteThisTask.bind(this) }>
          &times;
        </button>

        <input
          type="checkbox"
          readOnly
          checked={ !!this.props.task.checked }
          onClick={ this.toggleChecked.bind(this) }
        />

        <span className="text">{ this.props.task.text }</span>
      </li>
    );
  }
}

after

import React, { Component } from 'react';

import { Tasks } from '../api/tasks.js';

// Task component - represents a single todo item
export default class Task extends Component {
  toggleChecked() {
    // set the checked property to the opposite of its current value
    Tasks.update(this.props.task._id, {
      $set: { checked: !this.props.task.checked },
    })
  };

  deleteThisTask() {
    Tasks.remove(this.props.task._id);
  };

  render() {
    // give tasks a different className when they are checked off,
    // so that we can style them nicely in CSS.
    const taskClassName = this.props.task.checked ? 'checked' : '';

    return (
      <li className={ taskClassName }>
        <button className="delete" onClick={ this.deleteThisTask.bind(this) }>
          &times;
        </button>

        <input
          type="checkbox"
          readOnly
          checked={ !!this.props.task.checked }
          onClick={ this.toggleChecked.bind(this) }
        />

        <span className="text">
          <strong>{ this.props.task.username }</strong>: { this.props.task.text }
        </span>
      </li>
    );
  }
}

ブラウザで、まずは自分のアカウントを作成してください。 その上でログインすると、タスク入力欄が表示されると思います。 その状態でタスクを追加すると、自分のユーザーネームと一緒にタスクが表示されるようになっているでしょうか。

以前までに追加したタスクにはユーザーネームがついていませんね。

さて、ユーザーはログインできるようになり、各タスクがどのユーザーに紐付いているのか分かるようになりました。

本ステップで初めて出てきたMeteorの特徴について、もう少し詳しく見ておきましょう。

自動化されたアカウントUI

Meteorで作成中のアプリにaccounts-uiパッケージが含まれているなら、ログインメニューを作成するのに必要なのはUIコンポーネントレンダリングすることだけです。

このログイン機能は、どのログインメソッドがアプリに追加されているかを自動で検知し、最適なUIを表示します。

今回のケースでは、利用可能になっているメソッドはaccounts-passwordのみですので、ドロップダウンメニューにはパスワード入力欄しかありません。

もし他の機能も試してみたければ、accounts-facebookパッケージを追加して、Facebookログインボタンを有効化することもできます。

ログイン中ユーザーの情報取得について

データコンテナでは、Meteor.user()を使ってユーザーがログイン中かどうかを判定し、彼らの情報を取得することができます。 例えば、Meteor.user().usernameはログインユーザーのユーザーネームを持っています。 同様に、Meteor.userId()ではログインユーザーのユーザーIDを取得できます。

次のステップでは、サーバーでデータの検証を行うことで作成中のアプリをセキュアにする方法を学んでいきます。


非常に簡単にユーザーアカウント機能を追加することができましたね。

めちゃくちゃ簡単なのは良い点も悪い点もあり、良い点としては単純に実装が早くなること、悪い点は最悪何も理解してなくても実装できてしまうこと、既存の実装をうまいこと改変することができない or 難しいと想定されることです。

でも、とりあえず作りたいものがある、という場合にはやっぱり非常にありがたい用意ですよね。

ついでにですが、Facebookログイン機能は少なくともローカルでアプリを作成中の段階では有効化できなさそうです。 これはMeteorの問題というより、Facebook側のバリデーションの関係で無理な気がしています。

抜け道があるのかどうかまでは調べていないので、どうしてもローカルホストで試したいんだ、という人はググってみてくださいね。

次の更新は明日、もしくは週明けくらいになると思います。

MeteorJSとReactを勉強してみる その7: コンポーネントのステートにUIの状態を保存してみよう【公式翻訳】

MeteorJSを勉強してみる第七弾です。

タイトルの意味がわかりにくいですね。 まあ実際に書いてみないと何を言っているか分からないと思います。

英語って難しい。


コンポーネントのステートに一時的なUIの状態を保存してみよう

このステップでは、クライアント側でのデータフィルタリング機能をアプリに追加します。 これによりユーザーは、未完了のタスクだけを表示させることができます。

Reactコンポーネントのステートを、クライアント側のみで利用される一時的な状態を保持するためにどのように使えるのかを見ていきましょう。

まず、Appコンポーネントチェックボックスを追加します。

imports/ui/App.js

before

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    return this.props.tasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>
          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch()
  };
})(App);

after

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    return this.props.tasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch()
  };
})(App);

新しく追加した<input>タグの中で、this.state.hideCompletedを呼んでいるのがわかると思います。

Reactコンポーネントは「ステート」と呼ばれる特殊な属性を持っており、ここにカプセル化されたコンポーネントのデータを格納しておくことができます。

これを利用するためには、コンポーネントのコンストラクタの中でthis.state.hideCompletedの値を初期化しなければなりません。

imports/ui/App.js

before

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    return this.props.tasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch()
  };
})(App);

after

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted = false;
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    return this.props.tasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch()
  };
})(App);

ここで初期化したthis.stateは、イベントハンドラ内でthis.setStateを呼ぶことで更新することができます。 これにより、ステートは非同期的に更新され、同時にコンポーネントの再レンダリングを引き起こします。

では、実際にステートの内容を更新する役目を持つtoggleHideCompleted()を実装しましょう。

imports/ui/App.js

before

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted = false;
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch()
  };
})(App);

after

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted = false;
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    return this.props.tasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch()
  };
})(App);

ここまでできたら、hideCompletedがtrueの時にはそのタスクを表示しない、という処理をrenderTasks関数に持たせる必要があります。

imports/ui/App.js

before

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted = false;
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    return this.props.tasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch()
  };
})(App);

after

import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted = false;
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    let filteredTasks = this.props.tasks;

    if (this.state.hideCompleted) {
      filteredTasks = filteredTasks.filter((task) => !task.checked);
    }

    return filteredTasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch()
  };
})(App);

ここまでできたら、保存してブラウザを確認してみましょう。 追加したチェックボックスにチェックを入れると、完了していないタスクのみが表示されるようになっているはずです。

未完了タスクの合計数を表示する

完了したタスクを弾くコードを書いてきましたが、同じようなやり方で「未完了タスクの数を表示する」こともできます。

これを実装するためには、データコンテナにタスクの数を入れ、renderメソッドに1行追加するだけでOKです。

既にクライアント側のコレクションにデータを持っているため、「タスク数」を追加するにあたってはサーバーにアクセスする必要は一切ありません。

imports/ui/App.js

before

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted: false,
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    let filteredTasks = this.props.tasks;

    if (this.state.hideCompleted) {
      filteredTasks = filteredTasks.filter((task) => !task.checked);
    }

    return filteredTasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch(),
  };
})(App);

after

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { withTracker } from 'meteor/react-meteor-data';

import { Tasks } from '../api/tasks.js';

import Task from './Task.js';

// App Component - represnts the whole app
class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      hideCompleted: false,
    };
  }

  handleSubmit(event) {
    event.preventDefault();

    // find the text field via the React ref
    const text = ReactDOM.findDOMNode(this.refs.textInput).value.trim();

    Tasks.insert({
      text,
      createdAt: new Date()
    });

    // clear form
    ReactDOM.findDOMNode(this.refs.textInput).value = '';
  }

  renderTasks() {
    let filteredTasks = this.props.tasks;

    if (this.state.hideCompleted) {
      filteredTasks = filteredTasks.filter((task) => !task.checked);
    }

    return filteredTasks.map((task) => (
      <Task key={task._id} task={task} />
    ));
  }

  toggleHideCompleted() {
    this.setState({
      hideCompleted: !this.state.hideCompleted
    });
  }

  render() {
    return (
      <div className="container">
        <header>
          <h1>Todo List ({this.props.incompleteCount})</h1>

          <label className="hide-completed">
            <input
              type="checkbox"
              readOnly
              checked={this.state.hideCompleted}
              onClick={this.toggleHideCompleted.bind(this)}
            />
            Hide Completed Tasks
          </label>

          <form className="new-task" onSubmit={this.handleSubmit.bind(this)}>
            <input
              type="text"
              ref="textInput"
              placeholder="Type to add new Tasks."
            />
          </form>
        </header>

        <ul>
          {this.renderTasks()}
        </ul>
      </div>
    );
  }
}

export default withTracker(() => {
  return {
    tasks: Tasks.find({}, { sort: { createdAt: -1 } }).fetch(),
    incompleteCount: Tasks.find({ checked: { $ne: true } }).count()
  };
})(App);

ここまで来ると大分Reactの使い方に慣れてきた気がしますね。

  • renderメソッドには出力したいHTMLを書く
  • そのHTMLの中で動的に操作したい内容は変数化・メソッド化して外に出す
  • リスト要素のように共通の要素はrender内に直接書くのではなく別コンポーネントに分ける

僕も業務で使ってないので想像ですが、実際はrender()の中身も別ファイルに分けて、メソッドのみのファイルとUIのファイルを分けてしまうとより良さそうですね。

Meteorの公式ドキュメントに最適な構成に関するものもあったので、こちらも近々読んで翻訳してみます。

第八弾は明日にでも。

MeteorJSとReactを勉強してみる その6: iOSとAndroidでアプリを動かしてみよう【公式翻訳】

MeteorJSを勉強してみるシリーズ第六弾です。

作りたいサービスのイメージがある人はテンションが上がるのではないでしょうか。

ただ、現状(1.6系)のWindows版のMeteorではモバイル向けのビルドには対応していません。 残念ですが、Windowsの人は別のフレームワークを選ぶか、別途環境を用意しましょう。


iOSAndroidでアプリを動かす

ここまでではブラウザのみでアプリを立ち上げ、テストしてきました。 しかしMeteorはその他のプラットフォームでも動くように作られています。 つまり、作成中のTodoリストが多少のコマンドでiOSAndroidアプリになってしまうのです。

Meteorではスマホアプリを立ち上げるのに必要な設定が簡単にできるようになっていますが、全てのソースをダウンロードするのにはしばらく時間がかかります。 Android用のソースはおよそ300MB、そしてiOS用には2GBほどのXcodeをインストールする必要があります。 これらのインストールが面倒だという方は、一旦このステップを飛ばしても結構です。

iOSシミュレーターで動かす (Mac限定)

もしMacをお持ちであれば、アプリをiOSシミュレーターで動かすことができます。

ターミナルでアプリケーションのフォルダに行き、下記のコマンドを打ってください。

meteor install-sdk ios

このコマンドで、プロジェクトソースからiOSアプリを立ち上げるのに必要な設定が行うことができます。


訳者注: 上記コマンドを打つと、下記が表示されます。

Please follow the installation instructions in the mobile guide:
http://guide.meteor.com/mobile.html#installing-prerequisites

結局はここを読んで自分でコマンドを打つ必要があるんですね笑

せっかくなので、ここで全て解説しておきます。

まずは下記コマンドを打って、表示される選択肢からGet Xcodeを選びましょう。

meteor add-platform ios

そうするとApp StoreXcodeの画面が表示されるので、そのままインストールします。

インストールには時間がかかりますので、しばらくコーヒーでも淹れながら待ちましょう。

完了したら下記をターミナルで打ちます。

sudo xcodebuild -license accept

これでXcodeのライセンス条項に同意したことになります。

基本的には上記で設定は完了です。


完了したら、下記のコマンドを打ちましょう。

meteor add-platform ios
meteor run ios

iOSシミュレーターが立ち上がり、アプリが中で動いているのが確認できるはずです。


訳者注: もし上記で立ち上がらなかった場合は、一旦Xcodeのアプリケーションそのものを開いてみてください。 Macのファインダーからアプリケーションフォルダの中を探しましょう。

開いたら、人によってはXcodeの機能をフルで利用するための追加インストールを行うか、というインフォメーションが表示されると思いますので、Installを選びましょう。

僕の場合はこれで立ち上がるようになりました。

初回立ち上げの際には、シミュレータの立ち上げにもそこそこ時間がかかります。 気長に待ちましょう。

Androidエミュレータで動かす

ターミナルでアプリケーションのフォルダに行き、下記を打ってください。

meteor install-sdk android


訳者注: このコマンドも、iOS同様にリンクを表示するだけです。

Please follow the installation instructions in the mobile guide:
http://guide.meteor.com/mobile.html#installing-prerequisites

リンク先は同じなので、別途開かなくてOKです。

個人的にAndroid向けのセットアップはかなり苦労しました。 おそらく環境により詰まった際の取るべき対応はことなるのですが、ここではサラッと僕が何をやったかだけ流します。

ドキュメントに従うなら、下記を行えば立ち上げまで行けるはず。

意味わかりませんか? 大丈夫、僕も分かりません。

上から進めていきましょう。

Java Development Kit(JDK)をインストール

Oracle Java Websiteに行き、Java Development Kitを選択してダウンロードします。

この時、自分の環境(MacなのかWinなのか)に適したJDKをダウンロードするよう注意しましょう。 まあそもそもMeteorがWindows向けのアプリビルドに対応してないので殆どの人はMac版をインストールすると思います。

ダウンロードしたzipを解凍し、表示されたアイコンをダブルクリックしたら後はインストーラの指示に従っていきましょう。

Android SDKとその他必要なツールをインストール

Androidの立ち上げには色々必要みたいです。

There is no need to use Android Studio if you prefer a stand-alone install. Just make sure you install the most recent versions of the Android SDK Tools and download the required additional packages yourself using the Android SDK Manager.

Meteor公式にはこんなこと書いてありますが、特に初心者が実行するのは至難の業だと思いますので、ここは素直にAndroid Studioをインストールしましょう。

Android Studioのインストール手順は基本的にインストーラに従うだけですが、不安な人はAndroid Studioの公式サポートの動画をチェックしましょう。 僕は見ても意味ないと思います。

さて、Meteor公式には「正しいバージョンのAndroid SDK Tools」をインストールするよう但し書きがあります。 具体的には

  • Meteor 1.4.3.1以降のバージョンの場合、25.2.xか26.0.0以降のAndroid SDK Tools(mac, linux)をインストールすること
  • バージョン25.3.0のSDK Toolsでは動かない
  • Meteor 1.4.2以前のバージョンの場合、23系のSDK Tools(mac, linux)をインストールすること

と言われても、Android Studioをインストールする時に「Android SDK Tools」のバージョン選択画面なんかありません。

ただ、途中の画面でインストールするパッケージの内容を見ることはできます。 そこでAndroid SDK Toolsのバージョンを確認して、上記に合致しない場合はエラーが出る可能性があります。

バージョンが違ったら、上記macとかlinuxとかのリンクから、適するバージョンのSDK Toolsをダウンロードしてください。

その上で、~/Library/Android/sdk/にあるtools/の中身を、ダウンロードしたSDK Toolsの中身と置き換えます。

置き換える際には下記のコマンドを打ちましょう。

cd ~/Library/Android/sdk/tools/
rm *
cp ~/Downloads/tools/* ~/Library/Android/sdk/tools/

パスにANDROID_HOMEとその他ツールのディレクトリを設定

~/.bash_profileを開き、下記を追加してください。 (zshの場合は~/.zshrc)

# Android
export ANDROID_HOME="$HOME/Library/Android/sdk"
export PATH=$PATH:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools

書き込み終わったら、下記のコマンドで読み込む.bash_profileを更新します。

source ~/.bash_profile

Android Virtual Device(AVD)をエミュレータ用に作成

この過程はMeteor公式だとオプションのように書いてるのですが、どうやら必須な気がしています。

実際にAVDを作成するには、まずAndroid Studioを立ち上げ、Start a new Android Studio Projectから新規プロジェクトを立ち上げましょう。

設定はデフォルトのままで良いです。 ただ、Activityの設定画面ではAdd No Activityを選択しておいたほうがプロジェクトの作成待ち時間が少ない気がします。

立ち上がったら、画面上のアイコンでスマホっぽい形をしたものを探して、マウスオンしてください。 「AVD Manager」と表示されたらビンゴです。 クリックして、Create Virtual Deviceを選択しましょう。

あとはエミュレータで立ち上げたい機種を選択し、どのAndroid OSを利用するか選択します。

僕は機種にPixel 2、OSはNugat(API level: 25)を選択しました。 OSのバージョンについては、多分インストールしてあるMeteorのバージョンによって適切なものが変わります。

gradleのパーミッションを書き換える (必要に応じて)

もはやここまで来ると、僕をはじめとして普段Androidを触らない人はよく分かんないのではと思うのですが、 何やらAndroid StudioのバージョンによってはgradleというJavaのビルドシステムのファイルを読み書きする際のパーミッションが書き換わってしまうようです。

ここで引っかかってる人は全世界にたくさんいるようなので、幸いWebにはソリューションがそこらに落ちてます。

もしmeteor run androidError: spawn EACCESのようなエラーが出た場合、これを疑ってください。

僕の場合は、下記コマンドで解決しました。

gradle-x.yの部分は、インストールされているgradleにより異なります。ご自分で確認してくださいね。

chmod +x /Applications/Android\ Studio.app/Contents/gradle/gradle-4.1/bin/gradle

これでgradleのパーミッションを書き換えることができます。

必要と思われる設定は以上です。

僕は色々調べながらトライアンドエラーしてたらいつの間にか朝の7時になってました。 まあ気長にやるか、一旦飛ばしてしまっても良い気がします笑


これでAndroidアプリを立ち上げる準備ができます。 完了したら、下記のコマンドを打ちましょう。

meteor add-platform android

ターミナルでライセンス条項に同意したら、下記の通りにコマンドを打ってください。

meteor run android

しばらく初期化処理が走った後、Androidエミュレータが立ち上がり、ネイティブAndroid環境でアプリが動くでしょう。 エミュレータの挙動はそこそこ遅いので、実際にアプリとして使った感じを試したければ、実機でアプリを動かす必要があります。

Androidの実機で動かす

まずは上記Android向けステップを全て完了させましょう。 そうしたら、お持ちの実機でUSBデバッグが可能な状態にあることを確認し、デバイスをUSBでパソコンに繋ぎましょう。

また、デバイス上で動かす前にAndroidエミュレーターを止めておくことをお忘れなきように。

その上で、下記のコマンドを打ちましょう。

meteor run android-device

これでアプリが立ち上がり、デバイスにインストールされます。

iPhoneiPadで動かす (Mac限定、Apple developer accoutが必要)

Apple developer accountをお持ちなら、iOSの実機でもアプリを動かせます。 下記のコマンドを打ってください。

meteor run ios-device

これであなたのiOSアプリのプロジェクトがXcodeで立ち上がります。 Xcodeでは、Xcodeがサポートするデバイスならどれでもアプリを立ち上げることができます。

これで、Meteorでスマホアプリを立ち上げることがいかに簡単か分かりましたね。

次回以降ではさらにアプリに機能を追加していきましょう。


無事にシミュレーターを立ち上げられたでしょうか? 苦労した人もいると思いますが、それにしても割りと簡単にアプリの立ち上げまで行ける方だと思います。

よくわからない中でも、頑張って調べた経験はきっとどこかで活きるので、時間のある方は是非スマホアプリでの立ち上げにも挑戦してみてください。

第七弾は今日明日で投稿します。