美文网首页
从源码认识NextJs(一)

从源码认识NextJs(一)

作者: 万物论 | 来源:发表于2019-02-13 16:40 被阅读58次

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

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

    • next-server:这部分的代码就是处理我们在地址栏中输入地址后的一些列行为,最重要的就是生成html然后返回给客户端。

    • next:这个就是在使用nextjs的时候就比较经常接触了,存放了,各个已经封装好的容器组件和默认组件,比如_app.js,_document.js,_error.js这些我们也可以对他们进行修改达到我们的需求。还有就是包含了webpack的配置对这个应用是如何打包的,分别打包成两份文件,一份给服务端的一份在客户端的代码,熟悉webpack配置React服务端渲染的肯定很熟悉了,只不过nextjs都帮我们封装好了,不用自行配置。而next-server被当成next的一个依赖。

    在我们浏览器地址栏中访问一个已经被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__在浏览器中已经被挂载成了全局变量了....

    先分析到这,累了哈。

    相关文章

      网友评论

          本文标题:从源码认识NextJs(一)

          本文链接:https://www.haomeiwen.com/subject/enbwsqtx.html