TL;DR
以前にVue.js公式のHackerNewsのサンプルソースについて記載しましたが、勉強のためReactで書き直してコードを比較してみました。基本的には移植となり同じ設計を利用する前提です。
1.構成および処理の流れ
Vueのサンプル同様でindex.htmlの<div id="app"></div>
をイベントによって書き換えていく形となります。初期表示ではsrc/main.jsが処理されNewsページを表示します。(正確にはルートアクセス時にNewsページにリダイレクトします)
・ニュース表示
index.html > main.js > App.js > NewsView.js > Item.js
・ユーザ表示
index.html > main.js > App.js > UserView.js
・コメント表示
index.html > main.js > App.js > ItemView.js > Item.js > Comment.js
詳細は以前の記事のこの部分を参照していただけると理解が早いと思います。
今回の構成としてVueのCompornentをそのままReactのCompornentに移植します。
CSSは別ファイルとしてReact CSS Modulesを利用してsassで用意しています。
※CSS in JSと迷ったが何となくこっちがデファクトなのかと思った。
※Vue側ではstylusを利用していたがイマイチReact CSS Modulesでの利用方法が分かりませんでした。。
store(firebase)とfilterの部分はVueに依存していなかったのでそのまま利用します。
ディレクトリ構成、ファイルは以下のようになります。
documentroot/
├ node_modules/
├ src/
│ └ components/
│ └ App.js
│ └ Comment.js
│ └ Item.js
│ └ ItemView.js
│ └ NewsView.js
│ └ UserView.js
│ └ css/
│ └ Comment.sass
│ └ Item.sass
│ └ ItemView.sass
│ └ Main.sass
│ └ NewsView.sass
│ └ UserView.sass
│ └ variables.sass
│ └ filters/
│ └ index.js
│ └ store/
│ └ index.js
│ └ main.js
├ static/
│ └ build.js
├ index.html
├ package.json
└ webpack.config.js
2.main.js
import React from 'react'
import { render } from 'react-dom'
import App from './components/App'
render(<App />, document.getElementById('app'))
枠となるAppコンポーネントの描画のみとなります。
Vueではルーティング周りの手続きなどもここで行っていました。
3.App.js
import React from 'react'
import { HashRouter, Route, Redirect } from 'react-router-dom'
import CSSModules from 'react-css-modules'
import NewsView from './NewsView'
import UserView from './UserView'
import ItemView from './ItemView'
import styles from '../css/Main.sass'
class App extends React.Component {
render () {
return <div id="wrapper" className={styles.wrapper}>
<div id="header" className={styles.header}>
<a id="yc" className={styles.yc} href="http://www.ycombinator.com">
<img src="https://news.ycombinator.com/y18.gif" />
</a>
<h1><a href="#/news/1">Hacker News</a></h1>
<span className={styles.source}>
Built with <a href="https://facebook.github.io/react/" rel="noopener noreferrer" target="_blank">React.js</a> |
<a href="https://github.com/facebook/react" rel="noopener noreferrer" target="_blank">Source</a>
</span>
</div>
<HashRouter hashType="hashbang">
<switch>
<Route path="/news/:page" component={NewsView} />
<Route path="/user/:id" component={UserView} />
<Route path="/item/:id" component={ItemView} />
<Route exact path="/" render={() => <Redirect to="/news/1" />} />
</switch>
</HashRouter>
</div>
}
}
export default CSSModules(App, styles)
コンポーネントの大きな違いとして、React標準ではCSSは別ファイル管理となります。
CSSModulesを利用してsassファイルを読み込み、タグ内ではclassNameまたはstyleNameを利用してclass属性を指定します。
普通にclass属性を利用するとエラーとなります。
ページ表示時、class名は内部でBEM形式に変換された文字列となり、実際に出力されるHTMLのclass名と異なることに注意が必要です。
またBEMなどclass名に"-"を使用している場合は{styles['BLOCK--Modifier']}
のような形式で記述します。(今回は利用しているsass内から"-"を取り除いてます)
App.jsは基本的にVueと同様で、ページヘッダーの描画とルーティングの制御を行います。
HashRouter
のhashType
に"hashbang"
を設定することでルート以下のURLが"/#!/"となります。(Vueのサンプルに合わせた)デフォルトは"/#/"となります。
ここで問題がありVueのサンプルではマッチしない場合はニュースページの1ページ目にルーティングしていますが、React-routerで上手く表現できませんでした。
(別でマッチした場合も必ずマッチしない処理が呼ばれてしまった)location.hash
を元に自前で処理を記述すれば実現はできるのですが、方法が分かり次第アップデートします。。
4.NewsView.js
import React from 'react'
import CSSModules from 'react-css-modules'
import { Link } from 'react-router-dom'
import store from '../store'
import Item from './Item'
import mainstyles from '../css/Main.sass'
import styles from '../css/NewsView.sass'
class NewsView extends React.Component {
constructor (props) {
super(props)
this.state = {
page: parseInt(props.match.params.page) || 1,
items: []
}
this.update = this.update.bind(this);
}
componentWillMount () {
store.on('topstories-updated', this.update)
this.update()
}
componentWillReceiveProps (nextProps) {
let page = parseInt(nextProps.match.params.page) || 1
this.setState({ page: page })
this.update()
}
componentWillUnmount () {
store.removeListener('topstories-updated', this.update)
}
update () {
store.fetchItemsByPage(this.state.page).then(items => {
this.setState({ items: items })
})
}
render () {
let items = []
for (let i = 0; i < this.state.items.length; i++) {
items.push(<Item key={ this.state.items[i].id } index={ i + 1 + ((this.state.page - 1) * 30) } item={ this.state.items[i] } />)
}
let nav = ''
if (this.state.items.length > 0) {
let more = ''
if (this.state.page < 4) {
more = <Link to = { '/news/' + (this.state.page + 1) }>more...</Link>
}
let prev = ''
if (this.state.page > 1) {
prev = <Link to = { '/news/' + (this.state.page - 1) }>< prev</Link>
}
nav = <div className={styles.nav} >{prev} {more}</div>
}
return <div className={`${styles.newsview} ${mainstyles.view} ${!items.length && styles.loading}`} >{items}{nav}</div>
}
}
export default CSSModules(NewsView, styles)
Reactのライフサイクルについては既に多数の記事があるため説明は割愛します。公式はこちらとなります。
こちらもvueと処理は同じで以下の内容となります。
- コンポーネントが作られたら
topstories-updated
イベントを登録 - コンポーネントが削除されたら
topstories-updated
イベントを解除 - ページの変更またはストアが更新されたら新しいデータを取得
topstories-updated
イベントからupdate
メソッドをコールされますが、コンテキストが変わってしまうため、constructor
でbind
しています。
View(Render)部分について、ReactではVueのv-if
、v-for
といったディレクティブでなく、HTMLタグを変数に入れて処理するようなイメージとなります。
実際に行っている処理は
- ストアのニュースデータ毎にItemコンポーネントを用意して配列にセットします。
- 現在のページとストアのデータ件数からフッターナビの表示項目を制御します。
- ニュースデータとフッターナビを表示します。
となります。
mainstyles.view
について、前述のとおりclass名は内部でBEM形式に変換された文字列となるため、親コンポーネントのスタイルを利用する場合は再度sassファイルをimportし文字列を取得する必要があります。
またストアにデータがセットされるまではloadingを表示します。(`${~}`に関してはこちら)
5.Item.js
import React from 'react'
import CSSModules from 'react-css-modules'
import { Link } from 'react-router-dom'
import { domain, fromNow } from '../filters'
import styles from '../css/Item.sass'
import itemviewstyles from '../css/ItemView.sass'
class Item extends React.Component {
constructor (props) {
super(props)
this.state = {
item: {},
index: 0
}
}
componentWillMount () {
this.setState({ index: parseInt(this.props.index) })
this.setState({ item: this.props.item })
}
componentWillReceiveProps (newProps) {
this.setState({ index: parseInt(this.props.index) })
this.setState({ item: this.props.item })
}
render () {
let elmIndex = (this.state.index !== 0) ? <span className={styles.index} >{this.state.index}.</span> : ''
let itemClass = styles.item + ((this.state.index !== 0) ? '' : ' ' + itemviewstyles.item)
let domainName = ''
if (this.state.item.type === 'story') {
domainName = <span className={styles.domain} >
({ domain(this.state.item.url) })
</span>
}
let info = ''
if (this.state.item.type === 'story' || this.state.item.type === 'poll') {
info = <span >
{this.state.item.score} points by
<Link to = { '/user/' + this.state.item.by } >{this.state.item.by}</Link>
</span>
}
let descendants = ''
if (this.state.item.descendants) {
if (this.state.index !== 0) {
descendants = <span className='comments-link' > | <Link to = { '/item/' + this.state.item.id } >{this.state.item.descendants} comments</Link></span>
} else {
descendants = <span className='comments-link' > | {this.state.item.descendants} comments</span>
}
}
return <div className={itemClass} >
{elmIndex}
<p>
<a className='title' href={this.state.item.url} target='_blank' >{this.state.item.title}</a>
{domainName}
</p>
<p className={styles.subtext} >
{ info }
{ fromNow(this.state.item.time) } ago
{ descendants }
</p>
</div>
}
}
export default CSSModules(Item, styles)
Item.jsはニュース一覧、コメント詳細の2パターンから利用されています。
ニュース一覧に表示する場合はindexに数字がセットされ項番が表示されます。
コメント詳細に表示する場合は一部に親となるItemViewのスタイルを適用します。
コメント数をクリックすることでコメント詳細にページ内遷移しますが、コメント詳細でクリックした場合、同じページに遷移することになり、以下のWarningが表示されます。
Warning: Hash history cannot PUSH the same path; a new entry will not be added to the history stack
なのでコメント詳細ページではLinkタグを外すようにしています。
filterとなるdomain
、fromNow
は単純に関数呼出しで利用します。
6.UserView.js
import React from 'react'
import CSSModules from 'react-css-modules'
import store from '../store'
import { fromNow } from '../filters'
import mainstyles from '../css/Main.sass'
import styles from '../css/UserView.sass'
class UserView extends React.Component {
constructor (props) {
super(props)
this.state = {
id: props.match.params.id || '',
user: {}
}
}
componentWillMount () {
store.fetchUser(this.state.id).then(user => {
document.title = 'Profile: ' + this.state.id + ' | React.js HN Clone'
this.setState({ user: user })
})
}
render () {
return <div className={styles.userview + ' ' + mainstyles.view} >
<ul>
<li><span styleName="label">user:</span> {this.state.user.id}</li>
<li><span styleName="label">created:</span> {fromNow(this.state.user.created)} ago</li>
<li><span styleName="label">karma:</span> {this.state.user.karma}</li>
<li>
<span styleName="label">about:</span>
{ React.createElement('div', { styleName: 'about', dangerouslySetInnerHTML: { __html: this.state.user.about } }) }
</li>
</ul>
<p styleName="links">
<a href={'https://news.ycombinator.com/submitted?id=' + this.state.user.id}>submissions</a><br />
<a href={'https://news.ycombinator.com/threads?id=' + this.state.user.id}>comments</a><br />
</p>
</div>
}
}
export default CSSModules(UserView, styles)
ページ表示(コンポーネントの作成)時にユーザーの情報を取得しページのタイトルを書換えます。ユーザーの紹介文はHTML形式でレスポンスされるためdangerouslySetInnerHTML
を利用してinnerHtmlとしてセットします。ただしXSSが発生する可能性があるため利用には注意が必要です。公式にも記載があります。
7.ItemView.js
import React from 'react'
import CSSModules from 'react-css-modules'
import store from '../store'
import Item from './Item'
import Comment from './Comment'
import mainstyles from '../css/Main.sass'
import styles from '../css/ItemView.sass'
class ItemView extends React.Component {
constructor (props) {
super(props)
this.state = {
id: props.match.params.id || '',
item: {},
comments: [],
pollOptions: null
}
}
componentWillMount () {
let _self = this
store.fetchItem(this.state.id).then(item => {
store.fetchItems(item.kids).then(comments => {
document.title = item.title + ' | React.js HN Clone'
_self.setState({
item: item,
comments: comments
})
}).then((comments) => {
if (item.type === 'poll') {
store.fetchItems(item.parts).then(pollParts => {
_self.setState({pollOptions: pollParts})
})
}
})
})
}
render () {
let itemview
if (this.state.item.id) {
let item = this.state.item
let itemtext = <p styleName="itemtext">{item.text}</p>
let polloptions = []
if (this.state.pollOptions) {
for (var i = 0; i < this.state.pollOptions.length; i++) {
let polloption = <li key={this.state.pollOptions[i].id}>
{ React.createElement('p', { dangerouslySetInnerHTML: { __html: this.state.pollOptions[i].text } }) }
<p styleName="subtext">{this.state.pollOptions[i].score} points</p>
</li>
polloptions.push(polloption)
}
}
let commentTags
if (this.state.comments) {
let _comments = []
for (let comment of this.state.comments) {
_comments.push(<Comment key={comment.id} comment={comment} />)
}
commentTags = <ul className="comments">
{_comments}
</ul>
} else if (!this.state.comments && this.item.type === 'job') {
commentTags = <p>No comments yet.</p>
}
itemview = <div className={styles.itemview + ' ' + mainstyles.view} >
<Item key={this.state.item.id} index={0} item={this.state.item} />
{itemtext}
<ul className={styles.polloptions} >
{polloptions}
</ul>
{commentTags}
</div>
}
return itemview
}
}
export default CSSModules(ItemView, styles)
コメント詳細の表示で利用します。ページ表示(コンポーネントの作成)時に該当ニュースのコメントを取得し、後述のCommentコンポーネント側で再帰処理でコメントのコメントを取得します。またページのタイトルをニュースタイトルで書換えます。
storeのコールバックではコンテキストが変わってしまうためlet _self = this
でコンテキストを保持し_self.setState
でstateをセットしています。
Vueでは以下の様にdata(state)に直接promiseを渡していましたがReactでは結果を渡さなければrenderが上手く動作しませんでした。
data ({ to }) {
return store.fetchItem(to.params.id).then(item => {
document.title = item.title + ' | Vue.js HN Clone'
return {
item,
// the final resolved data can further contain Promises
comments: store.fetchItems(item.kids),
pollOptions: item.type === 'poll'
? store.fetchItems(item.parts)
: null
}
})
}
8.Comment.js
import React from 'react'
import CSSModules from 'react-css-modules'
import { fromNow } from '../filters'
import styles from '../css/Comment.sass'
import store from '../store'
class Comment extends React.Component {
constructor (props) {
super(props)
this.state = {
comment: props.comment,
childComments: [],
open: true
}
this.changeToggle = this.changeToggle.bind(this)
}
componentWillMount () {
if (this.state.comment.kids) {
store.fetchItems(this.state.comment.kids).then(comments => {
this.setState({ childComments: comments })
})
}
}
changeToggle (e) {
this.setState({ open: !this.state.open })
}
render () {
let comment
if (this.state.comment.text !== '') {
let childcommentTags
if (this.state.childComments.length > 0 && this.state.open) {
let _childcomments = []
for (let comment of this.state.childComments) {
_childcomments.push(<Comment key={comment.id} comment={comment} />)
}
childcommentTags = <ul className={styles.childcomments}>
{_childcomments}
</ul>
}
let toggleLabel = (this.state.open) ? '[-]' : '[+]'
comment = <li>
<div className={styles.comhead}>
<a className={styles.toggle} onClick={this.changeToggle}>{toggleLabel}</a>
<a href={'#/user/' + this.state.comment.by}>{this.state.comment.by}</a>
{ fromNow(this.state.comment.time) } ago
</div>
<div className={!this.state.open && styles.commenthide}>
{ React.createElement('p', { className: styles.commentcontent, dangerouslySetInnerHTML: { __html: this.state.comment.text } }) }
{childcommentTags}
</div>
</li>
}
return comment
}
}
export default CSSModules(Comment, styles)
コメントは左側の[+][-]アイコンで表示/非表示を行います。クリックイベントのコンテキストも最初にbindすることでstateを操作できるようにしています。
おわりに
今回はVue.jsで作られたページの移植であることと、あまりReactに慣れてなくベストプラクティスではないので一概には言えないですがVueやAngularのテンプレート内のディレクティブで操作することに慣れてしまうとコードで書くのが少し億劫でした。またReact CSS Modulesに関してもclass名が変換されることに慣れが必要です。
ただこちらの記事にもあるようにNPMパッケージ数が他と比べ群を抜いていること、大手企業が採用していること、ReactNativeなど他のプラットフォームでも有用なことを考えればReactは一度は通らなければいけない道だと感じました。