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

無駄と文化

実用的ブログ

React で this.props.children に新しい Props を渡す

JavaScript React

f:id:todays_mitsui:20160814183151p:plain

React でカスタムコンポーネントを作るとき、コンポーネントの子要素には this.props.children でアクセスできます。
この this.props.children はそのままレンダリングすることもできるのですが、何かしらの Props を渡したくなったらどうするのでしょうか。

ざっくり調べた感じ Stack Overflow とか海外のブログにしか情報が無いようだったのでまとめてみます。


TL;DR

いきなり結論から、
this.props.children に直接 Props を渡すことはできません。
代わりの方法として、React.cloneElement() で React要素をクローンする時に Props を渡すことができるので、this.props.children をクローンしつつ Props を渡せばいいようです。

デモを用意しました。


以下のようにすればローカルでデモをいじりつつ試せます。

$ git clone https://github.com/todays-mitsui/passing-props-to-children-sample.git
$ cd passing-props-to-children-sample
$ npm install
$ npm start


要素に Props を渡す

this.props.children に限らず React要素は React.cloneElement() という API でクローンできます。
そのとき第2引数にオブジェクトを渡すと、React要素が持っている既存の Props とマージされた後、新しい Props として設定されるとのことです。

let elementWithProps = React.cloneElement(element, { foo: 'bar' })

公式ドキュメントに解説があるので、詳しくはそちらをどうぞ。


this.props.children に応じた処理をする

ところで this.props.children は状況によって様々な型の値になります。
複数の子要素を持っている場合には this.props.childrenReact要素の配列 になります。
その他、子要素がただのテキストノードだった場合には string に、子要素を持たない場合は undefined と、まぁ場合によっていろいろみたいです。

そんな this.props.children を上手く扱うために React.Children というユーティリティクラスが用意されています。
使うときには React.Children で参照するほかに、ES2015 で記述しているなら、

import { Children } from 'react'

というように個別にインポートしてもいいでしょう。


今回は React.Children.map() を使って子要素一つひとつに Props を渡します。

const newProps = { foo: 'bar' }

const childrenWithProps = React.Children.map(
  this.props.children,
  (child) => {
    // 各子要素をクローンしつつ newProps を渡す
    return React.cloneElement(child, newProps)
  }
)

さて、実はこれだけではいけません。
子要素がテキストノードを含んでいる場合には、上記の child に string が渡されます。そして React.cloneElement() は string を受け取ってくれないので、そこでエラーが発生します。

なので child の type を判別しつ上手いこと処理を分岐しましょう。
string が渡ってきた場合には何もせず、そのまま返すようにします。

const newProps = { foo: 'bar' }

const childrenWithProps = React.Children.map(
  this.props.children,
  (child) => {
    console.info(typeof child, child)

    switch (typeof child) {
      case 'string':
        // 子要素がテスキスとノードだった場合はそのまま return
        return child

      case 'object':
        // React要素だった場合は newProps を渡す
        return React.cloneElement(child, newProps)

      default:
        // それ以外の場合はとりあえず null 返しとく
        return null
    }
  }
)

これで this.props.children を複製しつつ Props を渡した childrenWithProps を作ることができました。


React.Children.map() についても公式ドキュメントの解説が親切でした。


簡単なデモ

やり方の説明としては以上なんですが、上記のコードは単体では動かないので実際に動作する簡単なデモを書きました。

ロジック部分は1ファイルでこのようになっています。

/* main.js */

import React, { Children } from 'react'
import ReactDom from 'react-dom'


class Child extends React.Component {
  render() {
    const parentName = this.props.parentName || 'UnknownParent'
    const name = this.props.name || 'UnknownChild'

    return (
      <p>
        ParentName: <em>{parentName}</em> > ChildName: <em>{name}</em>
      </p>
    )
  }
}

class Parent extends React.Component {
  render() {
    const newProps = { parentName: 'foo' }

    const childrenWithProps = Children.map(
      this.props.children,
      (child) => {
        console.info(typeof child, child)

        switch (typeof child) {
          case 'string':
            return child

          case 'object':
            return React.cloneElement(child, newProps)

          default:
            return null
        }
      }
    )

    return (
      <div>
        {childrenWithProps}
      </div>
    )
  }
}


ReactDom.render(
  (
    <Parent>
      ### Text Node ###
      <Child name='hoge' />
      <Child name='fuga' />
      <Child name='piyo' />
    </Parent>
  ),
  document.querySelector('.container')
)

<Child> コンポーネントは name, parentName という2つの Props を <p> タグの中で表示するだけの簡単なものです。
今回は parentName の方を親要素の <Parent> コンポーネントから渡してあげています。


今回のデモのソースは全て GitHub に置いてあります。


まとめ

React はもともと API が少なくて学習コストが少ないと思っているんですが、少ないなりに覚えておくと便利な API もありますね。
React.cloneElement()this.props.children をカスタマイズする以外にもいろいろな用途で使えるはずです。


私からは以上です。

【今日のバグ取り】 JavaScript でコールスタックが溢れていたのをどうにかした話

JavaScript CSS

f:id:todays_mitsui:20160626123718p:plain


先日、とあるサイトを見ていたら JavaScript でエラーが出ているっぽいのを見つけました。

Chromeで見たときのエラーの内容はこんな感じ、

jquery.min.js:2 Uncaught RangeError: Maximum call stack size exceeded

どうやらコールスタックがいっぱいになって溢れているようですね。


スタックトレースを辿っていくと、以下の箇所がエラーの原因のようでした。

$(document).ready(function() {
  setAutoColorChange();
});
 
function setAutoColorChange(index) {
  var colorlist = ["#346caa", "#3386c8", "#4aa45d", "#8fb84f", "#debc1c", "#e09532", "#eb7889"];
  if (!index || index > (colorlist.length - 1)) {
    index = 0;
  }
  var color = colorlist[index];
  $('.top').animate({
    backgroundColor: color
  }, 5000);
  setAutoColorChange(++index);
}

この処理では「とある要素の背景色をゆっくりと変化させ続ける」というようなことをやっています。
5秒に一度、背景色を変化させるアニメーションを呼び出し続けることで、背景色を変化させ続ける演出をしているようです。


このコード、やりたいことはすごくわかるんです。
ロジックとしてもそこまで破綻していないと思います。

一方で、このコードがエラーを引き起こす理由もパッと見えてきます。
おそらく、JavaScript 以外の言語での経験が長い人のコードなのではないかと思います。

というわけで今回はこのコードの解説をしつつ、JavaScript らしく書き直していきたいと思います。


エラーの解説 - コールスタックとは何か

JavaScript のプログラム中で関数が実行されると、関数が実行された場所やそのときの状況などとの情報が コールスタック と呼ばれるメモリ領域に積まれます。

エラーが出ているコード中では setAutoColorChange() 関数の最後の部分で、再び自分自身である setAutoColorChange() を呼び出しています。

function setAutoColorChange(index) {
  /* ... 中略 ... */

  setAutoColorChange(++index); // <- 再び自分自身を呼び出す
}

これによってループを実現しているのですが、そのせいで setAutoColorChange() の処理が1周するたびにコールスタックが1段ずつ積まれてしまいます。

setAutoColorChange() が自分自身を呼ばずに関数内での計算が終わることは無いためコールスタックが無限に積まれ続けて、そのうちに「これ以上スタックを積むことが出来ない!」という点に達してエラーを引き起こしている訳です。


関数の中で自分自身を呼び出すことを再帰呼び出しといいます。

再帰呼び出しそのものがエラーの原因という訳ではありません。
無限に再帰呼び出しし続けてしまい、その結果コールスタックが積まれ続けてしまうことがエラーの原因なのです。


しかし、矛盾するようですが、このコードが正常に動く場合もあるだろうと思ったりします。
JavaScript以外の言語で同様の処理を実装すれば正常に動いてくれる場合もあります。

なぜ、JavaScriptではうまくいかないのか。それを理解するためには JavaScript のいくつかの特徴について知らなければいけません。


JavaScriptの特徴 1 - JavaScriptに「末尾再帰最適化」は無い

今回のコードのように関数の最後の行で自分自身を呼び出すようなパターンを 末尾再帰 といいます。
リスト構造や木構造、数列などのいくらでも長くなっていく可能性のあるデータを順々に辿って処理していくときによく使われる結構ポピュラーなパターンです。

ゆえに末尾再帰に対して 末尾再帰最適化 と呼ばれる最適化が施される言語もあります。
ざっくり言うと「再帰呼び出しが関数の最終行だったときには、コールスタックに積まずに済ませる」という処理です。

この末尾再帰最適化が効いていれば、最終行での再帰呼び出しを無限に繰り返してもコールスタックが溢れることはありません。
なんせ末尾再帰であればスタックに積まれない訳ですからね。


が、しかし、

JavaScript に末尾再帰最適化はありません。


では、どうすればいいかというと「無限に再帰するような処理は避けて、whileループなどを使いましょう」というのが一つの答えです。


JavaScriptの特徴 2 - JavaScript の処理は非同期に進む

元のコードにはjQueryを利用した5秒間のアニメーションが含まれています。

  $('.top').animate({
    backgroundColor: color
  }, 5000);

この部分です。
再帰呼び出しはこの後の行なので、感覚的には「5秒かけてアニメーションしてから、自分自身を呼び出して繰り返す」という処理に見えます。

が、実際に実行してみると、
無限の再帰呼び出しでコールスタックが溢れるまでにかかる時間は一瞬です。
1周するのに少なくとも5秒はかかりそうなループなのに、これはどういうことでしょうか。


これは JavaScript の 非同期 を基本とした処理に起因するものです。

多くのプログラミング言語では途中に時間のかかる処理が現れたら、その処理の完了を待ってから続きを再開します。あえてネガティブに言うと、時間のかかる処理が完了するまでプログラム全体がブロックされるのです。

しかし、JavaScript はそこらの言語とは違い、非同期処理が基本です。 途中に時間のかかる処理が現れても、完了を待つことなどせずどんどん進めます。

結果、光の速さで再帰呼び出しが掛かります。一瞬のうちに大量のスタックが積まれ、最終的に溢れます。


よくある解決策は callback関数 を渡すことです。.animate() メソッドの終了を待つ代わりに、「アニメーションが終わったタイミングでこの関数を実行しておいて」という感じで関数を渡します。

  $('.top').animate({
    backgroundColor: color
  }, 5000);
  setAutoColorChange(++index);

これを、

  $('.top').animate({
    backgroundColor: color
  }, 5000, function() {
    setAutoColorChange(++index);
  });

このように変えます。

これにて再帰呼び出しで setAutoColorChange() が呼び出されるのは少なくとも5秒に一度になりました。
コールスタックは16000段くらいなら積まれても溢れないようなので、この実装であれば80000秒間 = 約22時間はスタックが溢れずに持ちこたえられそうです。


書き直す

これまでの事を踏まえると、①末尾再帰最適化が効いて、②同期的に処理が進む言語であれば、そもそもエラーにならずに済みそうな気がしました。
が、しかし、JavaScriptはそうではないのです。無いものねだりしていても仕方ありません。

書き直しましょう。


というわけで書き直したデモとソースコードがこちらにあります。

github.com


大事な部分だけ抜き出すとこんなコードになっています。

まずは CSS と、

/* 背景色をゆっくりと変化させるために transiton を5sで設定する */
body { transition: background-color 5s linear; }

/* 切り替えの基点になる色を設定 */
body.color0 { background-color: #346caa; }
body.color1 { background-color: #3386c8; }
body.color2 { background-color: #4aa45d; }
body.color3 { background-color: #8fb84f; }
body.color4 { background-color: #debc1c; }
body.color5 { background-color: #e09532; }
body.color6 { background-color: #eb7889; }

JavaScript はこんな感じに、

// 背景色を設定したクラスを切り替えるためのクロージャを生成
// 現在設定されている色は index で保持する、
function initAutoColorChange($el, colorCount) {
  var index = 0;

  return function() {
    index = (index + 1) % colorCount;

    // "color*"にマッチするクラス名を全て削除
    // 今回の組み方では色数が10色以上担った場合に対応していない
    $el.removeClass(function(i, classNames) {
      return classNames.split(' ').map(function(className) {
        return className.match(/color\d+/);
      }).filter(Boolean).join(' ');
    });

    // 次のクラス名を付与
    $el.addClass('color'+index);
  }
}

jQuery(function($) {
  // 切り替え関数を取得
  // 対象は<body>、色数は7色
  var autoColorChange = initAutoColorChange($('body'), 7);

  autoColorChange()
  setInterval(autoColorChange, 5000); // 5000ms 毎に切り替えされるよう設定
});


変更点 1 - 再帰を辞めて setInterval

一定時間毎に関数を実行する方法として、JavaScript では setInterval() というメソッドが用意されています。
月並みですが、ループは setInterval() を使いましょう。

setInterval(autoColorChange, 5000);

このように。


変更点 2 - アニメーションは CSS transition に任せる

確かに jQuery の .animate() メソッドは便利なんですが、今回のように単純に背景色を変えるだけなら CSS アニメーションで充分だと感じます。

今回は <body> の背景色を5秒かけて変化させたいので、その旨を CSS に記述しています。

body { transition: background-color 5s linear; }


変更点 3 - 現在の色番号の保持はクロージャでやる

今回はあらかじめ7つの色を決めておいて、それをアニメーションさせながらぐるぐると変化させています。
そのために、処理の中でも「現在どの色を表示しているか」という情報を index という変数で保持しています。

元のコードは関数型の影響を色濃く受けているようで、再帰呼び出しの際に渡す引数として色番号を保持しています。


今回書き直すにあたって、同じようにしても良かったのですが、せっかくなら JavaScript らしいやり方でと思い クロージャ を使うことにしました。 具体的には、クラス名を貼り替える関数を返す関数 initAutoColorChange() を定義しています。

function initAutoColorChange($el, colorCount) {
  var index = 0;

  return function() {
    index = (index + 1) % colorCount;

    // "color*"にマッチするクラス名を全て削除
    // 今回の組み方では色数が10色以上担った場合に対応していない
    $el.removeClass(function(i, classNames) {
      return classNames.split(' ').map(function(className) {
        return className.match(/color\d+/);
      }).filter(Boolean).join(' ');
    });

    // 次のクラス名を付与
    $el.addClass('color'+index);
  }
}

initAutoColorChange() によって返される関数は、自身の外側で定義された変数 index を使用しています。
内側の関数が定義される時点で、 index の値は0。その後、関数が呼び出されるたびに index の値は1ずつインクリメントされますが、関数は index の値を保持し続けます。


まとめ

今回コードを書き直すにあたってやったのは、結局のところ「再帰を辞めて setInterval を使った」というだけです。

それだけ聞くと簡単に聞こえますが、
なぜ再帰ではだめなのか、なぜ setInterval を使う事が JavaScript らしいのか、それについて理解しようとするとバックグラウンドに多くの知識が必要になります。

今回、説明したコールスタックや末尾再帰最適化についてもっと詳しく知りたい方は、以下の記事を根気強く読み解いていくといいかも知れません。

つくづく、複数の言語を学んでこそプログラミングの知識がより深まるなぁ、と感じますね。


私からは以上です。