BETA

最新のサービスが見つかる「LITFIRE」を開発しました!

投稿日:2019-06-01
最終更新:2019-06-02

6月1日に公開しました。ここ! → https://litfire.jp

https://twitter.com/babelrc/status/1130786347608727552

個人開発も含めて新しいサービスが見つかる・投稿できる場所が欲しいなと思いつくりました。ログインしなくてもURLだけで誰でも簡単に投稿できるようになっています。自分のものである必要もありません、見つけたサービスをぜひ投稿してください。

LITFIREは二匹で開発しました。開発期間は二日ほどです。それから、三週間ほど何人かのユーザにテストしてもらいました。コンテンツも200以上に増えました。特に、RTしてくれた人たちは感謝です。

Webアプリの中身

WebページはTypeScriptcreate-react-appで開発しています。
依存する主なモジュールたちです。

{  
  "algoliasearch": "^3.33.0",  
  "classnames": "^2.2.6",  
  "firebase": "^6.1.0",  
  "first-input-delay": "^0.1.3",  
  "notistack": "^0.8.5",  
  "qs": "^6.7.0",  
  "react": "^16.8.6",  
  "react-dom": "^16.8.6",  
  "react-player": "^1.11.0",  
  "react-router-dom": "^5.0.0",  
  "react-scripts": "^3.0.1",  
  "react-share": "^2.4.0",  
  "react-spring": "^8.0.20",  
  "rxfire": "^3.5.0",  
  "rxjs": "^6.5.2",  
  "typescript": "3.4.5"  
}  

Material-UI

Material-UIというUIコンポーネントライブラリを使用しています。最高のライブラリです。
Material-UI: The world's most popular React UI framework
例えば、Cardの右上にあるタップすると回転するボタンは以下のように定義してます。

import { IconButton, Theme } from '@material-ui/core'  
import ExpandMoreIcon from '@material-ui/icons/ExpandMore'  
import { makeStyles } from '@material-ui/styles'  
import classNames from 'classnames'  
import React, { FunctionComponent, memo } from 'react'  

type Props = {  
  expanded: boolean  
  disabled?: boolean  
  onClick: () => void  
}  

const IconButtonExpand: FunctionComponent<Props> = ({  
  expanded,  
  disabled = false,  
  onClick  
}) => {  
  const classes = useStyles()  

  return (  
    <IconButton disabled={disabled} onClick={onClick}>  
      <ExpandMoreIcon  
        className={classNames(classes.icon, { [classes.expanded]: expanded })}  
      />  
    </IconButton>  
  )  
}  

const useStyles = makeStyles<Theme>(({ transitions }) => {  
  return {  
    expanded: { transform: 'rotate(180deg)' },  
    icon: {  
      transition: transitions.create('transform', {  
        duration: transitions.duration.shortest  
      })  
    }  
  }  
})  

export default memo(IconButtonExpand)  

これのことです。

useMediaQuery

@material-ui/coreuseMediaQueryという関数を使用しています。コンポーネント内でブレイクポイントを扱うことができます。
useMediaQuery - Material-UI
デスクトップで2カラムなのでモバイルとではコンポーネントを切り替える必要がありました。

import { Theme } from '@material-ui/core'  
import useMediaQuery from '@material-ui/core/useMediaQuery'  
import { useTheme } from '@material-ui/styles'  

const RouteHome: FunctionComponent = () => {  
  const theme = useTheme<Theme>()  
  const isMobile = useMediaQuery(theme.breakpoints.down('sm'))  

  if (isMobile) {  
    return (  
      <DivRoot>  
        <Main>  
          <CardProductHeader />  
          <CardProductSiteURL />  
          <CardProductImage />  
          <CardProductUpvote />  
          <CardProductTags />  
          <CardProductPlatform />  
          <CardProductSiteURLs />  
          <CardProductComments />  
          <CardProductTwitter />  
          <CardProductPages />  
          <CardRelatedProduct />  
          <CardProductShare />  
          <CardNewProducts />  
        </Main>  
      </DivRoot>  
    )  
  }  

  return (  
    <DivRoot>  
      <CardProductHeader />  
      <DivColumns>  
        <Aside>  
          <CardProductSiteURL />  
          <CardProductUpvote />  
          <CardProductPlatform />  
          <CardProductTwitter />  
          <CardProductSiteURLs />  
          <CardProductShare />  
          <CardProductRefetch />  
          <CardNewProducts />  
        </Aside>  
        <Main>  
          <CardProductImage />  
          <CardProductTags />  
          <CardProductPages />  
          <CardProductComments />  
          <CardRelatedProduct />  
        </Main>  
      </DivColumns>  
    </DivRoot>  
  )  
}  

モバイルではCardの並びを変えています。

Themes

@material-ui/stylesStylesProviderおよびThemeProviderを用いてライブラリのコンポーネントのデフォルトのPropsを定義できます。
Themes - Material-UI
何度も同じPropsを定義する必要が無くなり可読性が高くなるかな思います。

export const createTheme = () => {  
  return createMuiTheme({  
    props: {  
      MuiButton: { variant: 'outlined' },  
      MuiCardHeader: {  
        titleTypographyProps: { variant: 'h6' },  
        subheaderTypographyProps: { variant: 'caption' }  
      },  
      MuiList: { disablePadding: true }  
    },  
    typography: { fontFamily: ['Helvetica', 'sans-serif'].join(',') }  
  })  
}  

ちなみにwindow.themeのようにしておくと開発中にWebインスペクタから内容確認できます。

if (process.env.NODE_ENV === 'development') {  
  window.theme = createTheme()  
}  

CSS Grid Layout

CSSに関する知識が無いので、marginは使わず全てのコンポーネントにdisplay: gridを用いています。
グリッドレイアウトの基本概念 - CSS: カスケーディングスタイルシート | MDN
例えば、Cardを並べる時に使用するulはReactコンポーネントとして定義してます

const UlGrid: FunctionComponent = ({ children }) => {  
  const classes = useStyles()  

  return <ul className={classes.ul}>{children}</ul>  
}  

const useStyles = makeStyles<Theme>(({ breakpoints, spacing }) => {  
  return {  
    ul: {  
      display: 'grid',  
      gridAutoRows: 'min-content',  
      gridGap: spacing(2),  
      listStyle: 'none',  
      padding: 0,  
      margin: 0,  
      [breakpoints.down('sm')]: { gridGap: spacing(1) }  
    }  
  }  
})  

RxFire

RxFireというライブラリを使用しています。
Introducing RxFire: Easy async Firebase for all frameworks
例えば、以下のような関数を定義してReactコンポーネント内で呼び出しています。

import { firestore } from 'firebase/app'  
import { PRODUCTS } from 'modules/constants/collection'  
import { Product } from 'modules/firestore/types/product'  
import { docData } from 'rxfire/firestore'  

export const watchProduct = (productId: string) => {  
  return docData<Product>(  
    firestore()  
      .collection(PRODUCTS)  
      .doc(productId)  
  )  
}  

この関数はProductをリアルタイムデータを取得するものです。Observableはunsubscribeできるのでコンポーネントが破棄された後にuseStateの関数が呼び出されて壊れるのを防ぐことができます。

const RouteHome: FunctionComponent = () => {  
  const [product, setProduct] = useState<Product>([])  
  const [loading, setLoading] = useState(false)  

  useEffect(() => {  
    const subscription = getProduct(productId).subscribe(_product => {  
      setProducts(_product)  
      setLoading(false)  
    })  
    return () => subscription.unsubscribe()  
  }, [])  
}  

HTTPS Callable function

Callable functionを使用しています。
アプリから関数を呼び出す
以下はプロダクトを作成する関数ですが、引数と戻り値を型定義するのが安全です。型定義はCloud Functionsと共有します。

import { app } from 'firebase/app'  
import { ASIA_NORTHEAST1 } from 'modules/firebase/region'  
import { CreateProductData } from 'modules/firebase/types/createProductData'  
import { CreateProductResult } from 'modules/firebase/types/createProductResult'  
import { httpsCallable } from 'rxfire/functions'  

export const createProduct = () => {  
  return httpsCallable<CreateProductData, CreateProductResult>(  
    app().functions(ASIA_NORTHEAST1),  
    'createProduct'  
  )  
}  

React Router

react-router-domを用いてルーティングを実装しています。
React Router DOM - React Router: Declarative Routing for React.js
パスが切り替わるごとにGoogleAnalyticsにデータを送信できるようにしています。

const RouteIndex: FunctionComponent = () => {  
  const isStandalone = detectStandalone()  

  return (  
    <BrowserRouter>  
      <RouteGoogleAnalytics  
        disabled={process.env.NODE_ENV === 'development'}  
      />  
      <AppBarDefault />  
      <Switch>  
        <Route component={RouteFeedIndex} path={'/feeds'} />  
        <Route component={RouteGroupingIndex} path={'/groupings'} />  
        <Route component={RouteHome} exact path={'/'} />  
        <Route component={RoutePolicy} path={'/policy'} />  
        <Route component={RouteProductIndex} path={'/products'} />  
        <Route component={RouteSearchIndex} path={'/search'} />  
        <Route component={RouteTagIndex} path={'/tags'} />  
        <Route component={RouteUserIndex} path={'/users'} />  
        <Route component={RouteNotFound} path={'*'} />  
      </Switch>  
      {isStandalone && <BottomNavigationDefault />}  
    </BrowserRouter>  
  )  
}  

また、ルーティングをネストさせるようにしてしています。例えば、RouteProductIndexは以下のように定義されています。

const RouteProductIndex: FunctionComponent = () => {  
  return (  
    <Switch>  
      <Route component={RouteProductHome} exact path={'/products'} />  
      <Route component={RouteProductDetail} path={'/products/:productId'} />  
    </Switch>  
  )  
}  

History API

SPAではHistory APIに頼ることになりますが、スクロール位置のリセットを防ぐ工夫が必要です。
History API: Scroll Restoration
賢い方法ではないですが、useStateの初期値を復元してローディングを表示するのを防いでいます。

const RouteHome: FunctionComponent = () => {  
  const [__products, __setProducts] = useCacheState<Product[][]>('RouteHome', [])  
  const [products, setProducts] = useState<Product[][]>(__product)  
  const [loading, setLoading] = useState(products.length === 0)  

window.scrollTo

SPAでは新しいページに遷移した際に、スクロール位置を一番上に戻してあげる必要があります。
window.scrollTo
useEffectを用いて必要な時だけ呼び出すようにしてください。(何かをタップするたびに上にスクロールされたらイライラすると思います。)

const RouteHome: FunctionComponent = () => {  
  useEffectScrollTo()  
}  

例えば、以下のような関数が定義できます。

import { useEffect } from 'react'  

export const useEffectScrollTo = (dep?: any) => {  
  return useEffect(() => {  
    window.scrollTo({ behavior: 'smooth', top: 0 })  
  }, [dep])  
}  

Images GO API

画像の圧縮にImages Go APIを使用しています。
Images Go API の概要
URLからリサイズしたURLを生成する関数を定義しておくのがいいです。

return <img src={resizeURL(product.headerPhotoURL, { c: true })} />  

例えば、このように定義できます。

type Option = {  
  s?: number  
  c?: boolean  
}  

export const resizeURL = (url: string, option: Option) => {  
  if (!url) return ''  
  if (!option.s && !option.c) return url  
  const s = option.s ? `${option.s}` : ''  
  const c = option.c ? `-${option.c}` : ''  
  return `${url}=${s}${c}`  
}  

index.htmlは以下のように定義しています。
GoogleBotに正しい情報を読み取ってもらう為には、コンポーネントが呼び出された際にこの値が変わるようにしなければいけません。

<head>  
  <meta name="description" content="description" />  
  <meta name="theme-color" content="#2962ff" />  
  <meta name="twitter:card" content="" />  
  <meta name="twitter:site" content="" />  
  <meta property="og:description" content="" />  
  <meta property="og:image" content="" />  
  <meta property="og:title" content="LITFIRE" />  
  <meta property="og:url" content="" />  
  <title>LIT FIRE</title>  
</head>  

以下のようなReactコンポーネントを定義しておくといいです。

import { FunctionComponent, memo, useEffect } from 'react'  

const descriptionDom = document.querySelector<HTMLMetaElement>(  
  'meta[name="description"]'  
)  
const descriptionDefault = descriptionDom ? descriptionDom.content : ''  

const titleDefault = document.title  

type Props = {  
  title?: string  
  description?: string  
  image?: string  
}  

const Head: FunctionComponent<Props> = ({  
  title = null,  
  description = null,  
  image = null  
}) => {  
  const _title = title ? `${title} | ${titleDefault}` : titleDefault  
  const _description = description || descriptionDefault  
  const _image = image || ''  

  useEffect(() => {  
    if (!document || !document.head) return  

    document.title = _title  

    const metaHtmlCollection = Array.from(document.head.children)  

    for (const meta of metaHtmlCollection) {  
      const name = meta.getAttribute('name')  

      if (name && name.includes('description')) {  
        meta.setAttribute('content', _description)  
      }  

      if (name && name.includes('twitter:card')) {  
        meta.setAttribute('content', 'summary')  
      }  

      if (name && name.includes('twitter:site')) {  
        meta.setAttribute('content', '@litfireJP')  
      }  

      const property = meta.getAttribute('property')  

      if (property && property.includes('og:description')) {  
        meta.setAttribute('content', _description)  
      }  

      if (property && property.includes('og:image')) {  
        meta.setAttribute('content', _image)  
      }  

      if (property && property.includes('og:title')) {  
        meta.setAttribute('content', _title)  
      }  

      if (property && property.includes('og:url')) {  
        meta.setAttribute('content', window.location.href)  
      }  
    }  
  }, [_description, _title])  

  return null  
}  

export default memo(Head)  

さいごに

  • LITFIREの開発に関する内容を開発会議というサービスに投稿しました。ページはこちらです。
  • LITFIREを紹介する記事をCrieitというサービスに投稿しました。ページはこちらです。
技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

コンビニでバイトしてます。

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう
目次をみる
技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
or 外部アカウントではじめる
10秒で技術ブログが作れます!