从源码认识NextJs(一)

2019-02-13  本文已影响58人  万物论

在React服务端渲染上可能有些人熟悉配置webpack来实现同构应用,其实nextjs内部实现原理也是差不多的。

在分析源码的时候会分成两部分

在我们浏览器地址栏中访问一个已经被nextjs构建的地址的时候内部到底发生了什么?

当我们在命令行执行npm run start的时候开启了一个服务器

// next / next-start.ts
// ........
import startServer from '../server/lib/start-server'
// .......
startServer({dir}, port, args['--hostname'])
  .then(async (app) => {
    // tslint:disable-next-line
    console.log(`> Ready on http://${args['--hostname']}:${port}`)
    await app.prepare()
  })
  .catch((err) => {
    // tslint:disable-next-line
    console.error(err)
    process.exit(1)
  })

start-server文件下的代码无非就是搭建一个服务器

import http from 'http' // http模块
import next from '../next' // 导入next服务对象 其实就是一个node服务器

export default async function start (serverOptions, port, hostname) { // 异步方式 返回promise
  const app = next(serverOptions) // 构建服务器的参数 如果是dev {dev: true, dir}
  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中的内容

// This file is used for when users run `require('next')`
module.exports = (options) => {
  if (options.dev) { // 如果是开发模式 ,导入开发服务器
    const Server = require('./next-dev-server').default
    return new Server(options) // 返回开发服务器的实例
  }

  const next = require('next-server') // 否则返回 next的服务器
  return next(options) // 返回服务器
}

这里我先不讨论在开发模式下的服务器,直接讨论线上的服务器。
const next = require('next-server')导出next-server这个文件等下再讲。再回到start-server文件中,熟悉node服务端的朋友应该很清楚http.createServer(app.getRequestHandler())这句的意思吧,回调函数中包含了req,res两个用来处理请求的对象。

next-server

// next-server.ts
//.....
private handleRequest(req: IncomingMessage, res: ServerResponse, parsedUrl?: UrlWithParsedQuery): Promise<void> {
   // Parse url if parsedUrl not provided 如果parsedUrl没提供则对url进行解析
   if (!parsedUrl || typeof parsedUrl !== 'object') {
     const url: any = req.url
     parsedUrl = parseUrl(url, true)
   }

   // Parse the querystring ourselves if the user doesn't handle querystring parsing
   if (typeof parsedUrl.query === 'string') { // 如果用户没有对query进行解析 如?id=2&name=xxx
     parsedUrl.query = parseQs(parsedUrl.query)
   }

   res.statusCode = 200 // 成功响应
   return this.run(req, res, parsedUrl)
     .catch((err) => {
       this.logError(err)
       res.statusCode = 500
       res.end('Internal Server Error')
     })
 }
public getRequestHandler() {
   return this.handleRequest.bind(this)
 }
// ......

服务器接收到一个请求的时候会调用run函数

private async run(req: IncomingMessage, res: ServerResponse, parsedUrl: UrlWithParsedQuery) {
    try {
      const fn = this.router.match(req, res, parsedUrl) // 去匹配路由
      if (fn) { // 如果匹配到则返回对应的处理函数
        await fn() //调用对应的处理函数
        return
      }
    } catch (err) {
      if (err.code === 'DECODE_FAILED') {
        res.statusCode = 400
        return this.renderError(null, req, res, '/_error', {})
      }
      throw err
    }

    /**
     * 在匹配不到的情况下 返回404页面
     */
    if (req.method === 'GET' || req.method === 'HEAD') {
      await this.render404(req, res, parsedUrl)
    } else { // 不能执行
      res.statusCode = 501
      res.end('Not Implemented')
    }
  }

在run函数中调用了Router对象的一个实例来管理路由匹配路由以及匹配到路由后执行对应的操作就是fn函数

先看下Router对象是怎么定义的

// router.ts
import {IncomingMessage, ServerResponse} from 'http'
import {UrlWithParsedQuery} from 'url'
import pathMatch from './lib/path-match' // 路径匹配
/**
 * 这个是 服务请求的路由
 */
export const route = pathMatch()  // route是个 返回的函数  通过一个自定义的path 取得一个可以判断 路由正则的函数

type Params = {[param: string]: string}

export type Route = {
  match: (pathname: string|undefined) => false|Params,
  fn: (req: IncomingMessage, res: ServerResponse, params: Params, parsedUrl: UrlWithParsedQuery) => void,
}

export default class Router {
  routes: Route[] // 存放这个路由栈中的全部路由
  constructor(routes: Route[] = []) { // 初始化为空
    this.routes = routes
  }

  add(route: Route) {// 往路由数组前面添加一个路由  
    this.routes.unshift(route)
  }

  match(req: IncomingMessage, res: ServerResponse, parsedUrl: UrlWithParsedQuery) {
    if (req.method !== 'GET' && req.method !== 'HEAD') { // 匹配的时候必须是通过 get 或者 head方式
      return
    }

    const { pathname } = parsedUrl // 解析后的url pathname是类似 /foo/:id
    for (const route of this.routes) {
      const params = route.match(pathname) //比如我访问 home params为home ==》 (/:path*).match('/home')
      if (params) {
        return () => route.fn(req, res, params, parsedUrl)
      }
    }
  }
}

主要功能其实就是匹配到对应规则的路由后返回对应的执行函数,其他的比如pathMatch是根据路由url来生成对应的正则,有兴趣的可以看下里面的代码,我就不多做介绍了哈。

// next-server.ts
import Router, {route, Route} from './router' // 服务端路由管理
// ........
const routes = this.generateRoutes() // 路由生成器生成的路由
this.router = new Router(routes) // 生成路由管理
//...........
private generateRoutes(): Route[] { // 路由生成器
   const routes: Route[] = [
     {
       match: route('/_next/static/:path*'), // 用route函数去生成一个 匹配当前path的正则
       fn: async (req, res, params, parsedUrl) => {
         // The commons folder holds commonschunk files commons文件夹保存着commonschunk文件
         // The chunks folder holds dynamic entries  chunks文件夹保存动态的入口文件
         // The buildId folder holds pages and potentially other assets. As buildId changes per build it can be long-term cached. buildid文件夹包含页面和可能的其他资源,当buildid每次构建更改时,它可以长期缓存
         if (params.path[0] === CLIENT_STATIC_FILES_RUNTIME || params.path[0] === 'chunks' || params.path[0] === this.buildId) { // runtime
           this.setImmutableAssetCacheControl(res) // 如果是客户端运行时的静态文件 或者是 chunks 或者是buildid 则设置资源缓存时间 就是返回资源的时候告诉浏览器缓存资源
         }
         const p = join(this.distDir, CLIENT_STATIC_FILES_PATH, ...(params.path || [])) // CLIENT_STATIC_FILES_PATH === static xxx/.next/static/xxx/xxx
         await this.serveStatic(req, res, p, parsedUrl) // 判断是否是静态的资源
       },
     },
     {
       match: route('/_next/:path*'), // 匹配到这个路由回去渲染404?如果其他路由匹配不到就会匹配这个路由?然后渲染404页面
       // This path is needed because `render()` does a check for `/_next` and the calls the routing again 这个路由是必要的,因为render()函数会检查'/_next'而且还会再次调用这个路由
       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. 否则这将会导致非常严重的dos攻击
       // 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 || [])) // xxx/static/xxx/xxxx
         await this.serveStatic(req, res, p, parsedUrl)
       },
     },
   ]

   if (this.nextConfig.useFileSystemPublicRoutes) { // 如果开启文件路由,默认是会把pages下的所有文件匹配路由的
     // 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*'), // 比如访问 /home 经过route之后是一个
       fn: async (req, res, _params, parsedUrl) => {
         const { pathname, query } = parsedUrl // 从地址栏解析后的url
         if (!pathname) {
           throw new Error('pathname is undefined') // 提示这个路径没定义
         }
         await this.render(req, res, pathname, query, parsedUrl) // 渲染相应的路由
       },
     })
   }

   return routes
 }

回到next-server.ts中,我们可以看出这里面定义了多个匹配的规则和对应的函数。if (this.nextConfig.useFileSystemPublicRoutes)在官方文档中有提到自定义路由,如果想全部使用自定义路由就要把useFileSystemPublicRoutes设置为false,否则就会把pages下面的文件都处理成页面,从这里就会看出为什么会全部处理成页面。

假设我们访问/home这个路径,其实就是匹配到了match: route(/:path*)这个规则,然后在上面提到的run函数中执行返回的fn函数。在这个fn中如果匹配到了已经定义的路径也就是我们要请求的页面,则调用了render函数。

// next-server.ts
  public async render(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}, parsedUrl?: UrlWithParsedQuery): Promise<void> {
    const url: any = req.url
    if (isInternalUrl(url)) { // 判断是否是/_next/ 和/static下面的 因为静态资源不用render 这边做个判断
      return this.handleRequest(req, res, parsedUrl)
    }

    if (isBlockedPage(pathname)) { //如果是访问 _app _docouemnt 这些 因为这是组件不能直接访问
      return this.render404(req, res, parsedUrl)
    }

    const html = await this.renderToHTML(req, res, pathname, query) // 如果是页面则 渲染成html
    // Request was ended by the user 
    if (html === null) {
      return
    }

    if (this.nextConfig.poweredByHeader) {
      res.setHeader('X-Powered-By', 'Next.js ' + process.env.NEXT_VERSION)
    }
    return this.sendHTML(req, res, html) // 渲染结束 就输出html页面
  }

  private async renderToHTMLWithComponents(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}, opts: any) {
    const result = await loadComponents(this.distDir, this.buildId, pathname) // 加载要渲染的组件 {buildManifest, reactLoadableManifest, Component, Document, App}
    return renderToHTML(req, res, pathname, query, {...result, ...opts})
  }

  public async renderToHTML(req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery = {}): Promise<string|null> {
    try {
      // To make sure the try/catch is executed
      const html = await this.renderToHTMLWithComponents(req, res, pathname, query, this.renderOpts)
      return html
    } catch (err) {
      if (err.code === 'ENOENT') {
        res.statusCode = 404
        return this.renderErrorToHTML(null, req, res, pathname, query)
      } else {
        this.logError(err)
        res.statusCode = 500
        return this.renderErrorToHTML(err, req, res, pathname, query)
      }
    }
  }

在这里面判断访问的是否是静态资源之类的文件因为这类文件返回的内容不一样,还要判断是否是 _app, _document这类的文件也不能直接被渲染然后返回。判断是可以被渲染然后返回html文件后调用renderToHTML渲染文件,在这个函数中又调用了renderToHTMLWithComponents其中loadComponents就是加载我们这个页面所需要的组件,如我们在pages下面定义了一个home.js 而这个文件就是我们需要访问的页面但是他不是完整的一个页面,所以还需要document, app这些组件来组合成一个页面。

// load-components.ts
import {join} from 'path' // static BUILD_MANIFEST === build-manifest.json REACT_LOADABLE_MANIFEST === react-loadable-manifest.json SERVER_DIRECTORY === server
import {CLIENT_STATIC_FILES_PATH, BUILD_MANIFEST, REACT_LOADABLE_MANIFEST, SERVER_DIRECTORY} from 'next-server/constants'
import {requirePage} from './require'

function interopDefault(mod: any) {
  return mod.default || mod
}

export async function loadComponents(distDir: string, buildId: string, pathname: string) {
  const documentPath = join(distDir, SERVER_DIRECTORY, CLIENT_STATIC_FILES_PATH, buildId, 'pages', '_document') // xxx/.next/server/static/H7vg9E0I1RQ0XlkuGOap9/pages/_document
  const appPath = join(distDir, SERVER_DIRECTORY, CLIENT_STATIC_FILES_PATH, buildId, 'pages', '_app') // xxx/.next/server/static/H7vg9E0I1RQ0XlkuGOap9/pages/_app
  const [buildManifest, reactLoadableManifest, Component, Document, App] = await Promise.all([
    require(join(distDir, BUILD_MANIFEST)), // xxx/.next/build-manifest.json
    require(join(distDir, REACT_LOADABLE_MANIFEST)), // xxx/.next/react-loadable-manifest.json
    interopDefault(requirePage(pathname, distDir)), // pathname就是要请求的网页 require进来
    interopDefault(require(documentPath)), // 获取文档对象
    interopDefault(require(appPath)), // 获取app对象,比如用create-react-app 创建后的app.js
  ])

  return {buildManifest, reactLoadableManifest, Component, Document, App}
}
// 页面表现所需的js  react所需的对象 要load的组件  document app

最终返回 {buildManifest, reactLoadableManifest, Component, Document, App}buildManifest对应了我们构建后文件夹中的build-manifest.json包含了各个页面所依赖的文件。reactLoadableManifest对应的是react-loadable-manifest.json包含了react所需要的依赖文件。Component就是我们对应的页面文件如 home.js

回到next-server.tsrenderToHTMLWithComponents函数中调用了/render.tsx下面的renderToHTML函数,这也是本次的重点,里面会介绍怎么渲染成html文件。

// render.tsx
export async function renderToHTML (req: IncomingMessage, res: ServerResponse, pathname: string, query: ParsedUrlQuery, renderOpts: RenderOpts): Promise<string|null> {
  const { // render的配置
    err,
    dev = false,
    staticMarkup = false,
    App,
    Document,
    Component,
    buildManifest,
    reactLoadableManifest,
    ErrorDebug
  } = renderOpts

  // 预加载 不代表已经挂在 确保 全部加载完 才挂在
  await Loadable.preloadAll() // Make sure all dynamic imports are loaded 确保所有动态导入的组件已经加载完成 因为加载组件的时候Router还没初始化所以不能在组件中直接使用Router

  if (dev) {
    const { isValidElementType } = require('react-is') // 判断是否是有效的元素类型
    if (!isValidElementType(Component)) { // 如果不是react的组件
      throw new Error(`The default export is not a React Component in page: "${pathname}"`)
    }

    if (!isValidElementType(App)) {
      throw new Error(`The default export is not a React Component in page: "/_app"`)
    }

    if (!isValidElementType(Document)) {
      throw new Error(`The default export is not a React Component in page: "/_document"`)
    }
  }

  const asPath = req.url // url可能被装饰后的
  const ctx = { err, req, res, pathname, query, asPath } // 上下文对象包含了
  const router = new Router(pathname, query, asPath)
  const props = await loadGetInitialProps(App, {Component, router, ctx}) // 通过 getinitalprops得到的 所以app里面能拿到我们要渲染的页面的component router

  // the response might be finished on the getInitialProps call
  if (isResSent(res)) return null

  const devFiles = buildManifest.devFiles // dev下的文件路径
  const files = [ // 所需的页面以及页面的依赖
    ...new Set([ // set去重 没有重复的值
      ...getPageFiles(buildManifest, pathname),
      ...getPageFiles(buildManifest, '/_app'),
      ...getPageFiles(buildManifest, '/_error')
    ])
  ]

  const reactLoadableModules: string[] = [] // react 所需的依赖
  const renderPage = (options: ComponentsEnhancer = {}): {html: string, head: any} => { // 还没调用
    const renderElementToString = staticMarkup ? renderToStaticMarkup : renderToString // 渲染成静态标记 或者字符串

    if(err && ErrorDebug) { // 如果 错误则渲染 错误页面
      return render(renderElementToString, <ErrorDebug error={err} />)
    }

    const {App: EnhancedApp, Component: EnhancedComponent} = enhanceComponents(options, App, Component)

    return render(renderElementToString,
      <LoadableCapture report={(moduleName) => reactLoadableModules.push(moduleName)}> {/**动态组件们 */}
        <EnhancedApp // 这个是app
          Component={EnhancedComponent} // 这个应该是我们要渲染的组件
          router={router}
          {...props}
        />
      </LoadableCapture>
    )
  }

  const docProps = await loadGetInitialProps(Document, { ...ctx, renderPage }) // document props 如果docuemnt中也有getInitialProps 返回了html 和 head 这时候也会收集 子组件也就是要渲染的页面中的 动态加载的组件
  // the response might be finished on the getInitialProps call
  if (isResSent(res)) return null

  const dynamicImports = [...getDynamicImportBundles(reactLoadableManifest, reactLoadableModules)] // 动态导入组件
  const dynamicImportsIds: any = dynamicImports.map((bundle) => bundle.id) // 得到bundle的id

  return renderDocument(Document, {
    ...renderOpts, // buildmainfest reactloadablemanifest component app docuemnt staticmarkup generateetag buildid
    props, // app的props
    docProps, // document的props
    pathname, // 真实的path
    query, // 查询参数
    dynamicImportsIds, // react依赖的动态导入文件的id
    dynamicImports, // react依赖的动态导入文件
    files, // 当前页面所依赖的全部文件
    devFiles // ?
  })
}

得先确保动态导入的组件已经加载好了,调用app的getInitialProps,得到我们页面全部需要的文件路径,在这里会看到一个比较特殊的函数 renderPage这个函数会在document文件的getInitialProps中被调用通过 <LoadableCapture report={(moduleName) => reactLoadableModules.push(moduleName)}> 可以收集我们动态加载的组件的列表。调用render函数通过对应判断得到的渲染的方式const renderElementToString = staticMarkup ? renderToStaticMarkup : renderToString来渲染app和我们要的页面组件(这里举例就是home.js)成html。最后在结合document来渲染成完整的页面。

// render.tsx
function renderDocument(Document: React.ComponentType, {
  props,
  docProps,
  pathname,
  query,
  buildId,
  assetPrefix,
  runtimeConfig,
  nextExport,
  dynamicImportsIds,
  err,
  dev,
  staticMarkup,
  devFiles,
  files,
  dynamicImports,
}: RenderOpts & {
  props: any,
  docProps: any,
  pathname: string,
  query: ParsedUrlQuery,
  dynamicImportsIds: string[],
  dynamicImports: ManifestItem[],
  files: string[]
  devFiles: string[],
}): string {
  return '<!DOCTYPE html>' + renderToStaticMarkup(
    <Document
      __NEXT_DATA__={{
        props, // The result of getInitialProps
        page: pathname, // The rendered page
        query, // querystring parsed / passed by the user
        buildId, // buildId is used to facilitate caching of page bundles, we send it to the client so that pageloader knows where to load bundles
        assetPrefix: assetPrefix === '' ? undefined : assetPrefix, // send assetPrefix to the client side when configured, otherwise don't sent in the resulting HTML
        runtimeConfig, // runtimeConfig if provided, otherwise don't sent in the resulting HTML
        nextExport, // If this is a page exported by `next export`
        dynamicIds: dynamicImportsIds.length === 0 ? undefined : dynamicImportsIds,
        err: (err) ? serializeError(dev, err) : undefined // Error if one happened, otherwise don't sent in the resulting HTML
      }}
      staticMarkup={staticMarkup}
      devFiles={devFiles}
      files={files}
      dynamicImports={dynamicImports}
      assetPrefix={assetPrefix}
      {...docProps}
    />
  )
}

其中__NEXT_DATA__在浏览器中已经被挂载成了全局变量了....

先分析到这,累了哈。

上一篇下一篇

猜你喜欢

热点阅读