20200515のReactに関する記事は12件です。

VSCodeでReact-JSXを保存するとき勝手に改行されちゃう問題

問題

VSCodeで下記のようなDOMを書いて

ReactDOM.render(
  <React.StrictMode >
   <App />
  </React.StrictMode>,
  document.getElementById('root')
);

保存(⌘+S)しようとすると、勝手に改行されてしまう問題。
もちろんエラーになります。

ReactDOM.render(
  <
React.StrictMode >
  <
  App / >
  <
  /React.StrictMode>,
  document.getElementById('root')
);

解決策

VSCodeの設定を変更します。
Settings.jsonファイルを開き、下記を追加します。

    "files.associations": {
        "*.js": "javascriptreact"
    }

以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React: 以前の値を取得するときの挙動についての理解

<フックに関するよくある質問 – React> 前回の props や state はどうすれば取得できますか?を読み直していたときに挙動の理解に少し詰まったので、その解説です。

tl;dr

レンダーされたあとにその時点の値を保存しておくことで、次回のレンダリング時に「以前の値」を取り出せる。

前提理解

  • React.useEffectはレンダリングされた後に実行される
    • なので、このタイミングで「次回のレンダリング時にとっての前回の値」、つまり「現在の値」を格納しておく。
    • 格納先はrefに格納する
  • ref は何でも格納できる変数だと考えてOK
    • 一般的にはDOMを紐付け・入れるケースが多いけど、実体はJavaScriptの生オブジェクトなので何を入れてもOK。

コードで見る

コードブロックごとに実行順番を記載しました。
1 -> 2 -> 3の順番で読み解いていくと理解しやすいかと思います。

import React from "react";
import ReactDOM from "react-dom";

const App: React.FC = () => {
  /*************
   * 1
   *************/
  const ref = React.useRef(null) as any;
  const [count, setCount] = React.useState<number>(0);
  const increment = () => setCount(prev => ++prev);

  /*************
   * 3
   *************/
  React.useEffect(() => {
    console.log("[useEffect]count is ", count);
    ref.current = count; // <= 現在の値を保存しておく。次回から見たら前回の値になる。
  });

  /*************
   * 2
   *************/
  return (
    <div className="App">
      {console.log("[render]start---")}
      {console.log("[render] prev is ", ref.current)}
      {console.log("[render] count is ", count)}
      <p>prev: {ref.current}</p>
      <p>now: {count}</p>
      <button onClick={increment}>+</button>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

console.logの結果は下記です。

Screen Shot 2020-05-15 at 20.44.52.png

ボタンをクリックするごとに赤枠ごとロギングされます。1つ目の赤枠は初回レンダリング時に表示されます。

おわりに

React.useEffectとrenderの実行順番を意識してなかったので、少し詰まったけどそこを意識すれば割と簡単に読めるかと思います。

ちなみに、usePreviousみたいにカスタムフックスにもできます。将来的にもReactから提供されるかもなので、極力カスタムフックスを作っておくほうが良いかと思います。(参照: フックに関するよくある質問 – React)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6+Reactで付箋アプリっぽいページを作ってみた。3 (UI作成編)

この記事について

前回までRailsを利用してバックエンド側を作り込んでみたので、フロントエンド側の実装に入っていきたいと思います。
果たして、分量が多くなりすぎるのではないかと心配してますが、きっと多くなります。

まずは基点となるコンポーネントを追加するのじゃ。

react-railsを使う場合、viewのテンプレートで以下のようにreact_component()メソッドを呼んで、Reactコンポーネントのレンダリングを行います。
<%= react_component(コンポーネント名, {propsを表すハッシュ}) %>

ここで、呼び出すコンポーネント名ですが、うちの職場で付箋がたくさん貼ってある場所といえば、ホワイトボードなので、WhiteBoardという名前で作ることにしました。

ということで、以下のコマンドで最初のコンポーネントを作ってみます。

shell
# propsとしてtitleを受け取るWhiteBoardという名前のReactコンポーネントを作ってください。という意味です。
# propsは後から、色々追加になると思いますが、最初の段階ではなかなか思いつかないもの。。
bundle exec rails g react:component WhiteBoard title:string

うまくいったら、実際に表示できるのか試しておきます。
。。。そういえば、APIのことばかりやってて、UIのためのコントローラがない!!
ので、追加します。

shell
# コントローラ名もWhiteBoardとし、アクション名は適当に"main"としてみました。
bundle exec rails g controller WhiteBoard main

続いて、作成されたmain.html.erbを以下のように書き換えて、WhiteBoardコンポーネントを呼び出してもらうようにします。

app/views/white_board/main.html.erb
<%= react_component('WhiteBoard', { title: 'You can let others do your task' }) %>

ここで、一応動作確認してみます。
今まで、テストで確認してましたが、画面でみてみないと不安な事ってありますね。

shell
# サーバを起動
bundle exec rails s

起動できたら、localhost:3000/white_board/mainにアクセスしてみます。
スクリーンショット 2020-05-14 15.30.19.png
おぉ、ちょっと時間かかったけど表示できた。
確認できたら、Ctrl+Cでサーバを停止しちゃいます。

中身を書いていきます。

先ほどまでの手順でWhiteBoardコンポーネントというのを作成しています。
が、今回はさらに、以下のコンポーネントを追加します。

コンポーネント名 説明
UserBox ユーザ毎の枠(箱?)
Sticky タスクを表示するもの(付箋)

それぞれ、以下のコマンドで追加できます。
bundle exec rails g react:component UserBox
bundle exec rails g react:component Sticky

WhiteBoardコンポーネントの実装

WhiteBoardでは、主にAPIとのやりとりと、UserBoxの表示までを行うようにしてみました。
実装は以下のようになりました。(長いです。)

WthiteBoard.js
import React from "react"
import PropTypes from "prop-types"

// 自作コンポーネントはこのように呼び出せます。
import UserBox from "./UserBox"

class WhiteBoard extends React.Component {
  // コンストラクタ
  constructor(props) {
    // おまじない
    super(props);

    // stateの初期化
    this.state = { users: {}, loading: true, dropHandlers: {}, need_render: false };

    // イベントハンドラのバインド
    this.dropHandlerRegister = this.dropHandlerRegister.bind(this);
    this.onTaskDrop = this.onTaskDrop.bind(this);

  }

  // コンポーネントがマウントされたらデータの取得にいきます。
  componentDidMount() {
    this.getData();
  }

  // need_renderがtrueの場合だけレンダリングを行うようにしました。
  shouldComponentUpdate(nextProps, nextState){
    if (nextState.need_render) {
      return true;
    }

    console.log("** skip rendering **");
    return false;

  }

  // propsで指定されたURLに向かってユーザ毎のタスク一覧をくださいとリクエストを投げます。
  getData() {
    fetch(this.props.user_tasks_url)
      .then((response) => response.json())
      .then((json) => {
        // うまくいったら表示データを更新します。
        this.setState({users: json.users, loading: false, need_render: true});
      })
      .catch((response) => {
        console.log('** error **');
      })
  }

  // ユーザの変更をDBに通知します。
  callSwitchUser(task_id, user_id) {
    var switch_info = { switch_info: { task_id: task_id, user_id: user_id } };

    // APIとして作成したswitch_userアクションを呼び出します。
    // propでもらったCSRFトークンをリクエストヘッダに含めることで、更新リクエストを可能としています。
    // エラー処理がログ吐くだけというお粗末なものですが、すいません。
    fetch(this.props.switch_user_url, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json; charset=utf-8",
        "X-CSRF-Token": this.props.secure_token
      },
      body: JSON.stringify(switch_info)
    })
    .then(response => response.json())
    .then(json => console.log(JSON.stringify(json)))
    .catch(error_response => console.log(error_response));

  }

  // 各UserBoxにStickyがドロップされた際の処理を登録する処理です。
  dropHandlerRegister(user_id, func) {
    var handlers = this.state.dropHandlers;

    // 該当ユーザIDのハンドラが存在しなければ、stateに追加します。
    // このstate変更による再レンダリングは不要なため、need_renerにはfalseを設定しておきます。
    if ( ! handlers[user_id] ) {
      handlers[user_id] = func;
      this.setState({dropHandlers: handlers, need_render: false});
    }

  }

  // Stickyがドロップされた際のイベント処理です。
  onTaskDrop(prev_user_id, next_user_id, task) {
    // 各UserBoxのハンドラを呼び出します。
    Object.keys(this.state.dropHandlers).map((key) => {
      this.state.dropHandlers[key](prev_user_id, next_user_id, task);
    });

    // swich_userアクションを呼んで更新を反映します。
    this.callSwitchUser(task.id, next_user_id);

  }

  // レンダラーです。
  // ユーザ毎にUserBoxを生成しています。
  // dropHandlerRegsterはonTaskDrop時に呼び出す関数を登録してもらうための関数です。(わかりづらくてすみません。。)
  // onTaskDropは、UserBox内でStickyがドロップされた時に呼び出(CallBack)してもらう関数です。
  // ちなみに、ループして同じコンポーネントをいくつも使う時は、key属性に一意の値を設定しなければなりませんので、ここではユーザIDを設定しています。
  render () {
    return (
      <React.Fragment>
        <div id="WhiteBoardTitle">{this.props.title}</div>
        { ! this.state.loading && this.state.users.map((user) => <UserBox user={user} key={user.id} dropHandlerRegister={this.dropHandlerRegister} onTaskDrop={this.onTaskDrop} /> )}
      </React.Fragment>
    );
  }
}

// この下に型チェック用の記述がありましたが、削除してしまいました。

export default WhiteBoard

この実装に合わせて、viewから呼び出す際の引数も以下のように変更しました。
ユーザ一覧取得APIとユーザ切り替えAPIのURLを渡すようにしています。
さらに、CSRFトークンも渡しています。(form_authenticity_token()が出力してくれます。)

app/views/white_board/main.html.erb
<%= react_component('WhiteBoard', { 
  title: 'You can let others do your task', 
  user_tasks_url: api_users_user_task_list_url(:json), 
  switch_user_url: api_tasks_switch_user_url(:json),
  secure_token: form_authenticity_token
}) %>

UserBoxコンポーネントの実装

続いて、ユーザ事のタスク一覧を表示するUserBoxの実装です。
ここは、付箋が貼り付けられた(Stickyがドロップされた)場合の処理がちょっとトリッキーです。

app/javascript/components/UserBox.js
import React from "react"
import PropTypes from "prop-types"
import Sticky from "./Sticky"

// ユーザ毎の箱を表示します。
class UserBox extends React.Component {

  // コンストラクタです。
  constructor(props) {
    // おまじない。
    super(props);

    // 一つもタスクを持たないユーザの場合、user.tasksがnullになってしまうため、
    // nullの場合は空のハッシュを割り当てています。
    var tasks = this.props.user.tasks ? this.props.user.tasks : {};

    // タスクのリストをstateに突っ込みます。
    this.state = { tasks: tasks };

    // イベントハンドラのバインド
    this.onDrop = this.onDrop.bind(this);
    this.updateTaskList = this.updateTaskList.bind(this);
    this.preventDefault = this.preventDefault.bind(this);

    // WhiteBoardに対して、自身のupdateTaskList関数を登録します。
    // (これにより、タスクの所有者変更を通知してもらおうという算段です。)
    this.props.dropHandlerRegister(this.props.user.id, this.updateTaskList);

  }

  // ドラッグオーバー時の通常イベント処理を抑止するための処理です。
  // これやらないとドロップできないようです。
  preventDefault(event) {
    event.preventDefault();
  }

  // ドロップイベント処理
  onDrop(event) {
    // dataTransferにセットされたデータ(変更前ユーザIDと対象タスク情報)を取得します。
    var dropData = JSON.parse(event.dataTransfer.getData('text/plain'));

    // WhiteBoardのonTaskDropを呼び出してあげます。
    // こうすると、WhiteBoardからupdateTaskListが呼ばれるのでした。 
    this.props.onTaskDrop(dropData.now_user_id, this.props.user.id, dropData.task);

  }

  // タスク一覧更新処理
  // prev_user_id: 以前のユーザID
  // next_user_id: 変更後のユーザID
  // task: 対象タスク
  updateTaskList(prev_user_id, next_user_id, task) {
    // ユーザIDが変わらない時は何もしません。
    if (prev_user_id == next_user_id) {
      return;
    }

    // 以前のユーザIDと自分のユーザIDが等しい時。
    // それは、自分からそのタスクを削除する時です。
    if (prev_user_id == this.props.user.id) {
      // 自分のタスクを押し付けたので、自分のタスク一覧から削除しよう。
      this.deleteTask(task.id);
    }

    // 変更後のユーザIDが自身のユーザIDの時。
    // それはあなたに仕事が押し付けられた時です。
    if (next_user_id == this.props.user.id) {
      // 押し付けられた仕事を自分のタスク一覧に追加しよう。
      this.addTask(task);
    } 

  }

  // タスク削除処理
  deleteTask(task_id) {
    var tasks = this.state.tasks;

    // 削除対象IDのタスクをリストから削除します。
    // タスク一覧をKey-Value形式で持ってたのはこのためです。
    // ハッシュにしておくことで検索する手間を省いてます。
    delete tasks[task_id];

    // stateを更新します。(これで再描画してもらおう)
    this.setState({tasks: tasks});

  }

  // タスク追加処理
  addTask(task) {
    var tasks = this.state.tasks;

    // イヤイヤながらタスクを追加します。
    tasks[task.id] = task;

    // stateを更新します。(これで再描画してもらおう)
    this.setState({tasks: tasks});

  }

  // レンダラーです。
  render () {
    return (
      <React.Fragment>
        <div id={"user-" + this.props.user.id} className="UserBox" onDrop={this.onDrop} onDragOver={this.preventDefault} >
          <div className="UserName">{this.props.user.name}</div>
          <div className="TaskArea">
            { Object.keys(this.state.tasks).map((key) => <Sticky user_id={this.props.user.id} task={ this.state.tasks[key] } key={ key } /> ) }
          </div>
        </div>
      </React.Fragment>
    );
  }
}

export default UserBox

Stickyコンポーネントの実装

最後にタスクの内容を表示するStickyの実装です。
大事なのは、ドラッグ開始時にdataTranser.setDataを行っていることです。
これにより、drop時のデータ引継ぎができます。

app/javascript/components/Sticky.js
import React from "react"
import PropTypes from "prop-types"

class Sticky extends React.Component {
  // コンストラクタです。
  constructor(props) {
    // おまじないです。
    super(props);

    // ドラッグ開始イベントハンドラをバインドします。
    this.onDragStart = this.onDragStart.bind(this);

  }

  // ドラッグ開始イベントハンドラ
  onDragStart(event) {
    // ドラッグを開始したら、現在のpropsに設定されたユーザIDとタスク情報をJSON形式のテキストデータに直してdataTransferにセットします。
    // text/plainですが、JSON.stringify()を使うことでハッシュデータを引き継ぐことができます。
    // (https://stackoverflow.com/questions/9533585/drag-drop-html-5-jquery-e-datatransfer-setdata-with-json)
    var dragData = { now_user_id: this.props.user_id, task: this.props.task };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  // レンダラです。
  render () {
    return (
      <React.Fragment>

        <div id={"task-" + this.props.task.id} className="Sticky" draggable="true" onDragStart={this.onDragStart} >
          <div className="TaskTitle">{this.props.task.title}</div>
          <div className="TaskDescription">{this.props.task.description}</div>
          <div className="TaskDueDate">{this.props.task.due_date}</div>
        </div>

      </React.Fragment>
    );
  }
}

export default Sticky

軽く動作確認

まずは実際に画面表示してみる。

ここまでで、DBからユーザ情報とそれぞれのタスク情報を取得して画面に表示する流れと、
タスクの書かれた付箋をドラッグドロップして所有者を変更する流れが出来上がっているはずです。
ということで、動作確認用にサーバを起動(bundle exec rails s)して、テスト用ページ(localhost:3000/white_board/main)にアクセスしてみましょう。

スクリーンショット 2020-05-15 14.57.48.png

・・・あ、味気ねぇが、一応ドラッグドロップも動きます。
seedでデータを用意しておいたので、こういうときに簡単に確認できますね。

テストしてみます。

ドラッグドロップなんてテストできるの?
Rails環境にはCapybaraが住んでいるのでできます。

実際のUIを使ってテストするので、systemテスト扱いかなぁ、とシステムテストを追加します。

shell
# WhiteBoardのテストということで、whiteboardsにしてみました。
bundle exec rails g system_test whiteboards

以下のテストを追加しました。

test/system/whiteboards_test.rb
  test "sticky is able to drag and drop" do
    # fixutreで登録したデータを取得しておきます。
    alice = users(:alice);
    bob = users(:bob);
    task2 = tasks(:task2);

    # divのidを設定します。(idを設定しておくことで、テストが格段に楽になりますね。)
    task2_id = "task-" + task2.id.to_s;
    bob_id = "user-" + bob.id.to_s;
    alice_id = "user-" + alice.id.to_s;

    # white_board/mainを開く。
    visit white_board_main_url;

    # 一応各ユーザのタスク数を確認しておきます。
    assert_equal(1, alice.tasks.count);
    assert_equal(0, bob.tasks.count);

    # task2を表示しているエレメントを取得
    div_task2 = find(id: task2_id);

    # bobのUserBoxを表示しているエレメントを取得
    div_bob = find(id: bob_id);

    # aliceのUserBoxを表示しているエレメントを取得
    div_alice = find(id: alice_id);

    # aliceのタスクは1つ、bobのタスクはなし。
    div_alice.assert_selector("div", class: "Sticky", count: 1);
    div_bob.assert_selector("div", class: "Sticky", count: 0);

    # alice said "Hey bob, I think you want to do my job 'task2'."
    # drag_to(ドラッグ先エレメント)
    div_task2.drag_to(div_bob);

    # タスク所有者が入れ替わったことを確認
    div_alice.assert_selector("div", class: "Sticky", count: 0);
    div_bob.assert_selector("div", class: "Sticky", count: 1);

    # 本当かどうか、スクリーンショットを撮ってもらう。
    take_screenshot();

    # データをリロードしてDBにも反映されたことを確認
    alice.tasks.reload;
    bob.tasks.reload;
    assert_equal(0, alice.tasks.count);
    assert_equal(1, bob.tasks.count);

  end

UIがテストできると言うのは素晴らしいですね。

それにしても、見た目が寂しいので次回はスタイルを直してみます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6+Reactで付箋アプリっぽいページを作ってみた。3 (UI作成編1)

この記事について

前回までRailsを利用してバックエンド側を作り込んでみたので、フロントエンド側の実装に入っていきたいと思います。
果たして、分量が多くなりすぎるのではないかと心配してますが、きっと多くなります。

関連する記事

書いているうちに分量がすごくなって記事を分割したので、リンク先をまとめておきます。
その1(環境構築〜モデル作成編)
その2(API作成編)
その3(UI作成編1)
その4(UI作成編2)
おまけ(モデルのテスト編)

まずは基点となるコンポーネントを追加するのじゃ。

react-railsを使う場合、viewのテンプレートで以下のようにreact_component()メソッドを呼んで、Reactコンポーネントのレンダリングを行います。
<%= react_component(コンポーネント名, {propsを表すハッシュ}) %>

ここで、呼び出すコンポーネント名ですが、うちの職場で付箋がたくさん貼ってある場所といえば、ホワイトボードなので、WhiteBoardという名前で作ることにしました。

ということで、以下のコマンドで最初のコンポーネントを作ってみます。

shell
# propsとしてtitleを受け取るWhiteBoardという名前のReactコンポーネントを作ってください。という意味です。
# propsは後から、色々追加になると思いますが、最初の段階ではなかなか思いつかないもの。。
bundle exec rails g react:component WhiteBoard title:string

うまくいったら、実際に表示できるのか試しておきます。
。。。そういえば、APIのことばかりやってて、UIのためのコントローラがない!!
ので、追加します。

shell
# コントローラ名もWhiteBoardとし、アクション名は適当に"main"としてみました。
bundle exec rails g controller WhiteBoard main

続いて、作成されたmain.html.erbを以下のように書き換えて、WhiteBoardコンポーネントを呼び出してもらうようにします。

app/views/white_board/main.html.erb
<%= react_component('WhiteBoard', { title: 'You can let others do your task' }) %>

ここで、一応動作確認してみます。
今まで、テストで確認してましたが、画面でみてみないと不安な事ってありますね。

shell
# サーバを起動
bundle exec rails s

起動できたら、localhost:3000/white_board/mainにアクセスしてみます。
スクリーンショット 2020-05-14 15.30.19.png
おぉ、ちょっと時間かかったけど表示できた。
確認できたら、Ctrl+Cでサーバを停止しちゃいます。

中身を書いていきます。

先ほどまでの手順でWhiteBoardコンポーネントというのを作成しています。
が、今回はさらに、以下のコンポーネントを追加します。

コンポーネント名 説明
UserBox ユーザ毎の枠(箱?)
Sticky タスクを表示するもの(付箋)

それぞれ、以下のコマンドで追加できます。
bundle exec rails g react:component UserBox
bundle exec rails g react:component Sticky

WhiteBoardコンポーネントの実装

WhiteBoardでは、主にAPIとのやりとりと、UserBoxの表示までを行うようにしてみました。
実装は以下のようになりました。(長いです。)

WthiteBoard.js
import React from "react"
import PropTypes from "prop-types"

// 自作コンポーネントはこのように呼び出せます。
import UserBox from "./UserBox"

class WhiteBoard extends React.Component {
  // コンストラクタ
  constructor(props) {
    // おまじない
    super(props);

    // stateの初期化
    this.state = { users: {}, loading: true, dropHandlers: {}, need_render: false };

    // イベントハンドラのバインド
    this.dropHandlerRegister = this.dropHandlerRegister.bind(this);
    this.onTaskDrop = this.onTaskDrop.bind(this);

  }

  // コンポーネントがマウントされたらデータの取得にいきます。
  componentDidMount() {
    this.getData();
  }

  // need_renderがtrueの場合だけレンダリングを行うようにしました。
  shouldComponentUpdate(nextProps, nextState){
    if (nextState.need_render) {
      return true;
    }

    console.log("** skip rendering **");
    return false;

  }

  // propsで指定されたURLに向かってユーザ毎のタスク一覧をくださいとリクエストを投げます。
  getData() {
    fetch(this.props.user_tasks_url)
      .then((response) => response.json())
      .then((json) => {
        // うまくいったら表示データを更新します。
        this.setState({users: json.users, loading: false, need_render: true});
      })
      .catch((response) => {
        console.log('** error **');
      })
  }

  // ユーザの変更をDBに通知します。
  callSwitchUser(task_id, user_id) {
    var switch_info = { switch_info: { task_id: task_id, user_id: user_id } };

    // APIとして作成したswitch_userアクションを呼び出します。
    // propでもらったCSRFトークンをリクエストヘッダに含めることで、更新リクエストを可能としています。
    // エラー処理がログ吐くだけというお粗末なものですが、すいません。
    fetch(this.props.switch_user_url, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json; charset=utf-8",
        "X-CSRF-Token": this.props.secure_token
      },
      body: JSON.stringify(switch_info)
    })
    .then(response => response.json())
    .then(json => console.log(JSON.stringify(json)))
    .catch(error_response => console.log(error_response));

  }

  // 各UserBoxにStickyがドロップされた際の処理を登録する処理です。
  dropHandlerRegister(user_id, func) {
    var handlers = this.state.dropHandlers;

    // 該当ユーザIDのハンドラが存在しなければ、stateに追加します。
    // このstate変更による再レンダリングは不要なため、need_renerにはfalseを設定しておきます。
    if ( ! handlers[user_id] ) {
      handlers[user_id] = func;
      this.setState({dropHandlers: handlers, need_render: false});
    }

  }

  // Stickyがドロップされた際のイベント処理です。
  onTaskDrop(prev_user_id, next_user_id, task) {
    // 各UserBoxのハンドラを呼び出します。
    Object.keys(this.state.dropHandlers).map((key) => {
      this.state.dropHandlers[key](prev_user_id, next_user_id, task);
    });

    // swich_userアクションを呼んで更新を反映します。
    this.callSwitchUser(task.id, next_user_id);

  }

  // レンダラーです。
  // ユーザ毎にUserBoxを生成しています。
  // dropHandlerRegsterはonTaskDrop時に呼び出す関数を登録してもらうための関数です。(わかりづらくてすみません。。)
  // onTaskDropは、UserBox内でStickyがドロップされた時に呼び出(CallBack)してもらう関数です。
  // ちなみに、ループして同じコンポーネントをいくつも使う時は、key属性に一意の値を設定しなければなりませんので、ここではユーザIDを設定しています。
  render () {
    return (
      <React.Fragment>
        <div id="WhiteBoardTitle">{this.props.title}</div>
        { ! this.state.loading && this.state.users.map((user) => <UserBox user={user} key={user.id} dropHandlerRegister={this.dropHandlerRegister} onTaskDrop={this.onTaskDrop} /> )}
      </React.Fragment>
    );
  }
}

// この下に型チェック用の記述がありましたが、削除してしまいました。

export default WhiteBoard

この実装に合わせて、viewから呼び出す際の引数も以下のように変更しました。
ユーザ一覧取得APIとユーザ切り替えAPIのURLを渡すようにしています。
さらに、CSRFトークンも渡しています。(form_authenticity_token()が出力してくれます。)

app/views/white_board/main.html.erb
<%= react_component('WhiteBoard', { 
  title: 'You can let others do your task', 
  user_tasks_url: api_users_user_task_list_url(:json), 
  switch_user_url: api_tasks_switch_user_url(:json),
  secure_token: form_authenticity_token
}) %>

UserBoxコンポーネントの実装

続いて、ユーザ事のタスク一覧を表示するUserBoxの実装です。
ここは、付箋が貼り付けられた(Stickyがドロップされた)場合の処理がちょっとトリッキーです。

app/javascript/components/UserBox.js
import React from "react"
import PropTypes from "prop-types"
import Sticky from "./Sticky"

// ユーザ毎の箱を表示します。
class UserBox extends React.Component {

  // コンストラクタです。
  constructor(props) {
    // おまじない。
    super(props);

    // 一つもタスクを持たないユーザの場合、user.tasksがnullになってしまうため、
    // nullの場合は空のハッシュを割り当てています。
    var tasks = this.props.user.tasks ? this.props.user.tasks : {};

    // タスクのリストをstateに突っ込みます。
    this.state = { tasks: tasks };

    // イベントハンドラのバインド
    this.onDrop = this.onDrop.bind(this);
    this.updateTaskList = this.updateTaskList.bind(this);
    this.preventDefault = this.preventDefault.bind(this);

    // WhiteBoardに対して、自身のupdateTaskList関数を登録します。
    // (これにより、タスクの所有者変更を通知してもらおうという算段です。)
    this.props.dropHandlerRegister(this.props.user.id, this.updateTaskList);

  }

  // ドラッグオーバー時の通常イベント処理を抑止するための処理です。
  // これやらないとドロップできないようです。
  preventDefault(event) {
    event.preventDefault();
  }

  // ドロップイベント処理
  onDrop(event) {
    // dataTransferにセットされたデータ(変更前ユーザIDと対象タスク情報)を取得します。
    var dropData = JSON.parse(event.dataTransfer.getData('text/plain'));

    // WhiteBoardのonTaskDropを呼び出してあげます。
    // こうすると、WhiteBoardからupdateTaskListが呼ばれるのでした。 
    this.props.onTaskDrop(dropData.now_user_id, this.props.user.id, dropData.task);

  }

  // タスク一覧更新処理
  // prev_user_id: 以前のユーザID
  // next_user_id: 変更後のユーザID
  // task: 対象タスク
  updateTaskList(prev_user_id, next_user_id, task) {
    // ユーザIDが変わらない時は何もしません。
    if (prev_user_id == next_user_id) {
      return;
    }

    // 以前のユーザIDと自分のユーザIDが等しい時。
    // それは、自分からそのタスクを削除する時です。
    if (prev_user_id == this.props.user.id) {
      // 自分のタスクを押し付けたので、自分のタスク一覧から削除しよう。
      this.deleteTask(task.id);
    }

    // 変更後のユーザIDが自身のユーザIDの時。
    // それはあなたに仕事が押し付けられた時です。
    if (next_user_id == this.props.user.id) {
      // 押し付けられた仕事を自分のタスク一覧に追加しよう。
      this.addTask(task);
    } 

  }

  // タスク削除処理
  deleteTask(task_id) {
    var tasks = this.state.tasks;

    // 削除対象IDのタスクをリストから削除します。
    // タスク一覧をKey-Value形式で持ってたのはこのためです。
    // ハッシュにしておくことで検索する手間を省いてます。
    delete tasks[task_id];

    // stateを更新します。(これで再描画してもらおう)
    this.setState({tasks: tasks});

  }

  // タスク追加処理
  addTask(task) {
    var tasks = this.state.tasks;

    // イヤイヤながらタスクを追加します。
    tasks[task.id] = task;

    // stateを更新します。(これで再描画してもらおう)
    this.setState({tasks: tasks});

  }

  // レンダラーです。
  render () {
    return (
      <React.Fragment>
        <div id={"user-" + this.props.user.id} className="UserBox" onDrop={this.onDrop} onDragOver={this.preventDefault} >
          <div className="UserName">{this.props.user.name}</div>
          <div className="TaskArea">
            { Object.keys(this.state.tasks).map((key) => <Sticky user_id={this.props.user.id} task={ this.state.tasks[key] } key={ key } /> ) }
          </div>
        </div>
      </React.Fragment>
    );
  }
}

export default UserBox

Stickyコンポーネントの実装

最後にタスクの内容を表示するStickyの実装です。
大事なのは、ドラッグ開始時にdataTranser.setDataを行っていることです。
これにより、drop時のデータ引継ぎができます。

app/javascript/components/Sticky.js
import React from "react"
import PropTypes from "prop-types"

class Sticky extends React.Component {
  // コンストラクタです。
  constructor(props) {
    // おまじないです。
    super(props);

    // ドラッグ開始イベントハンドラをバインドします。
    this.onDragStart = this.onDragStart.bind(this);

  }

  // ドラッグ開始イベントハンドラ
  onDragStart(event) {
    // ドラッグを開始したら、現在のpropsに設定されたユーザIDとタスク情報をJSON形式のテキストデータに直してdataTransferにセットします。
    // text/plainですが、JSON.stringify()を使うことでハッシュデータを引き継ぐことができます。
    // (https://stackoverflow.com/questions/9533585/drag-drop-html-5-jquery-e-datatransfer-setdata-with-json)
    var dragData = { now_user_id: this.props.user_id, task: this.props.task };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  // レンダラです。
  render () {
    return (
      <React.Fragment>

        <div id={"task-" + this.props.task.id} className="Sticky" draggable="true" onDragStart={this.onDragStart} >
          <div className="TaskTitle">{this.props.task.title}</div>
          <div className="TaskDescription">{this.props.task.description}</div>
          <div className="TaskDueDate">{this.props.task.due_date}</div>
        </div>

      </React.Fragment>
    );
  }
}

export default Sticky

軽く動作確認

まずは実際に画面表示してみる。

ここまでで、DBからユーザ情報とそれぞれのタスク情報を取得して画面に表示する流れと、
タスクの書かれた付箋をドラッグドロップして所有者を変更する流れが出来上がっているはずです。
ということで、動作確認用にサーバを起動(bundle exec rails s)して、テスト用ページ(localhost:3000/white_board/main)にアクセスしてみましょう。

スクリーンショット 2020-05-15 14.57.48.png

・・・あ、味気ねぇが、一応ドラッグドロップも動きます。
seedでデータを用意しておいたので、こういうときに簡単に確認できますね。

テストしてみます。

ドラッグドロップなんてテストできるの?
Rails環境にはCapybaraが住んでいるのでできます。

実際のUIを使ってテストするので、systemテスト扱いかなぁ、とシステムテストを追加します。

shell
# WhiteBoardのテストということで、whiteboardsにしてみました。
bundle exec rails g system_test whiteboards

以下のテストを追加しました。

test/system/whiteboards_test.rb
  test "sticky is able to drag and drop" do
    # fixutreで登録したデータを取得しておきます。
    alice = users(:alice);
    bob = users(:bob);
    task2 = tasks(:task2);

    # divのidを設定します。(idを設定しておくことで、テストが格段に楽になりますね。)
    task2_id = "task-" + task2.id.to_s;
    bob_id = "user-" + bob.id.to_s;
    alice_id = "user-" + alice.id.to_s;

    # white_board/mainを開く。
    visit white_board_main_url;

    # 一応各ユーザのタスク数を確認しておきます。
    assert_equal(1, alice.tasks.count);
    assert_equal(0, bob.tasks.count);

    # task2を表示しているエレメントを取得
    div_task2 = find(id: task2_id);

    # bobのUserBoxを表示しているエレメントを取得
    div_bob = find(id: bob_id);

    # aliceのUserBoxを表示しているエレメントを取得
    div_alice = find(id: alice_id);

    # aliceのタスクは1つ、bobのタスクはなし。
    div_alice.assert_selector("div", class: "Sticky", count: 1);
    div_bob.assert_selector("div", class: "Sticky", count: 0);

    # alice said "Hey bob, I think you want to do my job 'task2'."
    # drag_to(ドラッグ先エレメント)
    div_task2.drag_to(div_bob);

    # タスク所有者が入れ替わったことを確認
    div_alice.assert_selector("div", class: "Sticky", count: 0);
    div_bob.assert_selector("div", class: "Sticky", count: 1);

    # 本当かどうか、スクリーンショットを撮ってもらう。
    take_screenshot();

    # データをリロードしてDBにも反映されたことを確認
    alice.tasks.reload;
    bob.tasks.reload;
    assert_equal(0, alice.tasks.count);
    assert_equal(1, bob.tasks.count);

  end

UIがテストできると言うのは素晴らしいですね。

それにしても、見た目が寂しいので次回はスタイルを直してみます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6+Reactで付箋アプリっぽいページを作ってみた。2 (API作成編)

記事について

前回、モデルの作成まで行ったので、Reactで作ったUIとやり取りするためのAPIを用意してみます。

ここで作成するAPIは以下の二つにします。(作成とか削除とかはまた今度。。)
・ユーザ毎のタスク一覧の取得
・タスクの押し付け・・・もとい、タスク所有ユーザの更新

関連する記事

書いているうちに分量がすごくなって記事を分割したので、リンク先をまとめておきます。
その1(環境構築〜モデル作成編)
その2(API作成編)
その3(UI作成編1)
その4(UI作成編2)
おまけ(モデルのテスト編)

ユーザ毎タスク一覧の取得

RailsでAPIを作成する際に作るのはControllerとViewです。
ユーザの一覧を取得したいので、"api/users/user_task_list"のようにアクセスできればなと。

コントローラの生成

ということで、コントローラを生成してもらいます。

shell
# 以下のコマンドで、viewの追加やroute.rbの編集も行ってくれます。
# APIにスタイルはいらないので、--no-stylesheetsをつけてみました。
bundle exec rails g controller API::Users user_task_list --no-stylesheets

コントローラの編集

生成されたコントローラに実際に処理を追加してみます。

app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
  def user_task_list
    # ユーザ情報を取得してインスタンス変数に格納しておく。
    @users = User.all(); # <= 追加する処理
  end
end

jbuilderファイルの作成

React用のAPIなのでデータのやりとりは、JSONで行うのが楽です。
ということで、ユーザデータをJSONで出力するためのテンプレートファイルを追加してあげます。
名前は、app/views/api/users/list.json.jbuilderとなります。

app/views/api/users/user_task_list.json.jbuilder
# jbuilderでは好きな形でデータが出力できます。
json.users @users do | user |
  json.id(user.id);
  json.name(user.name);
  json.tasks do
    user.tasks.each do | task |
      json.set! task.id do
        json.id(task.id);
        json.title(task.title);
        json.description(task.description);
        json.due_date(task.due_date.strftime("%Y-%m-%d"));
      end
    end
  end
end

# こんなイメージのjsonデータになります。
# あとで、IDをキーにタスクデータを取得したいので、ちょっと変な形になってます。
# {
#   users: [ {
#        id: 1
#        name: "Not Assigned"
#        tasks: { 
#          "1" : { id: 1, title: "test001", description: ...},
#          "3" : { id: 3, title: "test003", description: ...}
#        }
#      }, {
#        id: 2
#        name: "Alice"
#        tasks: { 
#          "2" : { id: 2, title: "test002", description: ...}
#        }
#      }
#   ]
# }

テストしてみます。

これで、ユーザ一覧をJSONデータとして出力できるようになったはず。
と言うことで、テストしてみます。
ここでは、以下のようなテストコードをテストコードに追加してみました。

test/controllers/api/users_controller_test.rb(抜粋です。)
  test "should get user_task_list.json" do
    # jsonフォーマット指定でGETリクエストを投げます。
    get api_users_user_task_list_url(:json)

    # 200OKが返ってくるはず。
    assert_response :success

    # Boby部をJSONデータに変換
    json_data = ActiveSupport::JSON.decode(@response.body);

    # usersの配列の長さは3のはず(fixtureで3人作っていれば)
    assert_equal(3, json_data['users'].length);

  end

テスト実行

shell
# うまくいったら喜ぼう。
bundle exec rails test test/controllers/api/users_controller_test.rb

タスクの押し付け

それでは、タスク所有ユーザ切り替え用のAPIを準備していきます。

コントローラの生成

ということで、コントローラの作成です。
内部的にはタスクのユーザIDを切り替えるので、'switch_user'の名前でアクションを追加します。

shell
bundle exec rails g controller API::Tasks switch_user --no-stylesheets

# おや、なんか変だ。。
# route.rbに"get 'tasks/switch_user'"を追加したと言っている。
      create  app/controllers/api/tasks_controller.rb
       route  namespace :api do
  get 'tasks/switch_user'
end

route.rbの修正

先ほどの結果で、switch_userアクションの呼び出しメソッドが"GET"になってしまったので、route.rbを修正します。(更新ならPUTがいいかな?と。)

config/route.rb
  # 変更箇所の前後のみ記載しております。
  namespace :api do
    put 'tasks/switch_user'  # <= getからputに変更します。
  end

コントローラの編集

それでは、追加されたtasks_controller.rbを編集して、switch_userアクションを実装します。
なお、ここで大事になるのはリクエストデータのもらい方。
今回は、以下のような形でもらうことを想定した実装としています。

リクエストデータイメージ
{
  "switch_info": { 
    "task_id": 1, 
    "user_id": 2 
  }
}

switch_userアクションの内容は、以下のとおり、変更対象のタスクのユーザデータを入れ替えて、更新する処理となります。
後々、エラー処理がしやすいように、エラーメッセージを返すための仕組みも入れてあります。

app/controllers/api/tasks_controller.rb(抜粋です。)
  # タスク押し付け処理
  def switch_user
    # エラーメッセージを入れるための配列を用意しておきます。
    @errors = [];

    # 許可済みのパラメータを生成します。(switch_uesr_params()というprivateメソッドを追加しておきました。)
    suparam = switch_user_params();

    begin
      # タスクデータを取得
      @task = Task.find(suparam[:task_id]);

      # ユーザデータを取得
      @user = User.find(suparam[:user_id]);

      # タスク所有者を変更
      @task.user = @user;

      # タスクデータを更新
      if ! @task.save then
        # 失敗したら、show_errorというテンプレートを使ってエラー情報を返すようにしました。
        @errors.push("failed to switch user: task.save() faield");
        render :show_error
      end

    rescue => ex
      # 例外発生時もshow_errorというテンプレートを使って例外メッセージを返すようにしました。
      @errors.push("failed to switch user: an exception occurred.");
      @errors.push(ex.to_s);
      render :show_error
    end

  end

  private
    # task_idとuser_idだけを許可済みパラメータとして返します。
    def switch_user_params()
      return params.require(:switch_info).permit(:task_id, :user_id);
    end

jbuilderファイルの準備

ここでは、処理結果をJSON形式で返すため、以下2つパターンでjbuilderファイルを用意します。
エラーがない場合は、更新後のタスクデータを。 => switch_user.json.jbuilder
エラーがあった場合は、エラーメッセージを。 => show_error.json.jbuilder

まず、更新成功時に返すswitch_user.json.jbuilder

app/views/api/tasks/switch_user.json.jbuilder
# task { id: 1, title: "hoge" ...}というような形で返します。
json.task do
  json.id(@task.id);
  json.title(@task.title);
  json.description(@task.description);
  json.due_date(@task.due_date.strftime("%Y-%m-%d %H:%M:%S"));
  json.user_id(@task.user.id);
end

次にエラー情報を返すshow_error.json.jbuilder

app/views/api/tasks/show_error.json.jbuilder
# @errorsと言う名前の配列にエラー情報を入れてあるので、その中身を出力してあげます。
json.errors @errors do | msg |
  json.message(msg);
end

テストしてみます。

これで、適切なパラメータでリクエストを投げれば、タスクの担当者が切り替わるはずです。
ので、やっぱりテストしてみます。

テスト内容はこちら!
・ユーザの切り替えがうまく行くこと。
・失敗したらエラー情報がもらえること。

test/controllers/api/tasks_controller_test.rb(抜粋です。)
  # 正常パターン
  test "should success to switch user" do
    notassigned = users(:notassigned);
    alice = users(:alice);
    task1 = notassigned.tasks[0];

    # aliceのタスクが増えるはず。
    assert_difference 'alice.tasks.count' do
      # リクエスト投げてみる。
      put(api_tasks_switch_user_url(:json), params: { switch_info: { task_id: task1.id, user_id: alice.id } });

      # 200OKのはず
      assert_response :success

      # 念のためリロード
      alice.tasks.reload();
      notassigned.tasks.reload();

    end

    # レスポンスから、JSONデータを取得
    json_data = ActiveSupport::JSON.decode(@response.body);

    # タスクデータのユーザIDがaliceのIDになるはず。
    assert_equal(alice.id, json_data['task']['user_id']);

    # notassignedさんのタスクが0個になるはず。
    assert_equal(0, notassigned.tasks.count);

  end

  # 存在しないユーザに切り替えるとエラーになるはず。
  test "should fail to swith user if user is not exist" do
    # 新しいユーザを作る。が、保存しない。
    new_user = User.new(name: "Bab");

    # タスクデータのインスタンスを取得
    task2 = tasks(:task2);

    # 保存してないユーザに割り当ててみよう。
    put(api_tasks_switch_user_url(:json), params: { switch_info: { taks_id: task2.id, user_id: new_user.id } });

    # 一応200OK。
    assert_response :success

    json_data = ActiveSupport::JSON.decode(@response.body);

    # エラー情報が返されるはず。
    assert(json_data['errors'].count > 0);

  end

テストOKであれば、一応APIは出来上がりと言うことにしよう!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6+Reactで付箋アプリっぽいページを作ってみた。1 (環境構築〜モデル作成編)

手段のために目的を。。

職場で(家庭でも?)やらなきゃいけないこと(タスク)を付箋に書いて、見やすいところに貼り付けて管理している人って結構多いと思います。
そして、勝手に他人の机に自分の付箋を貼り付けて、仕事を押し付けたり、有料で取引したり。。。

そんなやりとりをWebアプリでできたら、メンバーの持っているタスクなどが共有できて面白いかも?
ということで、Reactの勉強がてら実際に作ってみた過程を共有させていただきたいと思います。

・・・というか、「RailsでReactを使って何かやってみたい」という手段を目的としているので、実用性は度外視。。。

関連する記事

書いているうちに分量がすごくなって記事を分割したので、リンク先をまとめておきます。
その1(環境構築〜モデル作成編)
その2(API作成編)
その3(UI作成編1)
その4(UI作成編2)
おまけ(モデルのテスト編)

使用環境

データベース周りの作り込みで楽したいので、Railsを利用しました。
この記事で使った環境は、以下の通りです。

component version
Rails 6.0.2
react-rails 2.6.1
Ruby 2.6.5

環境構築にあたっては、こちらを参考にしました。
GitHub:react-railsのページ

ついでに知ったこと

JSONでのデータ出力でハマる
number型の属性を渡せなくて困る

ラフなモデル設計

適当ですが、こんな設計になりますかね。
ユーザがいなくなったらタスクもいなくなる形なので不自然かもしれませんが。。。
スクリーンショット 2020-05-12 11.50.23.png

環境構築

申し訳ありませんが、インターネット接続が可能で、railsが利用できる環境であることを前提に記述させていただきます。

shell
# アプリケーションを作成(今回はstickiesAppという名前にしてみました。)
rails new stickiesApp

# 作成されたアプリケーションディレクトリに移動
cd stickiesApp

# react-railsのReadMeに従いGemfileを編集(今回はRails6だったので、gem 'react-rails'の追加のみ)

# bundle installします。
bundle install

# webpackerの準備(asset pipelineでのやり方をお探しの方、すみません。。)
bundle exec rails webpacker:install
bundle exec rails webpacker:install:react
bundle exec rails generate react:install

ラフなUI設計

ユーザ毎に箱があって、その中に付箋があるってイメージですね。
そうそう、ちょうどこんな風に。。って、アクティビティ図かい!!(すいません、近くにastah*があったもので。。)
スクリーンショット 2020-05-12 15.35.00.png

モデルを準備する。

先ほどのかなりラフなモデル設計から、ユーザモデルとタスクモデルを作ってみます。

ユーザモデル

どちらを先に作るか悩みましたが、ユーザから作ってみました。
ぶっちゃけ、どっちでも良いのだと思いますが。

shell
# 「ユーザ名」に対応する文字列型の要素を一つ持つ"User"モデルを作成します。
bundle exec rails g model User name:string

タスクモデル

続いてタスクモデルですね。
こちらは、どのユーザが所有しているかを示すための、user_id列が必要になるので、それも加味した形で作ります。

shell
# 「タイトル」「説明」「期限」に対応する列と、ユーザモデルと関連付けるための列を作成します。
bundle exec rails g model Task title:string description:text due_date:datetime user:belongs_to

モデルの関連付けを追加

「ユーザは複数のタスクを持つ」というモデル設計に合わせて、user.rbを以下のように修正します。

app/models/user.rb
class User < ApplicationRecord
  has_many :tasks  # taskをたくさん持ってますよと教えてあげます。
end

ちなみに、タスクモデルのほうは、自動的に以下のような内容になります。(便利♪)

app/models/task.rb
class Task < ApplicationRecord
  belongs_to :user
end

マイグレーションを実行

さて、準備も整ったのでマイグレーションを実行してしまいます。

shell
# マイグレーションの実行です。(すぐに忘れるコマンドの一つ。。)
bundle exec rails db:migrate

初期データの登録

ユーザやタスクの登録処理まで書いてると長くなりすぎるので、テスト的に使えそうな初期データを予め登録してしまおう!ということで、初期データ用のseedを準備します。

db/seed.rb
# ユーザの作成
user0 = User.create(name: 'Not Assigned');
user1 = User.create(name: 'User001');
user2 = User.create(name: 'User002');

# タスクの作成
Task.create(title: 'task001', description: '0001', due_date: Date.new(2020, 4, 30), user: user0);
Task.create(title: 'task002', description: '0002', due_date: Date.new(2020, 4, 30), user: user1);
Task.create(title: 'task003', description: '0003', due_date: Date.new(2020, 4, 30), user: user2);
Task.create(title: 'task004', description: '0004', due_date: Date.new(2020, 4, 30), user: user1);

準備できたらDBに投入してみます。

shell
# 初期データの投入(間違ってdb:seedsと書いて失敗するのは私だけ?)
bundle exec rails db:seed

。。。結果がいまいち分からない(何も言わずにコマンドが終わる)ので、DBを直接確認しておきました。

shell
# sqlite3でDBを開く
sqlite3 db/development.sqlite3

sqlite> select * from users;
1|Not Assigned|2020-05-13 00:21:00.654477|2020-05-13 00:21:00.654477
2|User001|2020-05-13 00:21:00.662086|2020-05-13 00:21:00.662086
3|User002|2020-05-13 00:21:00.668647|2020-05-13 00:21:00.668647

sqlite> select * from tasks;
1|task001|0001|2020-04-30 00:00:00|1|2020-05-13 00:21:00.766118|2020-05-13 00:21:00.766118
2|task002|0002|2020-04-30 00:00:00|2|2020-05-13 00:21:00.772826|2020-05-13 00:21:00.772826
sqlite> .quit

# おお、入ってた!

モデルができたら。。テストですね!

テストについては多いのでこちらにまとめました。
ので、ご参考にしていただければ。
とにかく、言いたいことは、テスト作らずに進めると痛い目に遭うということ。
Rubyにしろ、Railsにしろバージョンアップしたいとき、テストが作られてないと影響確認すらできませんからね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

華麗なるGatsby.jsの実践(styled-components/headの編集/404ページ/画像の使用)

以前 gatsbyの公式チュートリアルを意訳しつつやったので、今回は、実際に業務で使用するにあたって
使いそうな機能について公式のドキュメント等を調べてみました。

スクリーンショット 2020-05-15 14.10.20.png

この記事のタイトルをつけた後に気づいたんですが、
公式で上記の画像を発見しました。
華麗なるギャツビーがやはり由来なんでしょうか。

お品書き

実際業務で使いそうな機能をピックアップしたところ、以下のようになりました。
- styled-componentsを使いたい
- head修正できるようにしたい
- 404ページをカスタマイズしたい
- gatsbyでの画像の扱いを知りたい

今回はhttps://www.gatsbyjs.org/starters/gatsbyjs/gatsby-starter-default/
このスターターを使って進めます。

スターターを使用するために、以下のコマンドを実行します。

$ gatsby new app https://github.com/gatsbyjs/gatsby-starter-default
$ cd app
$ gatsby develop

styled-componentsを使いたい

公式参考ページはこちら

必要なプラグインを取得します。

$ npm install --save gatsby-plugin-styled-components styled-components babel-plugin-styled-components

babel-plugin-styled-componentsはstyled-componentsをより扱いやすくしてくれるpluginです。

https://github.com/styled-components/babel-plugin-styled-components

gatsby-config.jsに以下を書き加えます。

gatsby-config.js
module.exports = {
  plugins: [`gatsby-plugin-styled-components`],
}

あとは使うだけ。思ったよりも簡単だった。

index.html
import styled from "styled-components"


const IndexPage = () => (
  <div>
    <Title>宇宙の日記</Title>
  </div>
)

export default IndexPage

const Title = styled.h1`
  color: blue;
`

head修正できるようにしたい

公式の参考ページはこちら
実は上記スターターにはすでに組み込まれていますが、念の為最初から実装方法を確認します。

gatsbyではreact helmetがサポートされているのでそれを使う。

npm install --save gatsby-plugin-react-helmet react-helmet

gatsby-config.jsに下記2つをを加える。

gatsby-config.js
{
  plugins: [`gatsby-plugin-react-helmet`]
}
gatsby-config.js
module.exports = {
  siteMetadata: {
    title: `色々な紙飛行機`,
    description: `多種多様な紙飛行機。その世界に触れてみませんか?`,
    author: `@irico`,
  },

下記のようなcomponentを準備して...

src/components/SEO
import React from "react"
import { Helmet } from "react-helmet"
import { useStaticQuery, graphql } from "gatsby"

function SEO() {
  const { site } = useStaticQuery(
    graphql`
      query {
        site {
          siteMetadata {
            title
            description
          }
        }
      }
    `
  )

  return (
    <Helmet
      title={site.siteMetadata.title}
      meta={[
        {
          name: `description`,
          content: site.siteMetadata.description,
        },
      ]}
    />
  )
}

export default SEO

あとはpageで使うだけ!これも簡単。
ページごとにカスタマイズしたいのであれば、SEOのpropsとして渡して分岐させてあげればよし。

const IndexPage = () => {
        <Layout>
            <SEO />
            ...
        </Layout>
}

404ページをカスタマイズしたい

公式の参考ページはこちら

^\/?404\/?$ (/404/, /404, 404/ or 404)

上記の正規表現に該当するpageを作成すればいいだけ!

gatsby developコマンドでは下記の画像のようなページになりますが、 ビルド後はカスタマイズした404ページに飛ぶようになります。

実はこれも上記スターターで最初からページが用意されています(至れり尽くせり)

ローカルで404ページを確認したいときは、 Preview custom 404 pageを閲覧すればOK!
スクリーンショット 2020-05-13 18.19.35.png

gatsbyでの画像の扱いを知りたい

公式の参考サイトはこちら
普通のパス指定での読み方ももちろんできるが、webpackによるimportがオススメです。
ファイルをあたかもJSソースのように取得することができ、以下の恩恵が得られます。

  • webpackが圧縮をおこなってくれる
  • ユーザーに404エラーを出す前にコンパイルエラーが出る(チェック漏れを防ぐ)
  • ファイル名にハッシュが含まれるおかげで、ブラウザのキャッシュを防ぐ。
import kvImg from "../images/kv.jpg"

const IndexPage = () => (
  <Layout>
    <IndexWrapper>
       <img src={kvImg} alt="紙飛行機の画像" />
    </IndexWrapper>
  </Layout>
)

escape hatchあるよ!

アプリ下にstaticというファイルを作ってこのファイル内に画像などを置くと、publicフォルダー内にコピーされる。
そうすると呼び出さずに画像を使うことができる!

staticフォルダーにkv.jpgを配置したのち、

const IndexPage = () => (
  <Layout>
    <IndexWrapper>
       <img src='/kv.jpg' alt="紙飛行機の画像" />
    </IndexWrapper>
  </Layout>
)

勝手にコピーしてくれるので呼び出しは不要!

ただ、以下の短所がある。

  • ファイルが縮小されない
  • ユーザー側に404が表示される
  • コンテンツハッシュが含まれないため、キャッシュされてしまう

なので基本的にはJSを介してアセットを使うのがいいです。static folderが役に立つのは以下のような場合になります。

  • maifestなどの、特定のファイル名でなければならないもの

  • 画像がたくさんあり、パスを動的に参照する必要がある場合

  • Pace.jsのように、バンドルするコード外部に小規模のスクリプトを読み込みたい場合

  • webpackと互換性がないもの

etc..

gatsby-image

https://www.gatsbyjs.org/tutorial/gatsby-image-tutorial/#querying-data-for-a-single-image

  • Intersection Observer APIを使用した遅延読み込み
  • 画像の位置を保持することで、画像を読んだ途端ページ位置がずれることを防ぐ
  • 灰色の背景/ぼやけた画像などの設定が簡単にできる。

などなど、モダンな画像処理をしてくれるらしいです。

ただし、 imgタグの完璧な代替ではないので注意。適したものとしては、固定された大きさのイメージやコンテナ全体に大きく広がるイメージなど。

実は上記スターターでは、宇宙飛行士の絵でgatsby-imageが使用されています。(src/component/image.js)

npm install gatsby-transformer-sharp gatsby-plugin-sharp gatsby-image

どのスターターも用いてない場合は、下記もインストールする。スターターを使用している場合は最初から含まれているパッケージです。

npm install gatsby-source-filesystem

gatsby-config.jsを下記のように書き直す。

module.exports = {
  plugins: [
    `gatsby-transformer-sharp`,
    `gatsby-plugin-sharp`,
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        path: `${__dirname}/src/data/`,
      },
    },
  ],
}

GraphQLで画像データを扱えるようにするために,gatsby-sourcr-filesystemに画像があるフォルダーを教えています。

先にsrc/components/image.js設定部分を示します。

import React from "react"
import { useStaticQuery, graphql } from "gatsby"
import Img from "gatsby-image"

const Image = () => {
  const data = useStaticQuery(graphql`
    query {
      placeholderImage: file(relativePath: { eq: "gatsby-astronaut.png" }) {
        childImageSharp {
          fluid(maxWidth: 300) {
            ...GatsbyImageSharpFluid
          }
        }
      }
    }
  `)

  return <Img fluid={data.placeholderImage.childImageSharp.fluid} />
}

export default Image

fileのパスは、先ほどgatsby-source-filesystemのconfigで設定したパスからの相対パスとなります。

まず知るべきなのは、gatsby-imageでは2つのresponsiveタイプがあるということです。

  • fixed

  • fluid

fixedは固定幅,fluidはコンテナに合わせて縮小拡大するタイプです。

上記のクエリを用いてサイズを指定します。

GraphQL fragments を使うことでイメージの設定を行うことができます。
GatsbyImageSharpFluidもそのフラグメントの1つ。
ただしこれは、GraphiQLでは使用できないので注意。

フラグメントについてはいまいち使い分け等がわからなかったので今後要調査....。

画像等の扱いについては上記4つの方法がありますが、使い分けについては

  • 直接パス指定 ・・・あまり使わなそう
  • gatsby-image ・・・基本的にはこれ!最適化等してくれるので楽
  • jsでのimport ・・・imgタグで色々カスタマイズして配置したい場合
  • static folder ・・・動的な読み込みや制約がある等

と感じました。実際に使っていく中で適宜使い分けたいと思います。

所感

実務で必要そうだな...と思った部分はほとんどスターターに最初から組み込まれていました。
しかも、使用したい機能についてはドキュメントが手厚く説明してくれてることが多かったです。
優秀ですね!
次回はwordpress等のプラグインやフォーム等がどこまで使えるのか?について調べてみたいと思います。
静的サイトジェネレータなので、動的な部分はほどほどがいいのかもしれませんが、その辺のパフォーマンス的な兼ね合いも調査しつつ...

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

話題の「Recoil」を使ってTodoアプリ作ってみた with TypeScript

はじめに

Facebook公式から新しいステート管理のためのライブラリが公開されました。

その名もRecoilです。

特徴としては
- ミニマム
- Hooksではお馴染みのuseStateと同じようなAPI
- パフォーマンス問題が解決できる(Viewの再レンダリングを抑える)

が挙げられるかと思います。

早速Todoアプリを作ってみましょう。

環境構築

$ npx create-react-app todo-recoil --typescript
$ cd todo-recoil
$ yarn add recoil

型定義しておく

意気揚々とTSで始めようとしましたが、型定義ファイルは2020/05/15 10時現在は無いようです。

ですが、TS supportがIssueで上がっていて、既に予定しているようです。

https://github.com/facebookexperimental/Recoil/issues/6

有志が型定義ファイルを作ってくださったみたいなので、今回はとりあえずそれを利用させていただきます。

https://github.com/csantos42/DefinitelyTyped/blob/recoil-types/types/recoil/index.d.ts

型定義ファイル(押すと開くよ)
src/react-app-env.d.ts
/// <reference types="react-scripts" />

// Type definitions for recoil 0.0
// Project: https://github.com/facebookexperimental/recoil#readme
// Definitions by: Christian Santos <https://github.com/csantos42>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// Minimum TypeScript Version: 3.7

/// <reference types="react" />

declare module 'recoil' {
    // Nominal Classes
    export import DefaultValue = __Recoil.DefaultValue;

    // Components
    export import RecoilRoot = __Recoil.RecoilRoot;

    // RecoilValues (aka Recoil state)
    export import atom = __Recoil.atom;
    export import selector = __Recoil.selector;

    // Hooks for working with Recoil state
    export import useRecoilValue = __Recoil.useRecoilValue;
    export import useRecoilValueLoadable = __Recoil.useRecoilValueLoadable;
    export import useRecoilState = __Recoil.useRecoilState;
    export import useRecoilStateLoadable = __Recoil.useRecoilStateLoadable;
    export import useSetRecoilState = __Recoil.useSetRecoilState;
    export import useResetRecoilState = __Recoil.useResetRecoilState;
    export import useRecoilCallback = __Recoil.useRecoilCallback;

    // Other
    export import isRecoilValue = __Recoil.isRecoilValue;
}

declare module 'recoil/utils' {
    // Convenience RecoilValues
    export import atomFamily = __Recoil.atomFamily;
}

declare namespace __Recoil {
    // Recoil_Node.js
    export class DefaultValue {}

    // Recoil_State.js
    type NodeKey = string;

    // Recoil_RecoilValue.js
    class AbstractRecoilValue<T> {
        tag: 'Writeable';
        key: NodeKey;
        constructor(newKey: NodeKey);
    }

    class AbstractRecoilValueReadonly<T> {
        tag: 'Readonly';
        key: NodeKey;
        constructor(newKey: NodeKey);
    }

    class RecoilState<T> extends AbstractRecoilValue<T> {}

    class RecoilValueReadOnly<T> extends AbstractRecoilValueReadonly<T> {}

    type RecoilValue<T> = RecoilValueReadOnly<T> | RecoilState<T>;

    export function isRecoilValue(val: unknown): val is RecoilValue<any>;

    // Recoil_State.js
    type AtomValues = Map<NodeKey, Loadable<any>>;
    type ComponentCallback = (state: TreeState) => void;
    type TreeState = Readonly<{
        // Information about the TreeState itself:
        isSnapshot: boolean;
        transactionMetadata: object;
        dirtyAtoms: Set<NodeKey>;

        // ATOMS
        atomValues: AtomValues;
        nonvalidatedAtoms: Map<NodeKey, unknown>;

        // NODE GRAPH -- will soon move to StoreState
        // Upstream Node dependencies
        nodeDeps: Map<NodeKey, Set<NodeKey>>;

        // Downstream Node subscriptions
        nodeToNodeSubscriptions: Map<NodeKey, Set<NodeKey>>;
        nodeToComponentSubscriptions: Map<NodeKey, Map<number, [string, ComponentCallback]>>;
    }>;

    // Recoil_Loadable.js
    type ResolvedLoadablePromiseInfo<T> = Readonly<{
        value: T;
        upstreamState__INTERNAL_DO_NOT_USE?: TreeState;
    }>;

    type LoadablePromise<T> = Promise<ResolvedLoadablePromiseInfo<T>>;

    type Accessors<T> = Readonly<{
        // Attempt to get the value.
        // If there's an error, throw an error.  If it's still loading, throw a Promise
        // This is useful for composing with React Suspense or in a Recoil Selector.
        getValue: () => T;

        toPromise: () => LoadablePromise<T>;

        // Convenience accessors
        valueMaybe: () => T | void;
        valueOrThrow: () => T;
        errorMaybe: () => Error | void;
        errorOrThrow: () => Error;
        promiseMaybe: () => Promise<T> | void;
        promiseOrThrow: () => Promise<T>;

        map: <T, S>(map: (val: T) => Promise<S> | S) => Loadable<S>;
    }>;

    export type Loadable<T> =
        | Readonly<Accessors<T> & { state: 'hasValue'; contents: T }>
        | Readonly<Accessors<T> & { state: 'hasError'; contents: Error }>
        | Readonly<
        Accessors<T> & {
        state: 'loading';
        contents: LoadablePromise<T>;
    }
        >;

    // Recoil_RecoilRoot.react.js
    type RecoilRootProps = {
        initializeState?: (options: {
            set: <T>(recoilVal: RecoilValue<T>, newVal: T) => void;
            setUnvalidatedAtomValues: (atomMap: Map<string, unknown>) => void;
        }) => void;
    };

    export const RecoilRoot: React.FC<RecoilRootProps>;

    // Recoil_atom.js
    type AtomOptions<T> = Readonly<{
        key: NodeKey;
        default: RecoilValue<T> | Promise<T> | T;
        // persistence_UNSTABLE?: PersistenceSettings<T>,
        dangerouslyAllowMutability?: boolean;
    }>;

    export function atom<T>(options: AtomOptions<T>): RecoilState<T>;

    // Recoil_selector.js
    type GetRecoilValue = <T>(recoilVal: RecoilValue<T>) => T;
    type SetRecoilState = <T>(
        recoilVal: RecoilState<T>,
        newVal: T | DefaultValue | ((prevValue: T) => T | DefaultValue),
    ) => void;
    type ResetRecoilState = <T>(recoilVal: RecoilState<T>) => void;

    type ReadOnlySelectorOptions<T> = {
        key: string;
        get: (opts: { get: GetRecoilValue }) => Promise<T> | RecoilValue<T> | T;

        // cacheImplementation_UNSTABLE?: CacheImplementation<Loadable<T>>,
        dangerouslyAllowMutability?: boolean;
    };

    type ReadWriteSelectorOptions<T> = ReadOnlySelectorOptions<T> & {
        set: (
            opts: // FIXME: these types are not working
                {
                    set: SetRecoilState;
                    get: GetRecoilValue;
                    reset: ResetRecoilState;
                },
            newValue: T | DefaultValue,
        ) => void;
    };

    export function selector<T>(options: ReadOnlySelectorOptions<T>): RecoilValueReadOnly<T>;
    export function selector<T>(options: ReadWriteSelectorOptions<T>): RecoilState<T>;

    // Recoil_Hooks.js
    type SetterOrUpdater<T> = (valOrUpdater: ((currVal: T) => T) | T) => void;
    type Resetter = () => void;
    type CallbackInterface = Readonly<{
        getPromise: <T>(recoilVal: RecoilValue<T>) => Promise<T>;
        getLoadable: <T>(recoilVal: RecoilValue<T>) => Loadable<T>;
        set: <T>(recoilVal: RecoilState<T>, valOrUpdater: ((currVal: T) => T) | T) => void;
        reset: <T>(recoilVal: RecoilState<T>) => void;
    }>;

    export function useRecoilValue<T>(recoilValue: RecoilValue<T>): T;
    export function useRecoilValueLoadable<T>(recoilValue: RecoilValue<T>): Loadable<T>;
    export function useRecoilState<T>(recoilState: RecoilState<T>): [T, SetterOrUpdater<T>];
    export function useRecoilStateLoadable<T>(recoilState: RecoilState<T>): [Loadable<T>, SetterOrUpdater<T>];
    export function useSetRecoilState<T>(recoilState: RecoilState<T>): SetterOrUpdater<T>;
    export function useResetRecoilState<T>(recoilState: RecoilState<T>): Resetter;
    export function useRecoilCallback<Args extends ReadonlyArray<unknown>, Return>(
        fn: (interface: CallbackInterface, ...args: Args) => Return,
        deps?: ReadonlyArray<unknown>,
    ): (...args: Args) => Return;

    // Recoil_atomFamily.js
    type Primitive = void | null | boolean | number | string;
    type AtomFamilyParameter =
        | Primitive
        | ReadonlyArray<AtomFamilyParameter>
        | Readonly<{ [k: string]: AtomFamilyParameter }>;
    type AtomFamilyOptions<T, P extends AtomFamilyParameter> = Readonly<
        AtomOptions<T> & {
        default:
            | RecoilValue<T>
            | Promise<T>
            | T
            | ((param: any /*FIXME*/) => T | RecoilValue<T> | Promise<T>)
            | any; // FIXME
    }
        >;

    function atomFamily<T, P extends AtomFamilyParameter>(
        options: AtomFamilyOptions<T, P>,
    ): (param: P) => RecoilState<T>;
}

RecoilRootを設置する

Recoilでステート管理を使用するコンポーネントは、親ツリーのどこかに RecoilRoot を配置する必要があります!

これを配置するのに適した場所は、ルートコンポーネントの中です。

src/App.tsx にRecoilRootをおきます。

src/App.tsx
import React from 'react';
import {TaskList} from './Task';

import {
    RecoilRoot,
} from 'recoil';


function App() {
    return (
        <RecoilRoot>
            <TaskList/>
        </RecoilRoot>
    );
}

export default App;

Atomを作る

Atomとはデータストアです。

keyは一意にする必要があります。
defaultには初期値を設定しておきましょう。

$ mkdir src/atoms
$ touch src/atoms/Task.ts
src/Atoms/Task.ts
import {atom} from 'recoil';

export interface Task {
    title: string;
    completed: boolean;
}

const initialTasks: Task[] = [];

export const taskState = atom({
    key: 'task',
    default: initialTasks,
})

Todo入力部分を作る

Recoilのhookがいくつかあるのですが、代表的なものが useRecoilState です。
useStateと同じ感じのAPIでatomを引数にとり、setterとgetterを戻り値にします。

const [tasks, setTasks] = useRecoilState(taskState);

これの兄弟に useRecoilValueuseSetRecoilState があります。
名前から予想できると思いますが、それぞれgetterとsetterが独立したものです。

以下は先ほどのコードと同意です。どちらかのアクションしか無い場合はunused等で怒られないよう適切に使っていきましょう。

// const [tasks, setTasks] = useRecoilState(taskState);
const tasks = useRecoilValue(taskState);
const setTasks = useSetRecoilState(taskState);

useRecoilStateの使い方を踏まえて、Todo入力部分を作ってみましょう。

src/Task.tsk
import React, {useState} from "react";
import {useSetRecoilState} from "recoil";
import {taskState} from "./atoms/Task";

const TaskInput = () => {
    const [title, setTitle] = useState('');
    const setTasks = useSetRecoilState(taskState);
    const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        setTitle(event.target.value)
    }
    const onClick = () => {
        setTasks(t => {
            return [...t, {title, completed: false}]
        })
        setTitle('')
    }
    return (
        <div>
            <label>
                タスク名
                <input type="text" value={title} onChange={onChange}/>
            </label>
            <button onClick={onClick}>登録</button>
        </div>
    )
}

export const TaskList = () => {
    return (
        <>
            <TaskInput/>
        </>
    )
}

Todoのリスト部分を作る

const tasks = useRecoilValue(taskState); でTodoのリストは取得できます。

src/Todo.tsx
+ import {useRecoilValue, useSetRecoilState} from "recoil";

.....

export const TaskList = () => {
+   const tasks = useRecoilValue(taskState);
+
    return (
        <>
            <TaskInput/>
+            <ul>
+                {tasks.map((t, index) => {
+                    return <TaskItem task={t} index={index} key={index}/>
+                })}
+            </ul>
        </>
    )
}

Todoの表示部分を作る

注意点は useRecoilStateuseRecoilValue で取り出す値はread onlyな点です。

最初実装した時に以下のようなコードを書いていました。

ダメなコード例
tasks[index].completed = !tasks[index].completed

しかし、ステートはread onlyで再代入不可なので、sliceを使って新たな配列を作り、そこに新しいオブジェクトを挿入する感じにしています。

src/App.tsx
....

interface TaskItemProps {
    task: Task;
    index: number;
}

const removeTasksAtIndex = (tasks: Task[], index: number) => {
    return [...tasks.slice(0, index), ...tasks.slice(index + 1)]
}

const replaceTasksAtIndex = (tasks: Task[], index: number, newTask: Task) => {
    return [...tasks.slice(0, index), newTask, ...tasks.slice(index + 1)]
}

const TaskItem: FC<TaskItemProps> = ({task, index}) => {
    const [tasks, setTasks] = useRecoilState(taskState);

    const onChange = () => {
        const newTasks = replaceTasksAtIndex(tasks, index, {
            ...task,
            completed: !task.completed
        });
        setTasks(newTasks);
    }

    const onClick = () => {
        const newTasks = removeTasksAtIndex(tasks, index);
        setTasks(newTasks);
    }

    return (
        <li key={index}>
            <input
                type="checkbox"
                checked={task.completed}
                onChange={onChange}
            />
            {task.title}
            <button onClick={onClick}>削除</button>
        </li>
    )
}
....

もし、「どうしても俺はread onlyが嫌なんだ!!!!!」って方は、atomを作る時に dangerouslyAllowMutability を trueにすれば良いです。

まあけど、React公式がdangerouslyというぐらいなので、素直にoffっといた方が良いです。
なぜならReduxで培ってきた、データフローを一方向にするという考えに背く羽目になるからです。

src/Atom/Tasks.ts
export const taskState = atom({
    key: 'task',
    default: initialTasks,
    dangerouslyAllowMutability: true
})

完成

はい、こんな感じのしょぼいTodoアプリができました。
この程度だとGlobalなステートの恩恵が全くありませんが、Recoilの入門には丁度良いのかな〜という感じです。
May-15-2020 13-28-18.gif

全部のコードが置いてあるリポジトリも置いておきました。

https://github.com/serinuntius/todo-recoil

まとめ

まだRecoilはexperimentalですが、Recoilは非常に馴染みのあるAPIで、使いやすいと個人的には思います。
Reduxが重すぎると思う方は、今後Recoilを使う機会があるかもしれません。

本当はまだ紹介していない、 selector という概念があるのですが、この記事が好評だったら書くかもしれません。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactでdangerouslySetInnerHTMLで生成したDOMを操作

CMSのリッチエディターで入力したデータをdangerouslySetInnerHTMLで表示するケースがあるかと思います。
※dangerouslySetInnerHTMLを使用するとXSSの可能性があるので気をつけてください。

その際、その表示されたDOMに対してjavascriptでDOM操作する方法について書きたいと思います。

実装

  • refでDOM取得後、それに対して操作してます
  • useEffectでcomponentが生成した後、処理しています
  • aタグにGAタグを入れられるようにしてます
  • value名はaタグ内のテキストになります
let refContent;
useEffect(() => {
  const hoge = refContent.getElementsByTagName('a');
  const selects = Array.prototype.slice.call(hoge);
  selects.forEach((h) => {
    const foo = h.innerText;
    h.setAttribute(
      'onClick',
      `ga('send', 'event', 'label', 'click', '${foo}'`
    );
  });
}, []);
return (
  <div
    dangerouslySetInnerHTML={{ __html: htmlData }}
    ref={(content) => (refContent = content)}
  />
);

以上となります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Next.js 9.3 の getServerSideProps は getInitialProps に代わるものではない

概要

Next.js の Issue で以下のようなものがありました。

NextJS v9.3 performance of getServerSideProps vs getInitialProps · Discussion #10930 · zeit/next.js

getServerSideProps を使うと getInitialProps と比べてパフォーマンスが大きく低下するといった Issue です。

何故パフォーマンスが低下するのか

getInitialProps を使った以下のようなコードがあったとします。

function Page({ data }) {
  // Render data...
}

Page.getInitialProps = async (ctx) => {
  const res = await fetch('https://.../data')
  const data = await res.json()
  return data
}

export default Page

getInitialProps は初回のみサーバーで実行され、それ以降1は常にクライアントで実行されます。このコードの場合、ナビゲートされると直接外部の API を呼び出します。

次に、getServerSideProps を使った以下のようなコードがあったとします。

function Page({ data }) {
  // Render data...
}

export async function getServerSideProps() {
  const res = await fetch('https://.../data')
  const data = await res.json()
  return { props: { data } }
}

export default Page

getServerSideProps は常にサーバーで実行されます。このコードの場合、ナビゲートされると Next.js サーバーを介し外部 API を呼び出します。

このように、getServerSideProps を使用すると常にレイヤーが 1 つ多くなるため、TTL が増加してしまいます。

getServerSideProps の使いどころは?

ここまで聞くと getServerSideProps はいいところがないように思えますが、前述の Issue では以下のようなケースで有用であるといわれています。

  • Next.js サーバーが外部 API を使わない場合。
  • Next.js サーバーを介してプロキシすることで CORS を回避したい場合。

他に、メリットとして

  • 環境変数によって秘密鍵がクライアント側に公開される恐れを考慮しなくて済む。
  • データ取得のロジックが公開されないためセキュリティが向上する。
  • データソースに直接アクセスできる。

などがあります。

参考


  1. next/link などによってナビゲートされた場合。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

MAC環境でVirtualBox+Vagrant+DockerでReact開発環境を作る╰(°ㅂ°)╯

#stayhomeの時間を活用して、「仮想環境」とか「コンテナ開発」とかに慣れ親しもうという魂胆です。作業を進める上でよくわからなかった点など、周辺情報を個人的な備忘録として残します。

ゴール

作ったり壊せたり自由にできる開発環境を立ち上げられるようになること。React.jsの勉強を始める予定なので、React.jsの雛形アプリをブラウザで表示させるところまでをゴールとする。

作業の流れ

ステップが多く、私のような初学者は、わからない部分を調べている間に迷子になることもしばしば。一つ一つの動作確認をしながら作業を進めていきます。

【前半戦:ホストOS側での作業】

  • 事前準備
    • vagrantプラグインの導入確認
  • VMの設定
    • vagrant初期化
    • Vagrantfileの編集
  • dockerとdocker-composeの導入
    • Dockerfileの作成
    • docker-compose.ymlの作成
  • ホストOSとゲストOS間のファイル同期の設定
    • mutagen.yml作成

【後半戦:ゲストOS側での作業】

  • VM起動
    • vagrantでVM起動
    • VMにssh接続
  • VMの動作確認
    • ホストOSとのフォルダ共有を確認
    • dockerとdocker-compose動作
  • React.jsの導入
    • Dockerコンテナの起動
    • 起動中のコンテナに入る
    • create-react-appのインストールとアプリのひな形作成
    • アプリの実行

教材

基本となる教科書はこちらの二冊(記事)です。
- Vagrantを使う「Mac最速のDocker環境」を初心者向けに解説【遅いMac for Dockerを卒業】
- DXを大幅に低下させるDocker for Macを捨ててMac最速のDocker環境を手に入れる

その他、以下のQiita記事を参考にさせていただきました。
- Docker環境内でcreate-react-app
- Windows環境においてVirtualBox+Vagrant+DockerでReact開発環境を作る*Windows向けに書かれた記事ですが、仮想環境起動のパート以降の後半部分を中心に参考にしました。

作業ディレクトリ

作業を行うディレクトリは、ホームフォルダ下に置いてある開発(develop)ファイルにVM(仮想マシン)フォルダを作成。その配下にsample-pjなる場所を作りました。
/Users/home/develop/vm/sample-pj

実行環境・ソフトウェア・パッケージ・プラグイン

  • macOS Mojave version 10.14.6
  • VirtualBox Version 6.1.6
  • Vagrant 2.2.9
  • VirtualBox Image ubuntu/xenial64
  • Vagrant Plugin
    • vagrant-disksize
    • vagrant-hostsupdater
    • vagrant-mutagen
    • vagrant-docker-compose
  • Docker version 19.03.8
  • docker-compose version 1.24.0

前提

Vagrantを使う「Mac最速のDocker環境」を初心者向けに解説【遅いMac for Dockerを卒業】に習って、以下のステップまで進めた状態からスタートします。

  • VirtualBoxをインストール
  • Vagrantをインストール
  • Vagrant Boxをダウンロード
  • プラグインの導入
~/develop/vm/sample-pj
$ vagrant box list
ubuntu/xenial64 (virtualbox, 20200505.0.0)

$ vagrant plugin list
vagrant-disksize (0.1.3, global)
vagrant-docker-compose (1.5.1, global)
vagrant-hostsupdater (1.1.1.160, global)
vagrant-mutagen (0.1.2, global)

この↑状態が確認できたところから開始です(`ω´)

【前半戦:ホストOS側での作業】

VMの設定

vagrant初期化

~/develop/vm/sample-pj
$ vagrant init ubuntu/xenial64

# 上記コマンドを実行して、下のような結果が出てくれば完了。
A `Vagrantfile` has been placed in this directory. You are now
ready to `vagrant up` your first virtual environment! Please read
the comments in the Vagrantfile as well as documentation on
`vagrantup.com` for more information on using Vagrant.
何が起きたかチェーーーーーック( ·∀·)ノ
~/develop/vm/sample-pj
$ ls

# sample-pj(プロジェクトディレクトリ)内の構造を見てみると、
# Vagrantfileができていることが確認できます。
Vagrantfile

Vagrantfileの編集

Vagrantfileは、仮想機械を構築するための構成情報を記述するための設定ファイルです。

( ˘ω˘ )? 「Vagrantfileにどんなことが書かれているのか?」という疑問については、Vagrantにおける仮想マシンの設定に素敵な感じにまとまっているので、詳しくはそちらを参照ください。

~/develop/vm/sample-pj
# vimエディタでVagrantfileを開きます。
$ vim Vagrantfile

# -*- mode: ruby -*-
# vi: set ft=ruby :
# All Vagrant configuration is done below. The "2" in Vagrant.configure
(略)

Vimエディタの使い方については、Vim初心者に捧ぐ実践的入門などのQiita記事を参考にしてください。初見では、とっつきにくい見た目をしていますが、何度か触っているうちに慣れてきました。

早速、教科書=(Vagrantを使う「Mac最速のDocker環境」を初心者向けに解説【遅いMac for Dockerを卒業】)のVagrantのセットアップを参照して、Vagrantfileを編集します。

~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
# /sample-pj/Vagrantfile
Vagrant.configure('2') do |config|

  # VMを立ち上げる際のboxの指定
  config.vm.box = 'ubuntu/xenial64'

  # VMに設定するホスト名
  # 後述のmutagen.yml内の記述と揃える必要があるので覚えて置くこと。
  config.vm.hostname = 'sample-app'

  config.vm.network :private_network, ip: '192.168.50.10'
  # ポートへフォワードの設定
  config.vm.network "forwarded_port", guest: 3000, host: 3000

  config.vm.provider :virtualbox do |vb|
    vb.gui = false
    vb.cpus = 4
    vb.memory = 4096
    vb.customize ['modifyvm', :id, '--natdnsproxy1', 'off']
    vb.customize ['modifyvm', :id, '--natdnshostresolver1', 'off']
  end

  config.disksize.size = '30GB'
  config.mutagen.orchestrate = true

 # config.vm.synced_folderは、ホストOSとゲストOS間で共有するフォルダを指定します。
  config.vm.synced_folder './', '/home/vagrant/app', type: "rsync",
    rsync_auto: true,
    rsync__exclude: ['.git/', 'node_modules/', 'log/', 'tmp/']

  # config.vm.provisionは、初回(VM作成時)に実行されます。
  # ここではDockerとdocker-composeの導入について書かれています。
  config.vm.provision :docker, run: 'always'

  # docker-compose の設定
  config.vm.provision :docker_compose,
    yml: "/vagrant/docker-compose.yml",
    compose_version: "1.24.0",
    run: "always"
end

ポートへフォワードの設定とdocker-composeのインストールについては、私が追記編集した部分です。

ポートフォワードの設定については、Vagrant + VirtualBoxで仮想環境側のポートをあけるを参考にしました。この記述なし(教科書通り)で作業を進め、ブラウザでhttp://localhost:3000/ でアプリを開いてみようとすると、ERR_CONNECTION_REFUSEDという下記のエラーが出てきて困ってしまいましたが、ポートフォワードの設定を加えて解決できました。

ブラウザ
This site can’t be reachedlocalhost refused to connect.
Try:

Checking the connection
Checking the proxy and the firewall
ERR_CONNECTION_REFUSED

docker-compose の設定ですが、教科書通りにconfig.vm.provision :docker_composeだけでは、vagrant up時にインストールされませんでした。バージョン情報などを追加し、無事インストールできるようになりました。原因は全くわかりません・・・。

dockerとdocker-composeの導入

Vagrantfileでは、config.vm.provisionの部分で、初回のvagrant up(VM作成時)にdockerとdocker-composeがインストールされるように設定してあります。以下では、docker-compose upによるコンテナ実行時に呼び出される、docker-compose.ymlとDockerfileの2つを作成していきます。

docker-compose.ymlの作成

~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
# docker-compose.ymlを作成
vagrant@sample-app:~/app$ vi docker-compose.yml

# 下記のように書き換え
version: "3"
services:
  #コンテナ名。後ほど、この名前を指定してコマンドを叩くことになります。
  node: 
    build:
      # 参照するファイルのある場所を、docker-compose.ymlからの相対パスで指定。今回は同じ階層・同じフォルダ内にある。
      context: .
    # 参照するファイル。今回はDockerfile_nodeという名前です。
      dockerfile: Dockerfile_node 
    # ストレージの指定 ホストOS:ゲストOS
    volumes:
      - ./:/usr/src/app
    ports:
      - "3000:3000"
    # docker-compose up後、即コンテナが終了することなく、待機状態とする。
    tty: true

Dockerfileの作成

~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
# Dockerfileを作る。docker-compose.ymlで指定した通り、今回はDockerfile_nodeという名前にします。
$ vi Dockerfile_node

# Dockerfileを編集
# どのイメージを基にするか。今回はreactの開発環境なので、node.jsを使用します。現時点で最新・コンパクトなバージョンを指定しました。
FROM node:14.2-alpine

WORKDIR /usr/src/app

作業ディレクトリ(WORKDIR)については、色々なサイトを見ましたが、Linux系では/usr/src/appがお作法になっているようです。「/usr/src/appとは?」という疑問に関しては、 Linux豆知識 217「/usr/src」ディレクトリを参照。

ホストOSとゲストOS間のファイル同期の設定

Vagrantfileの config.vm.synced_folderの記述で、ホストOSとゲストOS間で共有するフォルダを指定しましたが、config.vm.synced_folderによるファイル共有は、vagrant upによるVM作成時のみ実行されます。つまり、常時シンクロしてくれるわけではないそうです。

DXを大幅に低下させるDocker for Macを捨ててMac最速のDocker環境を手に入れる - ファイル同期の手段に習って、ホストOSとゲストOS間のファイル同期は、後述のmutagenというプラグインを使います。

mutagen.yml作成

DXを大幅に低下させるDocker for Macを捨ててMac最速のDocker環境を手に入れる - ファイル同期のソリューション 「Mutagen」参考に、mutagen.ymlを作成、編集していきましょう。

~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
# touchコマンドでmutagen.yml新規作成
$ touch mutagen.yml 

# sample-pj(プロジェクトディレクトリ)内の構造を見てみると、mutagen.ymlが追加されているのが確認できます。
$ ls
# 実行結果↓
Vagrantfile mutagen.yml
~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
# multagen.ymlファイルを開く
$ vim mutagen.yml

# multagen.yml内を編集
sync:
  app:
    # 双方向同期モード 但し、alphaが優先、ベースになる
    mode: "two-way-resolved"
    # alphaとbetaでエンドポイントを指定
    alpha: "./"
    # sample-appというhostの中の、/home/vagrant/appディレクトリ
    # sample-appは、Vagrantfile内のconfig.vm.hostname = 'sample-app'で指定した名前と揃える必要があります
    beta: "sample-app:/home/vagrant/app"
    # ignore(=無視)なので、以下に掲げる内容を無視するということかな?
    ignore:
    # vcs....バージョン管理システム(Version Control System)のことかな?それがtrueとは...? よく、わかりませんでした・・・。
      vcs: true
      # 一連のパスで指示されているディレクトリは無視するということかな?この辺よくわかりませんでした・・・。
      paths:
        - "/node_modules"
        - "/log"
        - "/tmp"

( ˘ω˘ )? 「multagen.ymlが何をしてくれているのか?」という疑問については、以下を参考にしまして、掴め(たような気に)ました。

以上で、ファイル共有の設定が完了しました。

以下の4つのファイルが準備できた段階で、ホストOS側での作業は終了です。

~/develop/vm/sample-pj
$ ls
# 実行結果↓
Dockerfile_node     docker-compose.yml
Vagrantfile     mutagen.yml

【後半戦:ゲストOS側での作業】

ここからは、VMを起動させてゲストOS側でReactの開発環境を作っていきます。

VagrantでVM起動

早速、VMの起動へ。

~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
$ vagrant up
ディレクトリ内をチェーーーーーック( ·∀·)ノ
~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
$ ls

# 実行結果↓
Dockerfile_node
Vagrantfile
docker-compose.yml
mutagen.yml
mutagen.yml.lock
ubuntu-xenial-16.04-cloudimg-console.log

作った覚えのない、ubuntu-xenial-16.04-cloudimg-console.logというファイルが新しくできていることがわかります。
( ˘ω˘ )? cloudimg-console.logって、何してくれるの?という疑問については、cloudimg-console.logとはを参考にしました。名前の前半のubuntu-xenial-16.04の部分は、仮想OSの種類に依るようです。

VMにssh接続

~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
$ vagrant ssh

# 実行結果↓
Welcome to Ubuntu 16.04.6 LTS (GNU/Linux 4.4.0-178-generic x86_64)
(略)
# Vagrantfileで指定したホスト名でターミナルが開く。
vagrant@sample-app:~$ 

vagrant@sample-app:~$ というように、ターミナルが表示されればOK。vagrantユーザーでsample-appというホスト(=ゲストOS)にログインされています。

VMの中身をチェーーーーーック( ·∀·)ノ
vagrant@sample-app
# /(ルートディレクトリ)配下にLinuxのディレクトリが並んでいます。
vagrant@sample-app:~$ cd /
vagrant@sample-app:/$ ls
# 実行結果↓
bin   home            lib64       opt   sbin  tmp      vmlinuz
boot  initrd.img      lost+found  proc  snap  usr      vmlinuz.old
dev   initrd.img.old  media       root  srv   vagrant
etc   lib             mnt         run   sys   var

仮想環境にゲストOSが導入されていることが確認できました。ちなみに、Linuxのディレクトリの構造についての参考URLはこちらLinuxのディレクトリ構造(一覧)を理解するを参照ください。

ホストOSとのフォルダ共有状況ををチェーーーーーック( ·∀·)ノ
vagrant@sample-app
# ~(ホームディレクトリ)
vagrant@sample-app:~$ cd 
vagrant@sample-app:~$ ls
# 実行結果↓
app


# appへ移動し、カレントディレクトリのパスを出力する
vagrant@sample-app:~/app$ pwd
# 実行結果↓ 
/home/vagrant/app #multagen.ymlファイルで指定したゲストOS側のエンドポイントと一致していることがわかります。


# appの中も見てみましょう。
vagrant@sample-app:~$ cd app
vagrant@sample-app:~/app$ ls
# 実行結果↓
Dockerfile_node     mutagen.yml
Vagrantfile         mutagen.yml.lock
docker-compose.yml  ubuntu-xenial-16.04-cloudimg-console.log

試しに、/app内でtestという名前でディレクトリを作成してみます。

vagrant@sample-app
# /app内でtestという名前でディレクトリを作成
vagrant@sample-app:~/app$ mkdir test
vagrant@sample-app:~/app$ ls

# 実行結果↓
Dockerfile_node
Vagrantfile
docker-compose.yml
mutagen.yml
mutagen.yml.lock
test  # 新しく作ったディレクトリ
ubuntu-xenial-16.04-cloudimg-console.log

一度、VMを離れて、ホストOS内の作業ディレクトリを見てみると、testという名前のファイルができているのが確認できました。

vagrant@sample-app
# exitでVMからログアウト
vagrant@sample-app:/vagrant$ exit

# 実行結果↓
logout
Connection to 127.0.0.1 closed.
~/develop/vm/sample-pj(ホストOS内プロジェクトディレクトリ)
$ ls

# 実行結果↓
Dockerfile_node
Vagrantfile
docker-compose.yml
mutagen.yml
mutagen.yml.lock
test # こちらにもtestディレクトリができています
ubuntu-xenial-16.04-cloudimg-console.log

# ファイルの共有が確認できたのでtestフォルダを削除します
$ rm -r test

# もう一度VMへssh接続
$ vagrant ssh

# 実行結果↓
Welcome to Ubuntu 16.04.6 LTS (GNU/Linux 4.4.0-178-generic x86_64)
(略)

VM側でのtestディレクトリが消えているか確認します。

vagrant@sample-app
vagrant@sample-app:~$ cd app
vagrant@sample-app:~/app$ ls

# 実行結果↓ こちらでもtestファイルが無くなっていました。
Dockerfile_node     mutagen.yml
Vagrantfile         mutagen.yml.lock
docker-compose.yml  ubuntu-xenial-16.04-cloudimg-console.log

以上で、ホストOSとのフォルダ共有が確認できました。

dockerとdocker-compose動作も併せて、チェーーーーーック( ·∀·)ノ
vagrant@sample-app
# dockerの動作チェック
vagrant@sample-app:~$ docker --version
# 実行結果↓
Docker version 19.03.8, build afacb8b7f0

# docker-composeの動作チェック
vagrant@sample-app:~$ docker-compose --version
# 実行結果↓
docker-compose version 1.24.0, build 0aa59064

バージョン情報が表示されて、dockerとdocker-composeが導入されていることが確認できました。

Dockerコンテナの起動

vagrant@sample-app
# /appディレクトリにてコマンド実行。バックグラウンドでの実行するため-dオプションをつけます。
vagrant@sample-app:~/app$ docker-compose up -d

# コンテナの実行状況を確認しましょう
vagrant@sample-app:~/app$ docker-compose ps
# 実行結果↓
   Name             Command          State           Ports        
------------------------------------------------------------------
app_node_1   docker-entrypoint.sh    Up      0.0.0.0:3000->3000/tc

実行中のコンテナに入る

vagrant@sample-app
# 実行中のコンテナに入る。node部分は、docker-compose.ymlで指定したコンテナ名。
vagrant@sample-app:~/app$ $ docker-compose exec node sh
# 実行結果↓ 以下が表示されれば無事にコンテナ内に入れています。
/usr/src/app # 

コンテナ内でcreate-react-appのインストールとアプリのひな形作成

vagrant@sample-app
# creat-react-appのインストール
/usr/src/app # npm install -g create-react-app

# react-sampleという名前でアプリのひな形を作成
/usr/src/app # create-react-app react-sample

/usr/src/app # ls
# 実行結果↓
Dockerfile
Vagrantfile
docker-compose.yml
mutagen.yml
mutagen.yml.lock
react-sample # 新しいreactアプリが立ち上がっています
ubuntu-xenial-16.04-cloudimg-console.log

アプリの実行

vagrant@sample-app
# react-sampleディレクトリに移動
/usr/src/app # cd react-sample

# react-sampleを実行
/usr/src/app/react-sample # yarn start
# 実行結果↓
Compiled successfully!

You can now view react-sample in the browser.

  Local:            http://localhost:3000
  On Your Network:  http://172.18.0.2:3000

Note that the development build is not optimized.
To create a production build, use yarn build.

ゴォォォォォル(´∀`*)

ブラウザでhttp://localhost:3000 へアクセスしてみます。以下のような画面が出てくればOKです。

Screen Shot 2020-05-15 at 10.52.48.png

おまけ:再度コンテナに入る

一度コンテナを立ち上げた後、作業のためにコンテナに入るまでの手順はこちら。

# ホストOSの作業ディレクトリにて、VMを起動
ホストOS作業ディレクトリ $ vagrant ssh

# ゲストOSのreact-sampleアプリへ移動し、コンテナを実行
ゲストOS:~$ cd app
ゲストOS:~/app$ cd react-sample
ゲストOS:~/app/react-sample$ docker-compose up -d

# コンテナに入る
ゲストOS:~/app/react-sample$ docker-compose exec node sh

# コンテナ内
/usr/src/app #

ついでに、ソフトウェアの導入を確認。React.jsの開発ができる環境になっているようです。

/usr/src/app # node --version
v14.2.0
/usr/src/app # npm --version
6.14.4
/usr/src/app # yarn --version
1.22.4

以上!!

初・初・初学者です(´·ω·`)

暗中模索な感じで始めてしまい、うまくいかず途中で投げ出したくもなりましたが、なんとか動くところまで持ってこれました。多々、理解違いがあるかと思います。今後、間違いに気づいた点などは、適宜修正していこうと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ページ遷移はどうするの?

ページ遷移ってどうするの?

あれ?ルーターがなくない?

今までReactを書いていてそろそろサーバーサイドレンダリングに手を出した私、よしポートフォリオサイトを作ってやろ!と考えたわけですよ!ここである問題が発生!あれ、Reactのときはページ切り替えはruterを使ってたけど...NextってRuterないやんけどうするんや〜!
いや安心してくださいませ〜
別にないわけではないです。
NextではRuterを使わなくてもページ繊維なんてちょちょいのちょいで行えるのです!
(あ!ReactでRuterを使うときは npm install ruterでルーターが使えるようにしてimportしてからでないと 使えませんので〜)npmとかのコマンドはまたどこかで説明するとして、話が逸れてしまったので本題に行ってみましょう!

じゃあ実際にどうすればいいの?

話は意外と簡単です!
Nextのアプリケーションは基本的にpageディレクトリ内に様々なファイルを記述しますが、そこにある.jsファイル、つまりページコンポーネントに import Link form 'next/link'と記述しましょう。これでNextで作成した各ページのjsファイルを遷移できるようになりました。あとはリンクを配置します。ページコンポーネントに以下のように記述します。

~.js
export default () => (
  <Layout header ="Top page" title ="Top page.">
    <p>Welcome to Next.js</p>
    <Link href ="./other">
      <button>Go to Other &gt;&gt;</button>
    </Link>
  </Layout>
);

とりあえず現在のページタイトルとかはtoppageとかにしといて遷移先のページのjsファイルの名前をother.jsとすると<Link href = "./other"> </Link>と言うように記述することでハイパーリンクが完成し、画面に表示されるようになります。つまり...

<Link href = " 遷移先のjsファイルの名前(.jsはいらない) ">
ここにボタンやテキストを入力する (ex<p>,<h3>,<button>,もしくは生テキスト....
</Link>

以上!これでNextにおける画面遷移はご理解いただけたでしょうか?
あ!忘れていました!今回のトップページはindex.jsとしていますが、もし遷移先にother.jsから戻るための画面遷移がしたいと思えばohter.jsの方でが上記のようなもの書きます。しかし、この時に遷移先がトップページであれば、href = "./index"と記述せずhref = "/"で行うことができます!覚えておきましょう!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む