React HooksのみでWebアプリを作りました! ( 参考にどうぞ ! )

公開日:2019-07-04
最終更新:2019-07-05
※この記事は外部サイト(https://qiita.com/uttk/items/28ad5e9dfebd9...)からのクロス投稿です

この記事について

私が作ったWebアプリをオープンソース化したので、そのWebアプリがどのような形で作られているかを解説した記事です。

オープンソース化した理由としては、これからReact Hooksを導入する人Webアプリを作ってみたい人などの参考になればと思いオープンソース化しました。

作ったもの

画像投稿ができるWebアプリです。

デモページ

デモページ

ソースコード

製作期間

  • 三か月ほど

実装した機能

  • 認証機能
  • 画像投稿機能
  • いいね機能
  • フォロー機能
  • 通知機能

使用したものなど

  • React
  • React-Router
  • dayjs
  • TypeScript
  • Firebase Authentication
  • Firebase FireStore
  • Firebase Storage
  • Firebase Hosting

なぜ作ったのか?

JavaScriptで使えるフレームワークを作成したのですが、製作した後、中々上手い実装などが浮かばず、行き詰ってしまいました。
この状況から何とか抜け出そうと考えた結果、Webアプリを作ってみようと思い、出来たのが今回のWebアプリです。

解説

ここからは、Webアプリがどのように出来ているかを解説したいと思います。

フォルダー構成について

フォルダー構成についてあまり深い関心が無い方もいるかもしれませんが、フォルダー構成はとても大切です。
個人で開発する場合も、チームで開発する場合も、フォルダー構成がしっかりしてないと良い開発はできないと私は思っています。
しかし、世の中の記事を見ていてもフォルダー構成に関する記事などがあまりありませんでした。
本当はあるのかもしれませんが、私には少ないように思えました。
なので、私が考えたフォルダー構成を皆さんに伝えたいと思います。

│  
├─node_modules  
├─public  
└─src  
  ├─assets  
  | └─icons  
  ├─components  
  │  ├─atoms  
  │  ├─modules  
  │  ├─molecules  
  │  ├─organisms  
  │  ├─pages  
  │  └─templates  
  ├─logics  
  │  ├─actions  
  │  ├─reducer  
  │  └─util  
  |    └─uses  
  └─tests  

フォルダーのみ表示してます

解説は、srcフォルダー内のみにします。

src/assets

画像などを保存するフォルダーです。

src/components

Reactコンポーネントを記述したファイルを入れるフォルダーです。
src/componentsのフォルダー構成は、Atomic Designに基づいて以下のようにコンポーネントのファイルを分割してます。

components/atoms

一番小さいコンポーネントの単位です。
React Hooksを使って無いコンポーネントのみを入れるようにします。

atomsのコンポーネント例
import React from "react";  
import styles from "./LoadingBar.module.scss";  

export const LoadingBar: React.FC<{ isLoading: boolean }> = ({ isLoading }) => {  
  return isLoading ? <div className={styles.loading_bar} /> : null;  
};  

components/molecules

atomsの次に小さいコンポーネントの単位です。
こちらも、atomsと同じくReact Hooksを使ってないコンポーネントのみを入れますが、
atomsとの違いは、要素数がatomsより多いコンポーネントになります。( 要素数がだいたい3~4個以上 )

moleculesのコンポーネント例
import React, { FormEvent } from "react";  
import styles from "./SearchInput.module.scss";  

interface SearchInputProps extends React.Props<{}> {  
  maxWidth?: number;  
  placeholder?: string;  
  onSubmit: (inputValue: string) => void;  
}  

export const SearchInput: React.FC<SearchInputProps> = ({  
  onSubmit,  
  maxWidth,  
  placeholder = "キーワードを検索"  
}) => {  
  const submit = (e: FormEvent<HTMLFormElement>) => {  
    e.preventDefault();  
    onSubmit((e.currentTarget.search as HTMLInputElement).value);  
  };  

  return (  
    <form  
      className={styles.search__input}  
      style={{ maxWidth }}  
      onSubmit={submit}  
    >  
      <div className={styles.search_icon} />  

      <input  
        type="text"  
        name="search"  
        autoComplete="off"  
        placeholder={placeholder}  
      />  
    </form>  
  );  
};  

components/organisms

コンポーネントの大きさ関係なくReact Hooksを使っていれば問答無用でこちらのフォルダーに入れられます。

organismsのコンポーネント例
import React from "react";  
import { useUsers } from "../../../logics/util/uses/users";  
import { useUtil } from "../../../logics/util/uses/util";  
import { User } from "../../../types";  
import { Button } from "../../atoms/Button";  

interface FollowButtonProps extends React.Props<{}> {  
  uid: User["id"];  
  className?: string;  
}  

export const FollowButton: React.FC<FollowButtonProps> = React.memo(  
  ({ uid, className }) => {  
    const { alerts } = useUtil();  
    const { isFollowee, current_user, followUser, unfollowUser } = useUsers();  
    const isFollowed = isFollowee(uid);  
    const currentUserId = current_user ? current_user.id : "";  
    const onClick = () => {  
      if (uid && currentUserId && currentUserId !== uid) {  
        if (isFollowed) {  
          unfollowUser(uid);  
        } else {  
          followUser(uid);  
        }  
      } else if (currentUserId) {  
        alerts.follow_user.warn();  
      } else {  
        alerts.required_login.warn();  
      }  
    };  

    return (  
      <Button  
        disabled={!uid}  
        className={className}  
        color={isFollowed ? "red" : "blue"}  
        size="small"  
        onClick={onClick}  
      >  
        {isFollowed ? "フォローを解除" : "フォローする"}  
      </Button>  
    );  
  }  
);  

components/pages

一つのページを構成するコンポーネントを入れるフォルダーです。

pagesのコンポーネント例
import React from "react";  
import { RouteComponentProps } from "react-router";  
import { Board } from "../../atoms/Board";  
import { BoardItem, NotifyBoardItem } from "../../atoms/BoardItem";  
import { DashboardTemplate } from "../../templates/DashboardTemplate";  
import styles from "./Dashboard.module.scss";  
import { useDashboard } from "./use";  

export const Dashboard: React.FC<RouteComponentProps> = () => {  
  const { notifies, finished, getNotifies } = useDashboard();  

  return (  
    <DashboardTemplate>  
      <main className={styles.dashboard}>  
        <Board headerLabel="通知一覧" minWidth={500}>  
          {notifies.map(notify => (  
            <NotifyBoardItem key={`notify-item-${notify.id}`} notify={notify} />  
          ))}  

          {finished ? null : (  
            <BoardItem  
              className={styles.link}  
              label="さらに10件の通知を表示"  
              onClick={getNotifies}  
            />  
          )}  
        </Board>  
      </main>  
    </DashboardTemplate>  
  );  
};  

components/templates

pagesでの共通部分を抜き出して、再利用できるようにしたコンポーネントを入れるフォルダーです。

templatesのコンポーネント例
import React, { useEffect } from "react";  
import useReactRouter from "use-react-router";  
import { GlobalHeader } from "../../organisms/GlobalHeader";  
import styles from "./PageTemplate.module.scss";  

interface PageTemplateProps extends React.Props<{}> {  
  title?: string;  
  background?: string;  
  className?: string;  
}  

export const PageTemplate: React.FC<PageTemplateProps> = ({  
  children,  
  className,  
  background  
}) => {  
  const { location } = useReactRouter();  

  useEffect(() => {  
    window.scrollTo(0, 0);  
  }, [location.pathname]);  

  return (  
    <div className={styles.page} style={{ background }}>  
      <GlobalHeader />  

      <div className={`${styles.body} ${className || ""}`}>{children}</div>  

      <div className={styles.footer}>  
        <p> 2019 uttk</p>  
      </div>  
    </div>  
  );  
};  

components/modules

上記のコンポーネントの分割に当てはまらないコンポーネントを入れるフォルダーです。

modulesのコンポーネント例
import React, { useEffect, useState } from "react";  
import { IconButton } from "../../atoms/Button";  
import styles from "./Alerts.module.scss";  

/**  
 * 長いので省略  
 */  

interface AlertProps extends React.Props<{}> {}  

export const Alert: React.FC<AlertProps> = React.memo(() => {  
  const [alerts, setAlert] = useState<AlertElement[]>([]);  
  const len = alerts.length;  

  AlertsMg.elements = alerts;  
  AlertsMg.dispatch = (elements: AlertElement[]) => {  
    setAlert(elements);  
  };  

  return (  
    <div>  
      {alerts.map((alert, i) => (  
        <AlertBar  
          key={alert.id}  
          top={(len - i) * 64}  
          alert={alert}  
          displayTime={3000}  
        />  
      ))}  
    </div>  
  );  
});  

interface AlertBarProps extends React.Props<{}> {  
  top: number;  
  alert: AlertElement;  
  displayTime: number;  
}  

export const AlertBar: React.FC<AlertBarProps> = React.memo(  
  ({ top, alert, displayTime }) => {  
    const [style, setStyle] = useState(alert.style);  

    useEffect(() => {  
      let timeoutId = setTimeout(() => {  
        setStyle(`${style} ${styles.alert_show}`);  

        timeoutId = setTimeout(() => {  
          setStyle(alert.style);  
          timeoutId = setTimeout(alert.onClose, 300);  
        }, displayTime);  
      }, 0);  

      return () => clearTimeout(timeoutId);  
    }, []);  

    return (  
      <li className={style} style={{ top }}>  
        <p>{alert.message}</p>  
        <IconButton  
          size={16}  
          icon="close_white"  
          color="transparent"  
          className={styles.close_btn}  
          onClick={alert.onClose}  
        />  
      </li>  
    );  
  }  
);  

src/logics

ロジック部分の処理を書いたファイルを格納するフォルダーです。

logics/actions

通信処理などをする関数を定義したファイルを入れるフォルダーです。

logics/reducer

後述するuseClutchで使うreducerを定義したファイルを入れるフォルダーです。

logics/util

actionsreducerには当てはまらないものをここに入れます。

logics/util/uses

このフォルダーには、コンポーネントで使うReact HooksカスタムHooksを定義したファイルを入れます。

コンポーネントについて

コンポーネントは、以下のフォルダー構成になります。

/ComponentName  
├─ index.tsx                  <- JSXを書くファイル  
├─ ComponentName.moduel.scss  <- index.tsxのスタイルを書くファイル (フォルダーと同じ名前にする)  
└─ use.ts                     <- index.tsxで使うコールバックや変数を定義するファイル  

index.tsxに描画部分を書き、ロジックなどはuse.tsに書くことによって、
描画部分はより描画に専念して書けますし、ロジックも同じように書けます。
修正する時も、色々とやりやすかったりします。
今回のWebアプリではpagesのみこのようにしていますが、後々は全部のコンポーネントをこの構成したいです。

ComponentName.module.scssは、index.tsx

import styles from "./ComponentName.module.scss"  

のようにするためです。

State管理について

このWebアプリでは、Reactは使っていますが、Redux使って無いので工夫する必要があります。

useClutch

このWebアプリでは、useClutchというカスタムHooksを作成しました。
以下が、データフローになります。

useClutchがやっていることは単純で、Promiseを返すreducerを実行して、awaitして結果を受け取ると、内部でsetStateをして描画を更新します。
こうすることで、reducer内で非同期処理が出来て処理を一か所にまとめることができます。

type Action = { type: "increment" } | { type: "decrement" };  

interface StoreType {  
  counter: number;  
}  

const state : StoreType  = {  
  counter: 0  
};  

const sleep = (t:number) => new Promise(r => setTimeout(r,t,t));  

const reducer = async (state:number, action:Action) : Promise<StoreType> => {  
  switch(action.type){  
    case "increment":  
      await sleep(5000);  
      return state + 1;  

    case "decrement":  
      await sleep(5000);  
      return state - 1;  

    default:  
      return state;  
  }  
}  

const App : React.FC = () => {  
  const clutch = useClutch(reducer, store);  
  const increment = () => clutch.dispatch("increment", { type: "increment" }).catch(console.error);  
  const decrement = () => clutch.dispatch("decrement", { type: "decrement" }).catch(console.error);  

  // 複数のactionを繋げることもできます。  
 // 描画更新は、すべてのactionが終了したときに発生します。  
  const add = () => clutch.pipe(  
    "test",  
    state => ({ type: "increment" }),  
    state => state.counter > 10 ? null : ({ type: "increment" }) // 前のactionの結果を受け取って10以下ならさらにincrementする  
  ).catch(console.error)  

  return (  
    <div>  
      <p>カウント : {clutch.counter}<p>  
      <button onClick={increment}>カウントアップ</button>  
      <button onClick={decrement}>カウントアップ</button>  
      <button onClick={add}>1足して10以下ならさらに1足す</button>  
    </div>  
  );  
}  

デザインについて

デザインは勉強の意味も込めて自分でやりましたが、ダサいので参考にはならないと思います。

しかしあれですね、デザイナーさんは凄いですね!
私もデザインについてもっと勉強しないといけないと感じました。( 特にAdobe。。。 )

React HooksとTypeScriptは相性がいい!

React HooksTypeScriptを使って開発しましたが、これが予想以上に良かったです!
React Hooksで、コンポーネントの記述量を減らして、その減らした分をTypeScriptに使うことで、従来の記述量と同じくらいか少ないのに、型安全で、より設計しやすいコーディングをすることができました。

これから開発する際には、TypeScriptを使ったほうがよさそうですね。

また、React Hooksは柔軟に組み合わせることができるので、複雑な処理も関数に閉じ込めることができます。

上記で紹介したuseClutchカスタムHooksで実装してます。

useClutchのソースコード
import { useRef, useState } from "react";  

type Reducer<S, A> = (preState: S, action: A) => Promise<S>;  

type GetStoreType<R extends Reducer<any, any>> = R extends Reducer<infer S, any>  
  ? S  
  : any;  

type GetActionType<R extends Reducer<any, any>> = R extends Reducer<  
  any,  
  infer A  
>  
  ? A  
  : any;  

type ActionCreator<S, A> = (preState: S) => A | null;  

type RequestStatus = "start" | "success" | "cancel" | "error";  

type ListenRequestCallback = (request: string, status: RequestStatus) => void;  

type CancelCallback = () => void;  

export interface Clutch<StoreType, ActionType> {  
  state: StoreType;  
  request: <T>(  
    req: string,  
    promiseCreator: () => Promise<T>  
  ) => Promise<T | null>;  
  cancelRequest: (request: string) => boolean;  
  listenRequest: (cb: ListenRequestCallback) => () => void;  
  isLoading: (request?: string) => boolean;  
  pipe: (  
    request: string,  
    ...funcs: Array<ActionCreator<StoreType, ActionType>>  
  ) => Promise<void>;  
  dispatch: (request: string, action: ActionType) => Promise<void>;  
}  

export const useClutch = <R extends Reducer<any, any>>(  
  asyncReducer: R,  
  initializeState: GetStoreType<R>  
): Clutch<GetStoreType<R>, GetActionType<R>> => {  
  type StoreType = GetStoreType<R>;  
  type ActionType = GetActionType<R>;  

  // 描画を更新するためのState  
  const [pureState, setState] = useState<StoreType>(initializeState);  

  const {  
    progressCancel,   
    progressPromise,   
    listenCallbacks,  
    notifyRequest,  
    resolveAsync,  
    updateState,   
    clutch  
  } = useRef({  
    // 実行中のPromise処理をキャンセルするコールバックを保持するMap  
    progressCancel: new Map<string, CancelCallback>(),  

    // 実行中のPromiseインスタンスを保持するMap  
    progressPromise: new Map<string, Promise<any>>(),  

    // 非同期処理状況をlistenする関数を保持するSet  
    listenCallbacks: new Set<ListenRequestCallback>(),  

    // 非同期処理の状態をlistenCallbackに伝える関数  
    notifyRequest: (request: string, status: RequestStatus) => {  
      listenCallbacks.forEach(cb => cb(request, status));  
    },  

    // 引数に渡されたPromiseを実行しawaitして結果を返す  
    resolveAsync: <T>(  
      request: string,  
      cb: () => Promise<T>  
    ): Promise<T | null> => {  
      const promise = progressPromise.get(request);  

      if (promise) {  
        return promise as Promise<T | null>;  
      }  

      notifyRequest(request, "start");  

      const cancelCallback = new Promise<"cancel">(re => {  
        progressCancel.set(request, () => re("cancel"));  
      });  

      const progress = Promise.race([cb(), cancelCallback])  
        .then(result => {  
          notifyRequest(request, result === "cancel" ? "cancel" : "success");  

          progressCancel.delete(request);  
          progressPromise.delete(request);  

          return result === "cancel" ? null : result;  
        })  
        .catch(e => {  
          notifyRequest(request, "error");  

          progressCancel.delete(request);  
          progressPromise.delete(request);  

          return Promise.reject(e);  
        });  

      progressPromise.set(request, progress);  

      return progress;  
    },  

    // stateを更新する関数  
    updateState: async (  
      request: string,  
      oldState: StoreType,  
      promiseCreator: (oldState: StoreType) => Promise<StoreType>  
    ) => {  
      const newState = await resolveAsync(request, () =>  
        promiseCreator(oldState)  
      );  
      const updated: Partial<StoreType> = {};  

      if (newState) {  
        for (const key in oldState) {  
          if (oldState[key] !== newState[key]) {  
            updated[key] = newState[key];  
          }  
        }  

        if (Object.keys(updated).length > 0) {  
          clutch.state = { ...clutch.state, ...updated };  
          setState(clutch.state);  
        }  
      }  
    },  

    // Propsに渡されるclutchオブジェクト  
    clutch: {  
      state: pureState,  

      // 処理が実行中かのフラグを返す関数  
      isLoading: (request?: string): boolean => {  
        if (request) {  
          return progressPromise.has(request);  
        }  

        return !!progressPromise.size;  
      },  

      // reducerを通さないPromiseを監視するようにする  
      request: <T>(  
        req: string,  
        promiseCreator: () => Promise<T>  
      ): Promise<T | null> => {  
        return resolveAsync<T>(req, promiseCreator);  
      },  

      // 実行中のPromiseを中断する  
      cancelRequest: (request: string): boolean => {  
        const cancel = progressCancel.get(request);  

        if (cancel) {  
          cancel();  
          return true;  
        } else {  
          return false;  
        }  
      },  

      // listenCallbackを設定する関数  
      listenRequest: (cb: ListenRequestCallback): (() => void) => {  
        listenCallbacks.add(cb);  
        return () => listenCallbacks.delete(cb);  
      },  

      // 引数に渡されたActionCreatorの順番で、reducerを実行する  
      pipe: async (  
        request: string,  
        ...funcs: Array<ActionCreator<StoreType, ActionType>>  
      ): Promise<void> => {  
        const promiseCreator = async (oldState: StoreType) => {  
          for (const fn of funcs) {  
            const action = fn(oldState);  

            if (action) {  
              oldState = await asyncReducer(oldState, action);  
            }  
          }  

          return oldState;  
        };  

        await updateState(request, { ...clutch.state }, promiseCreator);  
      },  

      // action&payloadをreducerに渡して実行する  
      dispatch: async (request: string, action: ActionType): Promise<void> => {  
        const promiseCreator = (oldState: StoreType) => {  
          return asyncReducer(oldState, action);  
        };  

        await updateState(request, { ...clutch.state }, promiseCreator);  
      }  
    }  
  }).current;  

  return clutch;  
};  

使った技術の感想

React & React Hooks

Reactは以前から使っていたので色々とやり易かったですが、React Hooksによってより使いやすくなりました。
例えば、上で説明したpagesのコンポーネントのindex.tsxの中身は、以下のようになりました。

import React from "react";  
import { RouteComponentProps } from "react-router";  
import { Button } from "../../atoms/Button";  
import { Icon } from "../../atoms/Icon";  
import { PageTemplate } from "../../templates/PageTemplate";  
import styles from "./Home.module.scss";  
import { useHome } from "./use";  

export const Home: React.FC<RouteComponentProps> = () => {  
  const { isLogin, isCheked, login, goToTrend } = useHome();  

  return (  
    <PageTemplate className={styles.page}>  
      <div className={styles.home}>  
        <div className={styles.title}>  
          <div className={styles.wrapper}>  
            <h1 className={styles.title_label}>NIJINOWA</h1>  

            <div className={styles.wrapper}>  
              <p>  
                <big>NIJINOWA</big> は二次創作を投稿できるサービスです  
              </p>  
            </div>  

            <div className={styles.container}>  
              <div className={styles.wrapper}>  
                <Button color="blue" onClick={goToTrend}>  
                  トレンドを見る  
                </Button>  
              </div>  

              <div className={styles.wrapper}>  
                <Button disabled={!isCheked} onClick={login}>  
                  {isLogin  
                    ? "ダッシュボードに移動"  
                    : "Googleアカウントでログイン"}  
                </Button>  
              </div>  

              <div className={styles.wrapper}>  
                <a  
                  href="https://twitter.com/uttk8128"  
                  target="_blank"  
                  className={styles.twitter_btn}  
                >  
                  <Icon icon="twitter_white" size={32} />  
                  製作者のTwitter  
                </a>  
              </div>  
            </div>  
          </div>  
        </div>  
      </div>  
    </PageTemplate>  
  );  
};  

見てわかるように、ロジックの処理が書かれていないことが分かります。
これは、useHome()というカスタムHooksにロジック部分を書いているためです。
以下がuseHome()の実際のソースコードになります

import { useEffect, useState } from "react";  
import useReactRouter from "use-react-router";  
import { useAuth } from "../../../logics/util/uses/auth";  

export const useHome = () => {  
  const { history } = useReactRouter();  
  const { isLogin, isCheked, loginCheck, loginWithGoogle } = useAuth();  
  const [isPush, setPush] = useState(false);  
  const login = () => {  
    if (isLogin) {  
      history.push("/dashboard");  
    } else {  
      setPush(true);  
      loginWithGoogle();  
    }  
  };  

  useEffect(() => {  
    if (!isCheked) {  
      loginCheck();  
    } else if (isLogin && isPush) {  
      history.push("/dashboard");  
    }  
  }, [history, isCheked, isPush, isLogin]);  

  return {  
    isLogin,  
    isCheked,  
    login,  
    goToTrend: () => history.push("/trends")  
  };  
};  

こうやって、ViewLogicを分けて書けるようになったのも、React Hooksによるところが大きいかと思います。
また、ファイルを分けているので関心の分離といった観点からもいいのではないでしょうか。

TypeScript

TypeScriptを使うことによって、コンポーネントやカスタムHooksに型情報を追加し、より扱い易いものを作ることができます。
また、上記でも言ったように、本当にReact HooksTypeScriptの相性は良く、
TypeScriptの記述量の多さに嫌気が差す方もいるかとは思いますが、React Hooksがコンポーネントの記述量を減らしてくれているので、
実質今までの記述量とあまり変わらないです。むしろ少なくなっている方です。
さらに、同じ記述量でもTypeScriptの方が型安全ですし、VSCodeなどのTypeScriptをサポートしているエディタを使えば入力補完も効いて、素晴らしい開発者体験が得られます。
React Hooksを導入する・しているなら、TypeScriptも導入することをお勧めします。

Firebase

Firebaseは、小さいサービスなどを作るのに本当に便利です。
今回のWebアプリでは、Auth,FireStore,Storage,Hostingを使いました。
今回の開発では、特にFireStoreのデータ構造とreducerによる処理の分割が、相性がとてもよかったです。

例えば、userドキュメントfollowerサブコレクションがあったとき、followerreducerを作れば、それだけで、followerの取得や変更などがアプリに追加できます。
これは、後からデータを追加する際にも同じような感じでアプリを拡張できます。

つまり何が言いたいのかというと、FireStoreコレクションごとにreducerを作ることにより、コレクションを新しく追加したとき、新しくreducerを作って、Webアプリに機能を追加し、編集の際も、そのreducerのみを変更すればいいのです。

また、ログイン機能を簡単に入れれたり、サイトを簡単にデプロイ出来たりとFirebaseの凄さに恐縮してしまいます。

作ってみた感想

実は今回Webアプリ製作はフレームワークの行き詰まりの解消のためでもあったので、
普通に作るのではなく、二つの制限をかけて作りました。

制限

  1. React.jsで作るが、Classコンポーネントは使わない
  2. なるべく、他のnpmモジュールに頼らず自分で作る

上記の制限の中で作ることにより、より設計やデータの流れについて考えるようになり色々とフレームワークのアイディアが見えてきたので、Webアプリを作ってよかったと思いました。

あと、やっぱりモノづくりは楽しい!

後書き

最後まで、読んでいただきありがとうございます。
何か気になることなどがあれば、お気軽にコメントください!
あと、最近Twitter始めたのでそちらでも大丈夫です!
それではまた。

記事が少しでもいいなと思ったらクラップを送ってみよう!
143
+1
主にフロントエンドのことを書くブログです。

よく一緒に読まれている記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう
目次をみる

技術ブログをはじめよう

Qrunch(クランチ)は、ITエンジニアリングに携わる全ての人のための技術ブログプラットフォームです。

技術ブログを開設する

Qrunchでアウトプットをはじめよう

Qrunch(クランチ)は、ITエンジニアリングに携わる全ての人のための技術ブログプラットフォームです。

Markdownで書ける

ログ機能でアウトプットを加速

デザインのカスタマイズが可能

技術ブログ開設

ここから先はアカウント(ブログ)開設が必要です

英数字4文字以上
.qrunch.io
英数字6文字以上
ログインする