6月1日に公開しました。ここ! → https://litfire.jp
https://twitter.com/babelrc/status/1130786347608727552
個人開発も含めて新しいサービスが見つかる・投稿できる場所が欲しいなと思いつくりました。ログインしなくてもURLだけで誰でも簡単に投稿できるようになっています。自分のものである必要もありません、見つけたサービスをぜひ投稿してください。
LITFIREは二匹で開発しました。開発期間は二日ほどです。それから、三週間ほど何人かのユーザにテストしてもらいました。コンテンツも200以上に増えました。特に、RTしてくれた人たちは感謝です。
Webアプリの中身
WebページはTypeScriptとcreate-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/core
のuseMediaQuery
という関数を使用しています。コンポーネント内でブレイクポイントを扱うことができます。
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/styles
のStylesProvider
および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.scrollTouseEffect
を用いて必要な時だけ呼び出すようにしてください。(何かをタップするたびに上にスクロールされたらイライラすると思います。)
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}`
}
head
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)
さいごに
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
0件のコメント