44
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ReactとDjangoでシンプルな画像付きブログアプリを作成する

Posted at

はじめに

この度、より新しいフロントエンドの技術の知識を入れるため、Reactを取り組むことにしました。
2週間ほど勉強し、フロントはReact、APIをDjangoでシンプルなブログアプリを作ったので、その手順をまとめます。

私の開発経験として、webフレームワークはRuby on Railsを1年半程で、フロンエンドはjQueryのみで、Djangoも今回初めて使用しました。

blog.gif

概要

タイトル、本文の入力と、画像が選択できる記事の投稿と削除。投稿した記事の一覧と詳細を閲覧できるページがあるシンプルなブログアプリを作成します。
流れとして、Dockerでフロントエンド、API、DB用のコンテナを作成し、APIの実装、フロントエンドの実装という順番で進めていきます。

コンテナ作成

・アプリケーションのディレクトリ作成

$ mkdir -p blog/frontend blog/api
$ cd blog

・API用のDockerfile作成

$ vi api/Dockerfile

FROM python:3.7
ENV PYTHONUNBUFFERED 1
RUN mkdir /api
WORKDIR /api
ADD ./requirements.txt .
RUN pip3 install -r requirements.txt
EXPOSE 8000

・インストールパッケージ一覧ファイル作成

$ vi api/requirements.txt

Django>=3.0.5
mysqlclient==1.4.6
djangorestframework==3.11.0
django-filter==2.2.0
Pillow==7.1.1
django-cors-headers==3.2.1

・フロントエンド用のDockerfile作成

$ vi frontend/Dockerfile

FROM node:13.13.0
RUN mkdir /frontend
WORKDIR /frontend
EXPOSE 3000

・docker-compose作成

$ vi docker-compose.yml

version: '3'
services:
 api:
   container_name: blog-api
   build:
     context: ./api
     dockerfile: Dockerfile
   command: sh -c "cd /api/blog && python3 manage.py runserver 0.0.0.0:8000"
   ports:
     - 8000:8000
   volumes:
     - ./api:/api
   depends_on:
     - db
     - front
   tty: true
 db:
   container_name: blog-db
   image: mysql:5.7
   restart: always
   environment:
     MYSQL_DATABASE: blog
     MYSQL_USER: root
     MYSQL_PASSWORD: password
     MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
   volumes:
     - ./mysql:/var/lib/mysql
   ports:
     - 3306:3306
 front:
    container_name: blog-front
    build:
      context: ./frontend
      dockerfile: Dockerfile
    command: sh -c "cd /frontend/blog && npm start"
    ports:
     - "3000:3000"
    volumes:
     - ./frontend:/frontend
    tty: true

・イメージ作成

$ docker-compose build

・DjangoとReactのアプリケーション作成

$ cd api
$ docker-compose run --rm api sh -c "django-admin startproject blog"
$ cd ../frontend
$ docker-compose run --rm front sh -c "npm install -g create-react-app && create-react-app blog"

・Djangoの設定変更

blog/settings.py
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['0.0.0.0']

DATABASES = {
    #'default': {
    #    'ENGINE': 'django.db.backends.sqlite3',
    #    'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    #}
   'default': {
   'ENGINE': 'django.db.backends.mysql',
   'NAME': 'blog',
   'USER': 'root',
   'HOST': 'blog-db',
   'POST': 3306,
   'OPTIONS': {
                'charset': 'utf8mb4',
              },
   }
}

・Reactで使うパッケージ追加

$ docker-compose run --rm front npm --prefix ./blog install axios @material-ui/core react-router-dom @babel/core @material-ui/icons

・コンテナ立ち上げ

$ docker-compose up

localhost:3000/, 0.0.0.0:8000/ に接続したら、それぞれのアプリケーションが起動していることが確認できます。

APIの作成

・記事用のアプリ作成

$ cd api/blog/
$ python manage.py startapp articles
$ cd articles

・モデル編集

models.py

import uuid
from django.db import models

class Article(models.Model):
    uuid  = models.UUIDField('uuid', primary_key=True, default=uuid.uuid4, editable=False)
    title = models.CharField(verbose_name="タイトル", max_length=100)
    body  = models.TextField('本文')
    image = models.ImageField(upload_to='post_images', null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

・APIの入出力設定を行うために、serializers.pyを新規作成します。

serializers.py
from rest_framework import serializers
from .models import Article

class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = '__all__'
        read_only_fields = ('created_at', 'updated_at')

・URLの設定を行うために、urls.pyを新規作成します

urls.py
from django.urls import re_path, include
from rest_framework import routers

from .views import ListArticle


router = routers.SimpleRouter()
router.register(r'articles', ListArticle)

urlpatterns = [
    re_path('', include(router.urls))
]

・viewファイルの編集

views.py

from rest_framework import viewsets
from .models import Article
from .serializers import ArticleSerializer
from rest_framework.response import Response

class ListArticle(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

    # 記事一覧取得
    def list(self, request):
        data = ArticleSerializer(Article.objects.all().order_by('created_at').reverse(), many=True).data

        return Response(status=200, data=data) 

    # 記事詳細取得
    def retrieve(self, request, pk=None):
        article_id = pk
        data = ArticleSerializer(Article.objects.filter(uuid=article_id), many=True).data

        return Response(status=200, data=data)

以上で、記事用のアプリの設定は終了です。
次に、今作成したアプリの読み込みや、その他設定を行います。

blog/settions.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework', #追加
    'articles' #追加
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'corsheaders.middleware.CorsMiddleware', #追加
]

# タイムゾーン変更
# TIME_ZONE = 'UTC'
TIME_ZONE = 'Asia/Tokyo'

# reactのアプリケーションからのアクセスを可能にする
CORS_ORIGIN_REGEX_WHITELIST = (
    r'^(http?://)?localhost',
    r'^(http?://)?127.',
)

# 画像の保存先設定
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
blog/urls.py

from django.contrib import admin
from django.urls import path
from django.urls import re_path, include  #追加
from django.conf import settings #追加
from django.contrib.staticfiles.urls import static #追加
from django.contrib.staticfiles.urls import staticfiles_urlpatterns #追加

urlpatterns = [
    path('admin/', admin.site.urls),
    re_path(r'^api/',   include('articles.urls')), #追加
]

# 画像にアクセス
urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

・DBマイグレート

$ docker-compose run --rm api python blog/manage.py makemigrations
$ docker-compose run --rm api python blog/manage.py migrate

以上でAPI側の実装は完了になります。
試しに、ユーザーを作成し、記事を作成してみます。

$ docker-compose run --rm api python blog/manage.py createsuperuser
# 以下設定してください

0.0.0.0:8000/api/articles/ にアクセスし、画面下部の入力フォームで、タイトルや本文を入力して、記事が登録できることを確認します。

フロントエンド作成

・環境変数設定

.env

REACT_APP_END_POINT=http://0.0.0.0:8000

・分割した関数とUIを保存するディレクトリを作成します

$ cd frontend/blog/src/
$ mkdir components function

・ヘッダー用ファイル作成

components/Header.js
import React from 'react'
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import { Typography, Button } from '@material-ui/core';
import { Link } from 'react-router-dom';

/**
* @author
* @function Header
**/

const Header = (props) => {
  return(
      <AppBar position="static">
          <Toolbar>
              <Link to="/">
                <Typography variant="h4" type="title" color="inherit" >
                    Blog
                </Typography>
              </Link>
              <div style={{marginLeft: 'auto'}}>
                  <Link to="/new">
                    <Button color="inherit">新規作成</Button>
                  </Link>
              </div>
          </Toolbar>
      </AppBar>
   )

 }

export default Header

・記事一覧用ファイル作成

components/Home.js
import React, {useState, useEffect}  from 'react';
import axios from 'axios';
import Card from '@material-ui/core/Card';
import CardMedia from '@material-ui/core/CardMedia';
import CardContent from '@material-ui/core/CardContent';
import { Typography } from '@material-ui/core';
import { NavLink } from 'react-router-dom';
import convertFormat from '../function/Convert.js';

/**
* @author
* @function Home
**/

const Home = (props) => {
  const [articleList, setArticle] = useState([]);

  useEffect(() => {axios
      .get(`${process.env.REACT_APP_END_POINT}/api/articles/`)
        .then(res => {
            const articleList = res.data;
            setArticle(articleList);
        })
        .catch(err => {
            console.log(err);
      });
  }, [props.match.params]);

  return(
     <div>
        {
          articleList.map(article => {
            return (
              <Card
                    key={`ranking-item-${article.uuid }`}
                    style={{ maxWidth: '500px', margin: '32px auto'}}
                    src={`post/${article.uuid}`}
               >
                 <NavLink key={article.uuid} to={`/post/${article.uuid}`}>
                   <CardMedia
                     image={article.image ? process.env.REACT_APP_END_POINT+article.image: 'http://design-ec.com/d/e_others_50/l_e_others_500.png'}
                     title={article.title}
                     style={{ height: '200px' }}
                   />
                   <CardContent>
                     <Typography type="title" variant="h4">
                       {article.title}
                     </Typography>
                     <Typography className='right-placement' type="title" >
                       {convertFormat(article.created_at)}
                     </Typography>
                   </CardContent>
                 </NavLink>
              </Card>
            )
          })
        }
      </div>
   )

 }

export default Home

・記事詳細用ファイル作成

components/Post.js
import React, {useState, useEffect} from 'react';
import axios from 'axios';
import Card from '@material-ui/core/Card';
import CardMedia from '@material-ui/core/CardMedia';
import CardContent from '@material-ui/core/CardContent';
import { Typography, Button } from '@material-ui/core';
import DeleteIcon from '@material-ui/icons/Delete';
import convertFormat from '../function/Convert.js'

/**
* @author
* @function Post
**/

const Post = (props) => {
    const [articleList, setArticle] = useState([]);

    useEffect(() => {
        console.log(`${process.env.REACT_APP_END_POINT}/api/articles/${props.match.params.postId}/`)
        axios
        .get(`${process.env.REACT_APP_END_POINT}/api/articles/${props.match.params.postId}/`)
          .then(res => {
              const articleList = res.data[0];
              setArticle(articleList);
          })
          .catch(err => {
              console.log(err);
        });
    }, [props.match.params.postId]);

    const deleteArticle = () => {
        
        if(window.confirm('この記事を削除してよろしいですか?')) {
            axios.delete(`${process.env.REACT_APP_END_POINT}/api/articles/${articleList.uuid}/`)
            .then(res => {
                window.location.href="/";
            }).catch(err => {
                console.log(err);
            });
        }
    }

  return(
    <div>
        <Card
             key={`ranking-item-${articleList.uuid }`}
             style={{ maxWidth: '1000px', margin: '32px auto'}}
             src={`post/${articleList.uuid}`}
        >
            { articleList.image && 
          <CardMedia
            image={process.env.REACT_APP_END_POINT+articleList.image}
            title={articleList.title}
            style={{ height: '400px' }}
          />
            }
          <CardContent style={{ textAlign: 'left' }}>
            <Typography variant="h3" type="title" style={{margin:'0 0 20px 0'}}>
              {articleList.title}
            </Typography>
            
            <Typography className='right-placement' type="title" >
              {convertFormat(articleList.created_at)}
            </Typography>

            <Typography variant="h5" type="title" style={{margin:'0 0 20px 0'}}>
              {articleList.body}
            </Typography>

            <div className="right-placement">
            <Button onClick={deleteArticle} variant="contained" color="secondary" size="small">
              <DeleteIcon />
                削除
            </Button>
            </div>
          </CardContent>
        </Card>    
    </div>
   )
 }

export default Post

・記事登録フォーム用ファイル作成

components/registerForm.js
import React from 'react';
import axios from 'axios';
import { Button } from '@material-ui/core';

export default class registerForm extends React.Component{

    constructor(props) {
        super(props);
        this.state = {
            title: '',
            body: '',
            image: null,
        };

        this.titleChange = this.titleChange.bind(this);
        this.bodyChange = this.bodyChange.bind(this);
        this.handleSubmit = this.handleSubmit.bind(this);
    }

    addArticle(title, body, image) {
        let form_data = new FormData();

        if(image){
          form_data.append('image', image, image.name);
        }

        form_data.append('title', title);
        form_data.append('body', body);

        axios.post(`${process.env.REACT_APP_END_POINT}/api/articles/`, form_data,{headers: {
                'content-type': 'multipart/form-data'
            }
         }).then(res => {
            window.location.href="/";
        }).catch(err => {
            console.log(err);
        });
    }

    titleChange(event) {
        this.setState({
            title: event.target.value,
        });
    }

    bodyChange(event) {
        this.setState({
            body: event.target.value,
        });
    }

    imageChange = (e) => {
      this.setState({
        image: e.target.files[0],
      })
    };

    handleSubmit(event) {
        event.preventDefault();
        if(this.state.title === ''){
            return;
        }
        if(this.state.body === ''){
            return;
        }
        this.setState({
            title: '',
            body: '',
            image: ''
        });
        this.addArticle(this.state.title, this.state.body, this.state.image);
    }

    render() {
        return (
            <div className="form central-placement">
                <form onSubmit={this.handleSubmit}>
                    <div className="form-element">
                        <input type="text" value={this.state.title} onChange={this.titleChange} className="form-element--title" placeholder="タイトル" />
                    </div>
                    <div className="form-element">
                        <textarea type="text" value={this.state.body} onChange={this.bodyChange} className="form-element--body" placeholder="本文" />
                    </div>
                    <div className="form-element">
                        <input type="file"
                           accept="image/png, image/jpeg"  onChange={this.imageChange} />
                    </div>
                    <div className="right-placement">
                        <Button variant="contained" color="primary" type="submit">新規作成</Button>
                    </div>
                </form>
            </div>
        );
    }
}

・共通関数用ファイル作成

function/Convert.js
import React from 'react';

// YYYY-MM-DD hh:mm:ss 形式で返す
export default function convertFormat(created_at) {
  if (created_at){
    var convertedCreatedAt = created_at.split(/[T.]/);
    return convertedCreatedAt[0] + ' ' + convertedCreatedAt[1];
  }
  return '';
}

・App.js編集

App.js
import React from 'react';
import Home from './components/Home';
import Post from './components/Post';
import Header from './components/Header';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import registerForm from './components/registerForm';

function App() {
  return (
    <Router>
      {console.log(process.env)}
      <div className="App">
        <Header />
        <Route path="/" exact component={Home} />
        <Route path="/post/:postId/" component={Post} />
        <Route path="/new" component={registerForm} />
      </div>
    </Router>
  );
}

export default App;

・最後にスタイルを編集します。

index.css
// 追加
a {
  text-decoration: none;
}

.central-placement {
  display: flex;
  justify-content: center;
}

.right-placement {
  display: flex;
  justify-content: flex-end;
}

form {
  width: 300px;
}

.form-element {
  margin: 20px 0;
}

.form-element--title {
  width: 100%;
  height: 25px;
}

.form-element--body {
  width: 100%;
  height: 50px; 
  resize: none;
}

以上でフロント側の実装は完了です。
再度、localhost:3000にアクセスすると、ブログアプリが使用できます。

終わりに

細かいことを省略してコードを羅列する形で申し訳ないです。(また改めて追加したいと思います。。)

まだ、Reactをやり始めたばかりですが、jQueryでDOM操作する時代に比べて、フロントエンドの敷居が高くなったなという印象です。
ですが、ReactやVueに移行していく流れがあるので、引き続きReactの勉強も行なっていきたいと思います。

#参考文献
・React入門 React・Reduxの導入からサーバサイドレンダリングによるUXの向上まで
https://www.arimakaoru.com/blog/create-todoapp-react-django-rest-framework
https://github.com/Rizwan17/reactjs-blog

44
43
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?