nextjsの大まかな流れをソースコードから読む

nextjsとは

  • nextjsはjavascript, Reactを用いて静的及びサーバーサイドレンダリングアプリケーションを構築するための軽量なフレームワークです.
    Reactでフレームワークを用いずにSPAを開発する場合,ルーティングの処理やSSR,リソースの管理など様々な要件により,アプリケーションはどんどん複雑になっていきます.
    nextjsではアプリケーションを開発する際の問題点を解決するような特徴をもっているため,快適にアプリケーションを開発することができます.

大まかなnextjsの処理の流れ

  • nextjsではnext devという,各ファイルをwatchし,編集された場合hot loadしてブラウザに反映するようなコマンドが用意されています.よくあるやつです.
    devコマンドではサーバー側のupから一連の流れを追えると思うので追っていきましょう.

devコマンド

devコマンドは当たり前ですが,サーバー,アプリケーションの起動を行います.
サーバーの立ち上げがstartServerメソッドで行われ,サーバー側の処理が完了したら,prepareメソッドでアプリケーション側の立ち上げを行うように書かれています.

import startServer from '../server/lib/start-server'
import { printAndExit } from '../server/lib/utils'
import { startedDevelopmentServer } from '../build/output'
import { cliCommand } from '../bin/next'

const nextDev: cliCommand = argv => {
  const dir = resolve(args._[0] || '.')

  // Check if pages dir exists and warn if not
  if (!existsSync(dir)) {
    printAndExit(`> No such directory exists as the project root: ${dir}`)
  }

  if (!existsSync(join(dir, 'pages'))) {
    if (existsSync(join(dir, '..', 'pages'))) {
      printAndExit(
        '> No `pages` directory found. Did you mean to run `next` in the parent (`../`) directory?'
      )
    }

    printAndExit(
      "> Couldn't find a `pages` directory. Please create one under the project root"
    )
  }

  const port = args['--port'] || 3000
  const appUrl = `http://${args['--hostname'] || 'localhost'}:${port}`

  startedDevelopmentServer(appUrl)

  startServer({ dir, dev: true }, port, args['--hostname'])
    .then(async app => {
      await app.prepare()
  })
}

次に,startServerメソッドの処理を追いましょう

startServer method

startServerメソッドでは{dir, dev:true}など,devコマンド実行時に与えたオプションを元にサーバー側で必要な情報をserverOptionsとして引数に与えています.
startServer(start)メソッドは,まずserverOptionsを引数としてnextメソッドに与えています.
次に,nextメソッドの戻り値をappという変数に代入し,appをもとにサーバーを立ち上げています.
最後に,appを戻り値として返しています.

import http from 'http'
import next from '../next'

export default async function start(
  serverOptions: any,
  port?: number,
  hostname?: string
) {
  const app = next(serverOptions)
  const srv = http.createServer(app.getRequestHandler())
  await new Promise((resolve, reject) => {
    // This code catches EADDRINUSE error if the port is already in use
    srv.on('error', reject)
    srv.on('listening', () => resolve())
    srv.listen(port, hostname)
  })
  // It's up to caller to run `app.prepare()`, so it can notify that the server
  // is listening before starting any intensive operations.
  return app
}

nextメソッド内は,devかproductionかをserversOptionsから判断し,読み込むファイルを変更しています.今回は,devを実行した場合の処理を追っているので,next-dev-server.tsを読みます.

import Server, { ServerConstructor } from '../next-server/server/next-server'

type NextServerConstructor = Omit<ServerConstructor, 'staticMarkup'> & {
  /**
   * Whether to launch Next.js in dev mode - @default false
   */
  dev?: boolean
}

// This file is used for when users run `require('next')`
function createServer(options: NextServerConstructor): Server {
  if (options.dev) {
    const Server = require('./next-dev-server').default
    return new Server(options)
  }

  return new Server(options)
}

// Support commonjs `require('next')`
module.exports = createServer

// Support `import next from 'next'`
export default createServer

次に,next-dev-server.tsはブログに貼るには長いので,constructorだけ貼ります.

Server class

export default class Server {
  dir: string
  quiet: boolean
  nextConfig: NextConfig
  distDir: string
  publicDir: string
  pagesManifest: string
  buildId: string
  renderOpts: {
    poweredByHeader: boolean
    ampBindInitData: boolean
    staticMarkup: boolean
    buildId: string
    generateEtags: boolean
    runtimeConfig?: { [key: string]: any }
    assetPrefix?: string
    canonicalBase: string
    documentMiddlewareEnabled: boolean
    dev?: boolean
  }
  private compression?: Middleware
  router: Router
  private dynamicRoutes?: Array<{ page: string; match: RouteMatch }>

  public constructor({
    dir = '.',
    staticMarkup = false,
    quiet = false,
    conf = null,
  }: ServerConstructor = {}) {
    this.dir = resolve(dir)
    this.quiet = quiet
    const phase = this.currentPhase()
    this.nextConfig = loadConfig(phase, this.dir, conf)
    this.distDir = join(this.dir, this.nextConfig.distDir)
    // this.pagesDir = join(this.dir, 'pages')
    this.publicDir = join(this.dir, CLIENT_PUBLIC_FILES_PATH)
    this.pagesManifest = join(
      this.distDir,
      this.nextConfig.target === 'server'
        ? SERVER_DIRECTORY
        : SERVERLESS_DIRECTORY,
      PAGES_MANIFEST
    )

    // Only serverRuntimeConfig needs the default
    // publicRuntimeConfig gets it's default in client/index.js
    const {
      serverRuntimeConfig = {},
      publicRuntimeConfig,
      assetPrefix,
      generateEtags,
      compress,
    } = this.nextConfig

    this.buildId = this.readBuildId()

    this.renderOpts = {
      ampBindInitData: this.nextConfig.experimental.ampBindInitData,
      poweredByHeader: this.nextConfig.poweredByHeader,
      canonicalBase: this.nextConfig.amp.canonicalBase,
      documentMiddlewareEnabled: this.nextConfig.experimental
        .documentMiddleware,
      staticMarkup,
      buildId: this.buildId,
      generateEtags,
    }

    // Only the `publicRuntimeConfig` key is exposed to the client side
    // It'll be rendered as part of __NEXT_DATA__ on the client side
    if (Object.keys(publicRuntimeConfig).length > 0) {
      this.renderOpts.runtimeConfig = publicRuntimeConfig
    }

    if (compress && this.nextConfig.target === 'server') {
      this.compression = compression() as Middleware
    }

    // Initialize next/config with the environment configuration
    envConfig.setConfig({
      serverRuntimeConfig,
      publicRuntimeConfig,
    })

    const routes = this.generateRoutes()
    this.router = new Router(routes)

    this.setAssetPrefix(assetPrefix)
  }
}

contructorもまあまあ長いですが,重要な処理は

const routes = this.generateRoutes()
this.router = new Router(routes)

のようにルーティングの設定をしているとこですかね.

nextjsではURLのパスと同名のファイルがpagesディレクトリから読み込まれます. 例えば http://localhost:3000/homeとブラウザに入力した場合に,pages/home.jsが読み込まれます.
また,nextjsではapiディレクトリ内に,

export default (req, res) => {
  res.setHeader('Content-Type', 'application/json')
  res.statusCode = 200
  res.end(JSON.stringify({ name: 'Nextjs' }))
}

のようにファイルを作成するだけで,簡単にapiサーバーを立てれるようになっています.
nextjsはURLによってサーバー側の処理が変わります.
このようなルーティングの処理を

const routes = this.generateRoutes()
this.router = new Router(routes)

でサーバー側に登録しています.

さらに進んで,generateRoutesメソッドを読みましょう

generateRoutes method

  private generateRoutes(): Route[] {
    const routes: Route[] = [
      {
        match: route('/_next/static/:path*'),
        fn: async (req, res, params, parsedUrl) => {
          // The commons folder holds commonschunk files
          // The chunks folder holds dynamic entries
          // The buildId folder holds pages and potentially other assets. As buildId changes per build it can be long-term cached.

          // make sure to 404 for /_next/static itself
          if (!params.path) return this.render404(req, res, parsedUrl)

          if (
            params.path[0] === CLIENT_STATIC_FILES_RUNTIME ||
            params.path[0] === 'chunks' ||
            params.path[0] === this.buildId
          ) {
            this.setImmutableAssetCacheControl(res)
          }
          const p = join(
            this.distDir,
            CLIENT_STATIC_FILES_PATH,
            ...(params.path || [])
          )
          await this.serveStatic(req, res, p, parsedUrl)
        },
      },
      {
        match: route('/_next/:path*'),
        // This path is needed because `render()` does a check for `/_next` and the calls the routing again
        fn: async (req, res, _params, parsedUrl) => {
          await this.render404(req, res, parsedUrl)
        },
      },
      {
        // It's very important to keep this route's param optional.
        // (but it should support as many params as needed, separated by '/')
        // Otherwise this will lead to a pretty simple DOS attack.
        // See more: https://github.com/zeit/next.js/issues/2617
        match: route('/static/:path*'),
        fn: async (req, res, params, parsedUrl) => {
          const p = join(this.dir, 'static', ...(params.path || []))
          await this.serveStatic(req, res, p, parsedUrl)
        },
      },
      {
        match: route('/api/:path*'),
        fn: async (req, res, params, parsedUrl) => {
          const { pathname } = parsedUrl
          await this.handleApiRequest(
            req as NextApiRequest,
            res as NextApiResponse,
            pathname!
          )
        },
      },
    ]

    if (
      this.nextConfig.experimental.publicDirectory &&
      fs.existsSync(this.publicDir)
    ) {
      routes.push(...this.generatePublicRoutes())
    }

    if (this.nextConfig.useFileSystemPublicRoutes) {
      this.dynamicRoutes = this.getDynamicRoutes()

      // It's very important to keep this route's param optional.
      // (but it should support as many params as needed, separated by '/')
      // Otherwise this will lead to a pretty simple DOS attack.
      // See more: https://github.com/zeit/next.js/issues/2617
      routes.push({
        match: route('/:path*'),
        fn: async (req, res, _params, parsedUrl) => {
          const { pathname, query } = parsedUrl
          if (!pathname) {
            throw new Error('pathname is undefined')
          }

          await this.render(req, res, pathname, query, parsedUrl)
        },
      })
    }

    return routes
  }

かかれていることは簡単ですね.routesというリストに

{
  match: route('/api/:path*'),
  fn: async (req, res, params, parsedUrl) => {
    const { pathname } = parsedUrl
    await this.handleApiRequest(
      req as NextApiRequest,
      res as NextApiResponse,
      pathname!
    )
  },
}

のようなmatchfnをkeyとするオブジェクトを格納しています. matchにURLとの対応関係を記述し, fnにサーバー側の処理を記述しています. 上記の例では https://hoge.js/api/postsというようにリクエストが投げられ場合にマッチします. もう一つ例を見てみましょう

routes.push({
  match: route('/:path*'),
  fn: async (req, res, _params, parsedUrl) => {
    const { pathname, query } = parsedUrl
    if (!pathname) {
      throw new Error('pathname is undefined')
    }

    await this.render(req, res, pathname, query, parsedUrl)
  },
})

同じく,matchfnをkeyとして持つオブジェクトをroutesに格納しています. これは https://hoge.jp/homeのようなリクエストにマッチします.
また:path*は,URLのパスをみてマッチする値をオブジェクトとして参照できるようになります. 先ほどのURLのリクエストが投げられた場合,_params.pathで参照できるはずです.

次は

routes.push({
  match: route('/:path*'),
  fn: async (req, res, _params, parsedUrl) => {
    const { pathname, query } = parsedUrl
    if (!pathname) {
      throw new Error('pathname is undefined')
    }

    await this.render(req, res, pathname, query, parsedUrl)
  },
})

renderメソッドを読みましょう

render method

function render(
  req: IncomingMessage,
  res: ServerResponse,
  pathname: string,
  query: ParsedUrlQuery = {},
  parsedUrl?: UrlWithParsedQuery
): Promise<void> {
  const url: any = req.url
  if (isInternalUrl(url)) {
    return this.handleRequest(req, res, parsedUrl)
  }

  if (isBlockedPage(pathname)) {
    return this.render404(req, res, parsedUrl)
  }

  const html = await this.renderToHTML(req, res, pathname, query, {
    dataOnly:
      (this.renderOpts.ampBindInitData && Boolean(query.dataOnly)) ||
      (req.headers &&
        (req.headers.accept || '').indexOf('application/amp.bind+json') !==
          -1),
  })
  // Request was ended by the user
  if (html === null) {
    return
  }

  return this.sendHTML(req, res, html)
}

renderToHTMLメソッド内でSSRを行い,htmlを戻り値として返しています. 最後にrenderToHTMLで取得したhtmlをsendHTMLメソッドによってクライアント側にhtmlをレスポンスしています.

owari

まとまりがなくなりましたが,以上がnextjsの大まかな流れとなります. 今回はapiの例やstaticな情報を扱う際の処理の流れは追わずに,一番ベーシックな URLによってpagesディレクトリ内のファイルを参照し,SSRして,クライアントにHTMLを返す一連の処理を追いました. 次はgetInitialPropsがどのように実装されているかやapi周りを読んでみたいと思います.


指摘などあればharxki (@harxki7) | Twitterまでお願いします。

github.com