[パート19 ][フロントエンド] GraphSQL、TypeScript、および反応を使用してTwitterのクローンを作成する


みんな.
として、私はこのTweeter challengeをやっている
Db diagram

リンクのプレビュー(フロントエンド)


つぶやきを送信するフォームから始めましょう.

src/コンポーネント/tweets/tweetform.TSX
import { ApolloError, useMutation } from '@apollo/client'
import { forwardRef, useEffect, useState } from 'react'
import { MdImage, MdPublic } from 'react-icons/md'
import { useRecoilValue, useSetRecoilState } from 'recoil'
import { ValidationError } from 'yup'
import { ADD_TWEET } from '../../graphql/tweets/mutations'
import { tweetsState } from '../../state/tweetsState'
import { userState } from '../../state/userState'
import { extractMetadata, handleErrors, shortenURLS } from '../../utils/utils'
import { addTweetSchema } from '../../validations/tweets/schema'
import Alert from '../Alert'
import Avatar from '../Avatar'
import Button from '../Button'

const TweetForm = () => {
  // Global state
  const user = useRecoilValue(userState)
  const setTweets = useSetRecoilState(tweetsState)

  // Local state
  const [body, setBody] = useState('')
  const [addTweetMutation, { data }] = useMutation(ADD_TWEET)
  // I create a local state for loading instead of using the apollo loading
  // because of the urlShortener function.
  const [loading, setLoading] = useState(false)
  const [errors, setErrors] = useState<ValidationError | null>(null)
  const [serverErrors, setServerErrors] = useState<any[]>([])

  const addTweet = async () => {
    setErrors(null)
    setServerErrors([])
    setLoading(true)
    // extract info from the tweet body ( urls, hashtags for now)
    const { hashtags, urls } = await extractMetadata(body)

    // Shorten the urls
    let shortenedURLS: any
    let newBody = body.slice() /* make a copy of the body */
    if (urls && urls.length > 0) {
      // Shorten the url via tinyURL
      // Not ideal but ok for now as I didn't create my own service to shorten the url
      // and I don't think I will create one ;)
      shortenedURLS = await shortenURLS(urls)
      shortenedURLS.forEach((el: any) => {
        // Need to escape characters for the regex to work
        const pattern = el.original.replace(/[^a-zA-Z0-9]/g, '\\$&')
        newBody = newBody.replace(new RegExp(pattern), el.shorten)
      })
    }

    try {
      // I should not validate hashtags and shortenedURLS as
      // it's an "intern" thing. I let it for now mostly for development purposes.
      await addTweetSchema.validate({
        body,
        hashtags,
        shortenedURLS,
      })
      await addTweetMutation({
        variables: {
          payload: {
            body: newBody ?? body,
            hashtags,
            url: shortenedURLS ? shortenedURLS[0].shorten : null,
          },
        },
      })
    } catch (e) {
      if (e instanceof ValidationError) {
        setErrors(e)
      } else if (e instanceof ApolloError) {
        setServerErrors(handleErrors(e))
      }

      console.log('e', e)
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    if (data) {
      setTweets((old) => {
        return [data.addTweet].concat(old)
      })
      setBody('')
    }
  }, [data])

  return (
    <div className="mb-4 p-4 w-full rounded-lg shadow bg-white">
      {serverErrors.length > 0 && (
        <div className="mb-4">
          {serverErrors.map((e: any, index: number) => {
            return (
              <Alert
                key={index}
                variant="danger"
                message={Array.isArray(e) ? e[0].message : e.message}
              />
            )
          })}
        </div>
      )}

      <h3>Tweet something</h3>
      <hr className="my-2" />
      <div className="flex w-full">
        <Avatar className="mr-2" display_name={user!.display_name} />
        <div className="w-full">
          <div className="w-full mb-2">
            <textarea
              rows={5}
              value={body}
              onChange={(e) => setBody(e.target.value)}
              className="w-full placeholder-gray4 p-2 "
              placeholder="What's happening"
            ></textarea>
            {errors && errors.path === 'body' && (
              <span className="text-red-500 text-sm">{errors.message}</span>
            )}
          </div>

          {/* Actions */}
          <div className="flex justify-between">
            <div className="flex items-center">
              <MdImage className="text-primary mr-2" />
              <div className="text-primary inline-flex items-center">
                <MdPublic className="mr-1" />
                <span className="text-xs">Everyone can reply</span>
              </div>
            </div>
            <Button
              text="Tweet"
              variant="primary"
              onClick={addTweet}
              disabled={loading}
              loading={loading}
            />
          </div>
        </div>
      </div>
    </div>
  )
}

export default TweetForm

ここを見ることがたくさんあります.まず第一に、つぶやきは文字列ではありません.したがって、データを抽出するつもりです.これはすべてバックエンドで行うことができますが、私は何かを持っていないので、少なくとも今のところ、私は特定のイベント(RedisとPubsubの例)を聞くことができます、私はフロントエンド側で作業を行うことを決めた.
例えば、私はリンクを抽出し、それらを短縮する必要があります.私もフロントエンドでそれをする必要がなかったとしてもハッシュタグを抽出しました.
とにかく、addtweet関数に注目しましょう.
あなたが気がつくことができる最初は、私がアポロクライアントによって提供されたロードとエラーを使用していないということです.URLを短縮することができますので、関数が起動するとすぐに状態を読み込む必要があります.同じように、私はデータをyupライブラリで検証するのでエラーを処理する必要があります.
これは、ExtractMetadataとShortenurls関数がどのように見えるかです.
export const extractMetadata = async (body: string) => {
  let hashtags = body.match(/(#[\w]+)/g)

  const urls = body.match(/https?:\/\/\S+/g)

  // Remove duplicates
  if (hashtags && hashtags?.length > 0) {
    hashtags = Array.from(new Set(hashtags))
  }
  return {
    hashtags,
    urls,
  }
}

export const shortenURLS = async (
  urls: string[]
): Promise<{ original: string; shorten: string }[]> => {
  const tinyURLS = []
  for (let url of urls) {
    const res = await TinyURL.shorten(url)
    tinyURLS.push({
      original: url,
      shorten: res,
    })
  }
  return tinyURLS
}
ここでの最大の問題は、URLを短くするために外部サービスを使うという事実です.それは少し時間がかかるので、フロントエンドでこれを行うことは理想から遠く離れている.しかしながら、私は特にURLを短くするために私自身のサービスをしたくありません.私はより良い解決方法は、バックグラウンドでURLの短縮を起動し、それが短縮URLを使用してつぶやきを更新するために行われれば、タスクを聞くためにRedisを使用することです.今のところ簡単にしましょう.
addRangeつぶやき変異について:
export const ADD_TWEET = gql`
  mutation($payload: AddTweetPayload!) {
    addTweet(payload: $payload) {
      ...tweetFragment
    }
  }
  ${TWEET_FRAGMENT}
`

あなたが見ることができるように、私は自分自身を繰り返すことを好まないので、我々はGraphSQLの断片を使用することができます.断片はこちら
src/graphql/tweets/fragmentTS
import { gql } from '@apollo/client'

export const TWEET_FRAGMENT = gql`
  fragment tweetFragment on Tweet {
    id
    body
    visibility
    likesCount
    retweetsCount
    commentsCount
    parent {
      id
      body
      user {
        id
        username
        display_name
        avatar
      }
    }
    preview {
      id
      title
      description
      url
      image
    }
    isLiked
    type
    visibility
    user {
      id
      username
      display_name
      avatar
    }
    created_at
  }
`

私は“プレビュー”の部分を言ったとは思わない.バックエンドを通してクイックツアーをして、変更したものを見ましょう

プレビューDataAlader (バックエンド)


リンクプレビューを表示するには、取得しなければなりません.そのためにdataloaderを使います.
src/DataLoader.TS
previewLinkDataloader: new DataLoader<number, unknown, unknown>(
    async (ids) => {
      const previews = await db('previews as p')
        .innerJoin('previews_tweets as pt', 'pt.preview_id', '=', 'p.id')
        .whereIn('pt.tweet_id', ids)
        .select(['p.*', 'pt.tweet_id'])

      return ids.map((id) => previews.find((p) => p.tweet_id === id))
    }
  ),
今は慣れ始めています.
私もプレビューエンティティを追加
import { Field, ObjectType } from 'type-graphql'

@ObjectType()
class Preview {
  @Field()
  id: number

  @Field()
  url: string

  @Field()
  title: string

  @Field({ nullable: true })
  description?: string

  @Field({ nullable: true })
  image?: string
}

export default Preview

と@ fieldresolver.
src/resolvers/tweetsresolvers.TS
@FieldResolver(() => Preview)
  async preview(@Root() tweet: Tweet, @Ctx() ctx: MyContext) {
    const {
      dataloaders: { previewLinkDataloader },
    } = ctx

    return await previewLinkDataloader.load(tweet.id)
  }
また、いくつかの問題を回避するために、TweetResolverのaddTweet関数で、挿入されたつぶやきを返すときに別のものを追加しました.
return {
        ...tweet,
        likesCount: 0,
        commentsCount: 0,
        retweetsCount: 0,
      }
最後に、リンクのプレビューを挿入した後、作成したDataCaladerのキャッシュをクリーンアップします.
src/events/scappreviewエミッタ.TS
import { EventEmitter } from 'events'
import { scrap } from '../utils/utils'
import knex from '../db/connection'
import { dataloaders } from '../dataloaders/dataloaders'

const scrapPreviewEmitter = new EventEmitter()

scrapPreviewEmitter.on('scrap', async (url: string, tweet_id: number) => {
  try {
    const result = await scrap(url)
    const previewsIds = await knex('previews')
      .insert({
        ...result,
        url,
      })
      .onConflict('url')
      .ignore()
      .returning('id')

    const toInsert = previewsIds.map((id) => {
      return {
        preview_id: id,
        tweet_id: tweet_id,
      }
    })

    await knex('previews_tweets').insert(toInsert)
    dataloaders.previewLinkDataloader.clear(tweet_id)
  } catch (e) {
    console.log('e', e)
  }
})

export default scrapPreviewEmitter

ところで、前にしたことを少し変えました.そして、特に私が短くなったURLを挿入するという事実、私がスクラップで得たURLではない.さもなければ、フロントエンドにはマッチがないので、プレビューを表示できませんでした.

プレビューコンポーネント


プレビューコンポーネントを追加することでジョブを終了するフロントエンド側に戻りましょう.
src/コンポーネント/つぶやき/プレビュー.TSX
const Preview = ({ preview }: any) => {
  return (
    <a
      href={preview.url}
      className="rounded shadow block p-3 hover:bg-gray3 transition-colors duration-300"
    >
      {preview.image && (
        <img
          className="rounded object-cover w-full"
          src={preview.image}
          alt={preview.title}
        />
      )}
      <h4 className="font-semibold my-2">{preview.title}</h4>
      {preview.description && <p>{preview.description}</p>}
    </a>
  )
}

export default Preview

ここではとても複雑なものはない.それにもかかわらず、私はイメージのlazyloadに注意を払わなければならない.私はgithubに問題を加えました、それで、私は忘れません;.
結果の小さなプレビューです.

私は、その部分について言いたかったことを多かれ少なかれ言いました.私が何か言及するのを忘れたならば、チェックしてください.そうでなければ、私に連絡し、コメントを残して自由に感じなさい.
さあ、お大事に.