- 投稿日:2019-05-26T23:16:44+09:00
nodejs製のwebアプリにnextjsを組み込むにはどのようにすればいいか試してみた
はじめに
以下の疑問点を解決するために試したことをまとめた。
- nodejs製のwebアプリにnextjsを組み込むにはどのようにすればいいか?
- DBに保存した値をSSRするためにはどのようにすればいいのか?
- ページの遷移はどのようにすればいいのか?
アプローチ
ユーザ別に登録できるタスクリストを作った。
repository
koajsでwebアプリの基盤を作り、typeormでDB操作を行う。
DBはdocker上にmysqlを立てた。
ormはtypeormを使用した。
その他細かいライブラリについてはpackage.jsonを参照。構成
全ユーザの一覧が見れるindexページと、タスクの一覧が見えるtaskページがある。
indexページではユーザとタスクのcrudが可能。
taskページではタスク一覧の読み取りのみ可能とした。データの構造は次の通り。
- server/entities/User.ts
- server/entities/Task.tsユーザを削除すると紐付いたタスクが自動的に削除されるように
Task.user
にonDelete: 'CASCADE'
オプションを付与した。nextに関係する処理があるファイルには★をつけた。
. ├── README.md ├── docker │ └── db │ └── my.conf ├── docker-compose.yml ├── models <- tableとapiの項目が一致するので同一のinterfaceを使用することにした │ ├── Task.ts │ └── User.ts ├── next.config.js ├── nodemon.json ├── ormconfig.json ├── package-lock.json ├── package.json ├── pages <- フロント側の処理。ちょっとnextが入ってるがほとんど普通のreact │ ├── index.tsx ★ │ └── tasks.tsx ★ ├── repositories <- api接続処理 │ ├── helpers.ts │ ├── tasks.ts │ └── users.ts ├── server │ ├── api <- controllerに当たる部分。apiリクエストに応答する処理。 │ │ ├── tasks.ts │ │ └── users.ts │ ├── commands <- サーバ処理とは一切関係ない。 │ │ ├── generateData.ts <- データを作る。 npm run generate:data で起動する。 │ │ └── route.ts <- routingを確認する。 npm run route で起動する。 │ ├── createRouter.ts ★ <- routerを作るentrypoint │ ├── entities <- tableの項目とマッピングするentity │ │ ├── Task.ts │ │ └── User.ts │ ├── helpers.ts <- DB接続処理がある。commandsでも使用するので分けた。 │ ├── index.ts ★ <- entrypoint。npm start で起動する。 │ ├── next.ts ★ <- nextを直接触る処理はここに閉じ込めた。 │ ├── repositories <- repositoriesと名前をつけたがuseCase層と合体させた。 │ │ ├── TaskRepository.ts │ │ └── UserRepository.ts │ └── views ★ <- controllerに当たる部分。 │ ├── index.ts │ └── tasks.ts ├── tsconfig.json └── tsconfig.server.json結論
1. nodejs製のwebアプリにnextjsを組み込むにはどのようにすればいいか?
1. 初期化処理で
prepare()
まずserverのentrypointである
server/index.ts
でprepare()
を行う。
それなりに時間がかかる処理なので、DB接続処理と同時に行うと時間節約できる。await Promise.all([ connectDatabase(), prepare() ])2. routingを設定する
すべてのリクエストにnextのhandleを設定する。
server/next.tsconst handler = app.getRequestHandler() // omit... export async function handle(ctx: BaseContext) { await handler(ctx.req, ctx.res) ctx.respond = false }server/createRouter.tsimport { handle } from './next' // omit... export default function () { const router = new Router() const assign = pipe( views, apiUsers, apiTasks, next ) return assign(router) } // omit... function next(router: Router) { return router .get('*', handle) // <- これが必要 }
router.get('*', handle)
で設定しないと以下のエラーが発生する。<-- GET /_next/on-demand-entries-ping?page=/ --> GET /_next/on-demand-entries-ping?page=/ 404 1ms - <-- GET /_next/static/development/pages/index.js?ts=1558873127678 --> GET /_next/static/development/pages/index.js?ts=1558873127678 404 0ms - <-- GET /_next/static/development/pages/_app.js?ts=1558873127678 --> GET /_next/static/development/pages/_app.js?ts=1558873127678 404 1ms - <-- GET /_next/static/runtime/webpack.js?ts=1558873127678 --> GET /_next/static/runtime/webpack.js?ts=1558873127678 404 1ms - <-- GET /_next/static/runtime/main.js?ts=1558873127678 --> GET /_next/static/runtime/main.js?ts=1558873127678 404 0ms - <-- GET /_next/static/development/dll/dll_599a58a60c43245180de.js?ts=1558873127678 --> GET /_next/static/development/dll/dll_599a58a60c43245180de.js?ts=1558873127678 404 0ms - <-- GET /_next/static/development/pages/index.js?ts=1558873127678 --> GET /_next/static/development/pages/index.js?ts=1558873127678 404 1ms - <-- GET /_next/static/development/pages/_app.js?ts=1558873127678 --> GET /_next/static/development/pages/_app.js?ts=1558873127678 404 1ms - <-- GET /_next/static/runtime/webpack.js?ts=1558873127678 --> GET /_next/static/runtime/webpack.js?ts=1558873127678 404 1ms - <-- GET /_next/static/runtime/main.js?ts=1558873127678 --> GET /_next/static/runtime/main.js?ts=1558873127678 404 0ms -一見viewへのリクエストに書けばいいように見える。
そこでserver/views/index.ts
に書いてみたが、await render(ctx.req, ctx.res, '/', query)
の後にawait handler(ctx.req, ctx.res)
を差し込めば上のエラーが、await render(ctx.req, ctx.res, '/', query)
より前に差し込めば下のエラーが発生する。fetch is not defined ReferenceError: fetch is not defined at _callee$ (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/.next/server/static/development/pages/index.js:886:13) at tryCatch (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/node_modules/regenerator-runtime/runtime.js:62:40) at Generator.invoke [as _invoke] (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/node_modules/regenerator-runtime/runtime.js:288:22) at Generator.prototype.(anonymous function) [as next] (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/node_modules/regenerator-runtime/runtime.js:114:21) at asyncGeneratorStep (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/.next/server/static/development/pages/index.js:186:24) at _next (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/.next/server/static/development/pages/index.js:208:9) at /Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/.next/server/static/development/pages/index.js:215:7 at new Promise (<anonymous>) at new F (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/node_modules/core-js/library/modules/_export.js:36:28) at Module.<anonymous> (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/.next/server/static/development/pages/index.js:204:12) at Module.all (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/.next/server/static/development/pages/index.js:873:15) at _callee8$ (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/.next/server/static/development/pages/index.js:696:75) at tryCatch (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/node_modules/regenerator-runtime/runtime.js:62:40) at Generator.invoke [as _invoke] (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/node_modules/regenerator-runtime/runtime.js:288:22) at Generator.prototype.(anonymous function) [as next] (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/node_modules/regenerator-runtime/runtime.js:114:21) at asyncGeneratorStep (/Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/.next/server/static/development/pages/index.js:186:24)1. レンダリング処理を行う
次項(2. DBに保存した値をSSRするためにはどのようにすればいいのか?)で説明する。
2. DBに保存した値をSSRするためにはどのようにすればいいのか?
views/index.ts
とviews/tasks.ts
にあるようにrender関数に 文字列化した データを設定する。export default async function (ctx: BaseContext) { const users = await getRepository().all() const stringified = JSON.stringify(users) const query = { users: stringified } await render(ctx.req, ctx.res, '/', query) // <- ここで設定 ctx.respond = false }
pages/index.tsx
やpages/tasks.tsx
にある通り、getInitialProps
でデータを取得する。Page.getInitialProps = async (context: NextContext) => { const renderingOnServer = context.query.users !== undefined if (renderingOnServer) { // server処理の場合はqueryに文字列が設定されているので // JSON.parseして使用する。 const raw = context.query.users as string const users = JSON.parse(raw) return { users } } // ブラウザ側ならapiからデータを取得する。 const users = await UsersRepository.all() return { users } }生成されたhtmlを見てみると、以下のようにjsonが書き込まれていることがわかる。
<script id="__NEXT_DATA__" type="application/json">{"dataManager":"[]","props":{"pageProps":{"users":[{"id":1,"name":"山田","tasks":[{"id":1,"userId":1,"text":"皿洗い"},{"id":2,"userId":1,"text":"買い物"}]},{"id":2,"name":"佐藤","tasks":[{"id":3,"userId":2,"text":"草むしり"},{"id":4,"userId":2,"text":"押入れの片付け"}]},{"id":3,"name":"平田","tasks":[{"id":5,"userId":3,"text":"野菜の皮むき"}]},{"id":4,"name":"山本","tasks":[{"id":6,"userId":4,"text":"カレー作り"}]},{"id":5,"name":"柴田","tasks":[{"id":7,"userId":5,"text":"食卓の準備"},{"id":8,"userId":5,"text":"押入れの片付け"}]}]}},"page":"/","query":{"users":"[{\"id\":1,\"name\":\"山田\",\"tasks\":[{\"id\":1,\"userId\":1,\"text\":\"皿洗い\"},{\"id\":2,\"userId\":1,\"text\":\"買い物\"}]},{\"id\":2,\"name\":\"佐藤\",\"tasks\":[{\"id\":3,\"userId\":2,\"text\":\"草むしり\"},{\"id\":4,\"userId\":2,\"text\":\"押入れの片付け\"}]},{\"id\":3,\"name\":\"平田\",\"tasks\":[{\"id\":5,\"userId\":3,\"text\":\"野菜の皮むき\"}]},{\"id\":4,\"name\":\"山本\",\"tasks\":[{\"id\":6,\"userId\":4,\"text\":\"カレー作り\"}]},{\"id\":5,\"name\":\"柴田\",\"tasks\":[{\"id\":7,\"userId\":5,\"text\":\"食卓の準備\"},{\"id\":8,\"userId\":5,\"text\":\"押入れの片付け\"}]}]"},"buildId":"development","dynamicBuildId":false}</script>server側の処理のときならDBに接続できるのでは?と思い以下のようにしたがだめだった。
Page.getInitialProps = async (context: NextContext) => { const renderingOnServer = context.query.users !== undefined if (renderingOnServer) { const UserRepository = require('../server/repositories/UserRepository') const Container = require('typedi') const repository = Container.get(UserRepository) const users = await repository.all() return { users } // const raw = context.query.users as string // const users = JSON.parse(raw) // return { users } } const users = await UsersRepository.all() return { users } }./server/repositories/UserRepository.ts SyntaxError: /Users/h-h/h_h/2019-05-25-nextjs-koa-mysql/server/repositories/UserRepository.ts: Support for the experimental syntax 'decorators-legacy' isn't currently enabled (6:1): 4 | import { User } from '../entities/User' 5 | > 6 | @Service() | ^ 7 | @EntityRepository(User) 8 | export class UserRepository { 9 |SyntaxErrorなのでbabelの設定次第では行けそうな気もするが、できたとしてもviewにDB操作するような処理を書くのは好きじゃないので考えるのをやめた。
3. ページの遷移はどのようにすればいいのか?
pages/index.tsx
やpages/tasks.tsx
にある通り次のようにする。import Link from 'next/Link' // omit... <Link href="/tasks"> <a>> tasks</a> </Link>参考資料
nextの組み方を調べたときに確認した。
- custom-server-koanextでtypescriptを使えるようにするために確認した。
- custom-server-typescript
- with-typescriptnodemonの設定方法を確認した。
- custom-server-nodemon
- 投稿日:2019-05-26T22:36:15+09:00
create-react-app が Node.js 12.0.0 で動かない
問題
次のように create-react-app を動かそうとした(環境は Mac):
$ npx create-react-app hello-react --typescript $ cd hello-react $ yarn startしかしエラー:
# # Fatal error in , line 0 # Check failed: U_SUCCESS(status). # # # error Command failed with exit code 1. info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.原因
ググったところ同様の問題を扱った issue を見つけ、そのなかで次のコメントを見つけた:
If anyone is using Node.js 12.0.0, try to upgrade to 12.1.0. For me it is now working.
ちょうど 12.0.0 をインストールしていたので、12.1.0 にアップグレードすることにした。
解決
- まず、Node.js 12.1.0 をインストールして、バージョンを 12.1.0 に切り替える(ここの詳細は別の記事に書きました)
- yarn をグローバルにインストールし直す(
npm install -g yarn
)- プロジェクトルート(
hello-react
)に移動し、node_modules
を削除して、yarn install
し直す(一応)yarn start
し直すこれで無事アプリが起動!
- 投稿日:2019-05-26T20:41:22+09:00
安全に React Hooks を使用する
React Hooks はとても便利で、Custom Hooks を上手く実装することで、再利用性の高いコードを、簡潔に実装することができます。
しかし、Hooksを不用意に使用してしまうと 意図しない無限ループに陥ったり、正しくStateが反映されなかったりすることがあります。
useCallback を使い無限ループを避ける
例えば以下のようなDivの大きさを取得する
useRect
というCustom Hooksについて考えてみます。const useRect = () => { const [rect, setRect] = useState<ClientRect | DOMRect>(); const ref = useRef<HTMLDivElement | null>(null); useEffect(() => { const target = ref.current; if (target) { const rect = target.getBoundingClientRect(); setRect(rect); } }, []); return { ref, rect }; };このHooksは以下のようにrefをセットして使用できます。
const App: React.FC = () => { const { ref, rect } = useRect(); return ( <div className="target" ref={ref}> <p>Width: {rect ? `${rect.width}px` : "undefined"}</p> <p>Height: {rect ? `${rect.height}px` : "undefined"}</p> </div> ); };ここで、windowの大きさが変化した時に、divの大きさを取得し直したいと考えたとします。この場合は、handleResizeのようなhandlerを記述し、EventListenerのresizeイベントに登録することになるでしょう。
import debounce from "lodash/debounce"; export const useRect = () => { const [rect, setRect] = useState<ClientRect | DOMRect>(); const ref = useRef<HTMLDivElement | null>(null); const resize = () => { if (ref.current) { const rect = ref.current.getBoundingClientRect(); setRect(rect); } }; useEffect(() => resize(), []); const handleResize = debounce(resize, 16); useEffect(() => { window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, [handleResize]); return { ref, rect }; };このHooksは一見正しく動作するように見えます。しかし、
resize
関数は、描画のたびに毎回生成されてしまいます。ここで問題になるのは、resize
を呼び出すとresize
関数が新たに生成されてしまう、ということです。例えば、うっかり、mount時に
resize
を呼び出している useEffect を誤って以下のように書き換えてしまうとどうなるでしょうか?useEffect(() => resize(), [resize]);この場合は、resize を呼び出すと resize が生成され また useEffect が実行されてしまい、無限に処理が実行されてしまいます。
ここで、
eslint
のreact-hooks/exhaustive-deps
を入れている場合は、次のような warning を出してくれます。./src/useRect.ts Line 8: The 'resize' function makes the dependencies of useEffect Hook (at line 18) change on every render. To fix this, wrap the 'resize' definition into its own useCallback() Hook react-hooks/exhaustive-depsこの warning に書かれている通り、この無限ループを避けるためには、
useCallback
でresize
を囲むことで、描画ごとにresizeが生成されることを避ける必要があります。const resize = useCallback(() => { if (ref.current) { const rect = ref.current.getBoundingClientRect(); setRect(rect); } }, []);
useCallback
の代わりにuseMemo
を使っても良いでしょう。const resize = useMemo(() => { return () => { if (ref.current) { const rect = ref.current.getBoundingClientRect(); setRect(rect); } } }, []);適切にメモ化をすることで、描画ごとにresizeは生成されることはなくなり、無限ループを回避できるようになりました!
react-hooks/exhaustive-deps
を設定する
useCallback
による関数のメモ化ができていても、第二引数が正しく設定されていないと、state
の更新がうまくいかない場合があります。例えば以下のような簡単な counter を考えます。
const App: React.FC = () => { const [count, setCount] = useState(0); const addCount = useCallback(() => { setCount(count + 1); }, []); return ( <div className="target"> <button onClick={() => addCount()}>Add Count</button> <p>count: {count}</p> </div> ); };この例では、
addCount
が 描画の最初にメモ化されていますが、メモ化されているせいでcount
の参照が古いままになっています。この例では、useCallback
にはcount
を第二引数に加える必要があります。const addCount = useCallback(() => { setCount(count + 1); }, [count]);
react-hooks/exhaustive-deps
を lint の rule として設定していると、この場合も以下のように warning を出してくれます。./src/App.tsx Line 9: React Hook useCallback has a missing dependency: 'count'. Either include it or remove the dependency array. You can also do a functional update 'setCount(c => ...)' if you only need 'count' in the 'setCount' call react-hooks/exhaustive-deps
react-hooks/exhaustive-deps
は、Hooks内で参照している変数が第二引数に加えられていない場合に warning を出します。この lint を入れておくことで、古い変数を参照してしまうケースを避けることができます。さらに、この
react-hooks/exhaustive-deps
は賢く、useState
の dispatcher や、useRef
の current など設定の必要がないものは除外してくれます。また、
react-hooks/exhaustive-deps
を設定しておく副次的なメリットとして、仮に第二引数を意図的に変更する場合も、コメントでdisableにする必要があるということが挙げられます。例えば、Mount時にだけ呼び出したい場合や、使用しているライブラリが毎描画ごとに変更される場合など、第二引数を意図的に変えたい時、自分たちのチームでは、なぜ
react-hooks/exhaustive-deps
を disable するかのコメントを eslint-disable のコメントとともに付記しています。このルールによって、Hooksの引数を誤って変更してしまうリストを減らすことができました。
useEffect(() => { // ... // apolloClient は毎描画ごとに変更されてしまうため第二引数から除外する // eslint-disable-next-line react-hooks/exhaustive-deps }, [paramId]);React の公式ドキュメントでも、
react-hooks/exhaustive-deps
を設定することをお勧めしています。最新のcreate-react-app
では、デフォルトでこのルールが設定されています。We recommend using the exhaustive-deps rule as part of our eslint-plugin-react-hooks package. It warns when dependencies are specified incorrectly and suggests a fix.
cf. Hooks API Reference – Reactstop-runaway-react-effects
react-hooks/exhaustive-deps
によるルールチェックは万能ではなく、間接的に実行する関数が変化する場合をチェックできません。例えば、useRect
の例にある、handleResize
を使用しているuseEffect
の処理を以下のように書き換えた場合、useEffect(() => { handleResize(); window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); }; }, [handleResize]);この場合も
handleResize
がresize
を呼び出すため、useCallback
でメモ化されていない場合は、無限に処理が走ってしまいますが、react-hooks/exhaustive-deps
は warning を出してくれません。そこで、自分たちのチームは stop-runaway-react-effects を使用して、 useEffect の無限ループがないか監視しています。
このライブラリは
useEffect
やuseLayoutEffect
の 処理を tracking して、ある一定の期間でuseEffect
の実行回数が閾値を超えた場合に warning を表示してくれます。また、その warning で第二引数を表示してくれるので、どこの引数が変更されてしまっているかを確認することができます。導入は簡単で、index に以下のコードを追加するだけです。時間当たりの閾値を設定することもできます。
import { hijackEffects } from "stop-runaway-react-effects"; if (process.env.NODE_ENV !== "production") { hijackEffects(); // hijackEffects({ callCount: 10, timeLimit: 1000 }); }無限ループが起きている場合には、以下のようにwarningが出ます。
まとめ
React Hooksの引数の設定・メモ化を適切に行うことで、より安全にHooksを使用することができます。具体的には、以下の3つに留意しましょう。
- 適切にメモ化を行い無限ループを避ける
- 適切なメモ化を行うために
react-hooks/exhaustive-deps
を設定するstop-runaway-react-effects
で無限ループを監視する(お好み)冒頭にも書きましたが、Hooksを使用することで、再利用性の高いコードを、簡潔に実装することができます。これは、可読性やパフォーマンスの向上にも繋がります。ぜひ安全に、そして積極的にHooksを使っていきましょう!
- 投稿日:2019-05-26T00:27:24+09:00
Reactのversionを上げるためにyarnを実行したらfseventsでERRORが発生した
久しぶりに個人で作っているPortfolioのversionを上げようとしたら
fsevents
が原因でエラーが発生したのでメモ。
yarn
を実行したら下記のようなエラーが発生するwarning Error running install script for optional dependency: "/Users/minusfive/dev/oss/ember-cli-sass-variables-export/node_modules/fsevents: Command failed. Exit code: 1 Command: node install Arguments: Directory: /Users/minusfive/dev/oss/ember-cli-sass-variables-export/node_modules/fsevents Output: node-pre-gyp info it worked if it ends with ok node-pre-gyp info using node-pre-gyp@0.6.39 node-pre-gyp info using node@10.4.0 | darwin | x64 node-pre-gyp info check checked for \"/Users/minusfive/dev/oss/ember-cli-sass-variables-export/node_modules/fsevents/lib/binding/Release/node-v64-darwin-x64/fse.node\" (not found) node-pre-gyp http GET https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.1.3/fse-v1.1.3-node-v64-darwin-x64.tar.gz node-pre-gyp http 404 https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.1.3/fse-v1.1.3-node-v64-darwin-x64.tar.gz node-pre-gyp ERR! Tried to download(404): https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.1.3/fse-v1.1.3-node-v64-darwin-x64.tar.gz node-pre-gyp ERR! Pre-built binaries not found for fsevents@1.1.3 and node@10.4.0 (node-v64 ABI, unknown) (falling back to source compile with node-gyp) node-pre-gyp http 404 status code downloading tarball https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.1.3/fse-v1.1.3-node-v64-darwin-x64.tar.gz node-pre-gyp ERR! Tried to download(undefined): https://fsevents-binaries.s3-us-west-2.amazonaws.com/v1.1.3/fse-v1.1.3-node-v64-darwin-x64.tar.gz node-pre-gyp ERR! Pre-built binaries not found for fsevents@1.1.3 and node@10.4.0 (node-v64 ABI, unknown) (falling back to source compile with node-gyp) node-pre-gyp http Connection closed while downloading tarball file gyp info it worked if it ends with ok gyp info using node-gyp@3.6.2 gyp info using node@10.4.0 | darwin | x64 gyp info ok gyp info it worked if it ends with ok gyp info using node-gyp@3.6.2 gyp info using node@10.4.0 | darwin | x64 以下略
yarn start
もERRORが発生してしまい、1~6の解決策がyarn側から提示されたがうまくいかなかった。これは
fsevents
とNode.js
のversionが対応していないために起こるエラーで、fsevents
のv1.2.9
で解決されている。versionを上げるには
package.json
にて以下のように記述して、yarn
を実行する。package.json"resolutions": { "**/**/fsevents": "^1.2.9" }これ以外の解決策で見つけたのは以下の通り
rm -r /node_modules
=>rm yarn.lock
=>yarn
yarn cache clean
=>yarn upgrade
yarn upgrade
を実行すると解決するといったissueが多かったfseventsに関する解決策の参考URI: https://github.com/yarnpkg/yarn/issues/5962