[PART 16] GraphQL, Typescript 및 React로 Twitter 복제본 만들기( 트윗 타임라인 )

안녕하세요 여러분 ;).

참고로 저는 이렇게 하고 있습니다Tweeter challenge



Db diagram

밥을 먹이다



피드에서 작업하는 동안 너무 많은 SQL 요청을 수행하고 있음을 알았습니다. "카운트"데이터 로더를 삭제하고 피드 기능에서 직접 카운트를 가져오기로 결정했습니다.

src/TweetResolver.ts

async feed(@Ctx() ctx: MyContext) {
    const { db, userId } = ctx

    const followedUsers = await db('followers')
      .where({
        follower_id: userId,
      })
      .pluck('following_id')

    const tweets = await db('tweets')
      .whereIn('user_id', followedUsers)
      .orWhere('user_id', userId)
      .orderBy('id', 'desc')
      .select(selectCountsForTweet(db))
      .limit(20)

    return tweets
  }


그리고 selectCountsForTweet()의 경우:

유틸리티/utils.ts

export const selectCountsForTweet = (db: Knex) => {
  return [
    db.raw(
      '(SELECT count(tweet_id) from likes where likes.tweet_id = tweets.id) as "likesCount"'
    ),
    db.raw(
      `(SELECT count(t.parent_id) from tweets t where t.parent_id = tweets.id and t.type = 'comment') as "commentsCount"`
    ),
    db.raw(
      `(SELECT count(t.parent_id) from tweets t where t.parent_id = tweets.id and t.type = 'retweet') as "retweetsCount"`
    ),
    'tweets.*',
  ]
}


낙타 케이스 이름을 가지려면 카운트 이름 주위에 큰따옴표를 추가해야 한다는 것을 배웠습니다 ;). 따라서 graphQL 쿼리를 변경할 필요가 없습니다. parentTweetDataloader에도 이 기능이 필요합니다.

src/데이터로더

parentTweetDataloader: new DataLoader<number, Tweet, unknown>(async (ids) => {
    const parents = await db('tweets')
      .whereIn('id', ids)
      .select(selectCountsForTweet(db))
    return ids.map((id) => parents.find((p) => p.id === id))
  }),


백엔드에 충분합니다. 나는 당신이 코드를 확인하자

피드 작업



src/페이지/Home.tsx

import React from 'react'
import Layout from '../components/Layout'
import Feed from '../components/tweets/Feed'

const Home = () => {
  return (
    <Layout>
      {/* Tweet Column */}
      <div className="container max-w-container flex mx-auto gap-4">
        <div className="w-full md:w-tweetContainer">
          {/* Tweet Form */}

          {/* Tweet Feed */}
          <Feed />
        </div>

        {/* Home Sidebar */}
        <div className="hidden md:block w-sidebarWidth bg-gray5 flex-none">
          Sidebar
        </div>

        {/* Hashtags */}

        {/* Followers Suggestions */}
      </div>
    </Layout>
  )
}

export default Home



레이아웃 구성 요소를 확인할 수 있습니다. Navbar와 어린이 소품이 있는 작은 포장지입니다.

피드 구성 요소도 정말 간단합니다.

src/components/tweets/feed.tsx

import { useQuery } from '@apollo/client'
import React, { useEffect } from 'react'
import { useRecoilState, useSetRecoilState } from 'recoil'
import { FEED } from '../../graphql/tweets/queries'
import { tweetsState } from '../../state/tweetsState'
import { TweetType } from '../../types/types'
import Tweet from './Tweet'

const Feed = () => {
  const [tweets, setTweets] = useRecoilState(tweetsState)
  const { data, loading, error } = useQuery(FEED)

  useEffect(() => {
    if (data && data.feed && data.feed.length > 0) {
      setTweets(data.feed)
    }
  }, [data])

  if (loading) return <div>Loading...</div>
  return (
    <div className="w-full">
      {tweets.length > 0 && (
        <ul>
          {tweets.map((t: TweetType) => (
            <Tweet key={t.id} tweet={t} />
          ))}
        </ul>
      )}
    </div>
  )
}

export default Feed


다음은 GraphQL 쿼리입니다.

src/graphql/tweets/queries.ts

import { gql } from '@apollo/client'

export const FEED = gql`
  query {
    feed {
      id
      body
      visibility
      likesCount
      retweetsCount
      commentsCount
      parent {
        id
        body
        user {
          id
          username
          display_name
          avatar
        }
      }
      isLiked
      type
      visibility
      user {
        id
        username
        display_name
        avatar
      }
      created_at
    }
  }
`



그리고 구성 요소의 경우:

src/components/tweets/Tweet.tsx

import React from 'react'
import { MdBookmarkBorder, MdLoop, MdModeComment } from 'react-icons/md'
import { useRecoilValue } from 'recoil'
import { userState } from '../../state/userState'
import { TweetType } from '../../types/types'
import { formattedDate, pluralize } from '../../utils/utils'
import Avatar from '../Avatar'
import Button from '../Button'
import IsLikedButton from './actions/IsLikedButton'

type TweetProps = {
  tweet: TweetType
}

const Tweet = ({ tweet }: TweetProps) => {
  const user = useRecoilValue(userState)

  const showRetweet = () => {
    if (tweet.user.id === user!.id) {
      return <div>You have retweeted</div>
    } else {
      return <div>{tweet.user.display_name} retweeted</div>
    }
  }

  return (
    <div className="p-4 shadow bg-white rounded mb-6">
      {/* Retweet */}
      {tweet.type === 'retweet' ? showRetweet() : ''}
      {/* Header */}
      <div className="flex items-center">
        <Avatar className="mr-4" display_name={tweet.user.display_name} />

        <div>
          <h4 className="font-bold">{tweet.user.display_name}</h4>
          <p className="text-gray4 text-xs mt-1">
            {formattedDate(tweet.created_at)}
          </p>
        </div>
      </div>

      {/* Media? */}
      {tweet.media && <img src={tweet.media} alt="tweet media" />}
      {/* Body */}
      <div>
        <p className="mt-6 text-gray5">{tweet.body}</p>
      </div>

      {/* Metadata */}
      <div className="flex justify-end mt-6">
        <p className="text-gray4 text-xs ml-4">
          {pluralize(tweet.commentsCount, 'Comment')}
        </p>
        <p className="text-gray4 text-xs ml-4">
          {pluralize(tweet.retweetsCount, 'Retweet')}{' '}
        </p>
      </div>

      <hr className="my-2" />
      {/* Buttons */}
      <div className="flex justify-around">
        <Button
          text="Comments"
          variant="default"
          className="text-sm"
          icon={<MdModeComment />}
          alignment="left"
        />
        <Button
          text="Retweets"
          variant="default"
          className="text-sm"
          icon={<MdLoop />}
          alignment="left"
        />

        <IsLikedButton id={tweet.id} />

        <Button
          text="Saved"
          variant="default"
          className="text-sm"
          icon={<MdBookmarkBorder />}
          alignment="left"
        />
      </div>
    </div>
  )
}

export default Tweet



그 모습은 다음과 같습니다.



나중에 IsLikedButton에 대해 이야기하겠습니다.

리트윗이 무엇인지 알아보겠습니다. 리트윗을 생각하는 방식을 바꿔야 할 것 같습니다. 현재 리트윗은 부모와 함께하는 일반적인 트윗입니다. 하지만 실제로는 리트윗에는 tweet_id와 user_id를 참조하는 테이블만 있어야 한다고 생각합니다. 나중에 변경하고 프런트엔드의 동작을 반영하겠습니다 ;).

ApolloClient와 캐시?



ApolloClient는 캐시와 함께 제공되며 실제로 이를 사용하여 데이터를 업데이트할 수 있습니다(글로벌 스토어처럼). 사용자가 트윗을 좋아할 때 트윗을 업데이트하려고 했습니다. 문제는 사용자가 트윗을 좋아하거나 싫어하면 모든 트윗을 다시 렌더링한다는 것입니다. 제 경우에는 좋아요 버튼만 다시 렌더링하고 싶습니다. 나는 apolloClient로 해결책을 찾지 못했기 때문에 반동을 사용하여 모든 트윗을 저장하고 더 많은 유연성을 가질 것입니다(현재 지식 관점에서 :D).

src/state/tweetsState.ts

import { atom, atomFamily, selectorFamily } from 'recoil'
import { TweetType } from '../types/types'

export const tweetsState = atom<TweetType[]>({
  key: 'tweetsState',
  default: [],
})

export const singleTweetState = atomFamily<TweetType | undefined, number>({
  key: 'singleTweetState',
  default: selectorFamily<TweetType | undefined, number>({
    key: 'singleTweetSelector',
    get: (id: number) => ({ get }) => {
      return get(tweetsState).find((t) => t.id === id)
    },
  }),
})

export const isLikedState = atomFamily({
  key: 'isLikedTweet',
  default: selectorFamily({
    key: 'isLikedSelector',
    get: (id: number) => ({ get }) => {
      return get(singleTweetState(id))?.isLiked
    },
  }),
})



tweetsState는 트윗을 저장합니다. singleTweetState를 사용하면 get 메서드에서 tweetsState를 사용하여 단일 트윗을 얻을 수 있습니다. 마지막으로 isLikedState는 트윗의 isLiked 속성에만 관심이 있습니다.

모든 것이 실제로 작동하는 것을 봅시다:

src/components/tweets/feed.tsx

const Feed = () => {
  const [tweets, setTweets] = useRecoilState(tweetsState)
  const { data, loading, error } = useQuery(FEED)

  useEffect(() => {
    if (data && data.feed && data.feed.length > 0) {
      setTweets(data.feed)
    }
  }, [data])



GraphQL 쿼리에서 데이터를 가져온 경우 setTweets 메서드를 사용하여 글로벌 스토어에 트윗을 저장합니다.

이제 IsLikedButton을 살펴보겠습니다.

src/components/tweets/actions/IsLikedButton.tsx

import { useMutation } from '@apollo/client'
import React from 'react'
import { MdFavoriteBorder } from 'react-icons/md'
import { useRecoilState, useRecoilValue } from 'recoil'
import { TOGGLE_LIKE } from '../../../graphql/tweets/mutations'
import { isLikedState } from '../../../state/tweetsState'
import Button from '../../Button'

type IsLIkedButtonProps = {
  id: number
}

const IsLikedButton = ({ id }: IsLIkedButtonProps) => {
  const [isLiked, setIsLiked] = useRecoilState(isLikedState(id))

  const [toggleLike, { error }] = useMutation(TOGGLE_LIKE, {
    variables: {
      tweet_id: id,
    },
    update(cache, { data: { toggleLike } }) {
      setIsLiked(toggleLike.includes('added'))
    },
  })
  return (
    <Button
      text={`${isLiked ? 'Liked' : 'Likes'}`}
      variant={`${isLiked ? 'active' : 'default'}`}
      className={`text-sm`}
      onClick={() => toggleLike()}
      icon={<MdFavoriteBorder />}
      alignment="left"
    />
  )
}

export default IsLikedButton



글로벌 스토어에서 isLiked 선택기를 가져오는 데 필요하므로 tweet_id를 소품으로 전달합니다.

그런 다음 apolloClient의 useMutation을 사용하여 toggleLike 요청을 만듭니다. 변경이 완료되면 업데이트 키를 사용하여 원하는 모든 작업을 수행할 수 있습니다. 여기에서 isLiked 속성을 변경합니다. 이렇게 하면 내 버튼만 다시 렌더링됩니다.



오늘은 이 정도면 충분할 것 같아요!

좋은 하루 되세요;)

좋은 웹페이지 즐겨찾기