美文网首页
《Next.js》源码浅析

《Next.js》源码浅析

作者: Napster99 | 来源:发表于2021-07-05 19:47 被阅读0次

    Next.js 源码浅析


    前言:在Web1.0时代,很多项目都是有JSP、PHP生成的页面,浏览器负责展示服务端输出的页面,输出什么展示什么,所有对页面的逻辑控制都放在了WebServer端,基本上部署一个Tomcat或Apache服务器就能搞定,但随着互联网多元化的出现,单一架构模式已经没办法满足当前复杂的业务发展,于是出现了各种架构模式,Ajax的出现更是加快了前后端分离的步伐,把JSP中静态的HTML部分剥离了出来,动态数据部分通过调用Ajax方式从服务端获取,操作DOM,完成最终页面的展示。
    但很快通过Ajax抓取数据再渲染页面的弊端也显露出来,最重要的问题就是对搜索引擎的抓取很不友好,导致排名下降,SEO体验变得很差。所以还是得重回服务端渲染的老路子。
    那么问题来了,有没有一种方法既可以解决SEO&首屏加载问题,又能有良好的开发体验?
    答案:同构模式了解一下。

    什么是同构渲染?

    简单来说就是一份代码,既可以跑在服务端又能跑在客户端。

    首先先看下最原始的服务端渲染的实现(基于NodeJS+Express实现)

    const express = require('express');
    const app = express();
    
    app.get("/", (req, res) =>
      res.send(`
        <html>
          <head>
              <title>ssr demo</title>
          </head>
          <body>
            <h1>Hello world</h1>
          </body>
        </html>
    `)
    );
    
    app.listen(3000, () => console.log("Example App listening on port 3000 ..."));
    

    例子很简单,浏览器访问根目录的时候,服务端返回一个简单的页面。这里同学们可能注意到返回的是一个字符串,没错。浏览器会解析Content-Type: text/html;按页面类型显示(显示画面自行脑补)
    因为服务端没有DOM,所以不能处理事件等DOM相关行为,只能输出HTML String。
    因此相同的代码客户端需要再跑一次,把DOM的行为再加上,这样才能输出一张功能完整的页面供用户使用,这也是同构渲染的意义所在。
    话不多说,直接上基于React+NodeJS+打包套件若干实现的同构渲染。

    客户端代码 client.js
    import React from "react";
    import Page from "../comp/Page";
    import ReactDOM from "react-dom";
    
    ReactDOM.hydrate(<Page />, document.getElementById("root"));
    
    服务端代码 server.js
    import express from "express";
    import React from "react";
    import { renderToString, renderToStaticMarkup } from "react-dom/server";
    import Page from "../comp/Page";
    
    const app = express();
    app.use(express.static("public"));
    
    // 将组件渲染成字符串
    const content = renderToString(<Page />);
    
    app.get("/", (req, res) =>
      res.send(`
        <html>
          <head>
              <title>ssr demo</title>
          </head>
          <body>
            <div id="root">${content}</div>
            <button style="background: tan;" onClick="alert(6)">Server点击</button>
          </body>
          <script src="/index.js"></script>
          <script>
            window.__DATA__ = '${content}'
          </script>
        </html>
    `)
    );
    
    app.listen(3000, () => console.log("Exampleapp listening on port 3000 ..."));
    

    通过代码可以看出,Page中的这段代码同时被client&server都加载了。只是在客户端被当作一个组件直接引入,在服务端通过了renderToString方法转换后得到了具体的字符串在输出。各个API(renderToString/renderToStaticMarkup/hydrate/...)的具体作用就不做细致介绍了,自行学习。
    在server.js例子中有两点需要特别注意的地方

    1、<script src="/index.js"></script>
    2、window.DATA = '${content}'

    注意一下:
    当浏览器执行了script标签就会发起加载index.js,这时服务端就必须要有对应的路由返回index.js文件,例子中的做法是把public文件夹设置成静态文件访问的根目录,这样就可以通过设置的路径访问对应的文件了。
    再则将content内容赋值给了名叫DATA的全局对象,理解了这种形式,对后续Next是怎么传值给客户端有一定的参考意义。

    附:例子源码GitHub【react-ssr-demo】

    此致我们了解了SSR的实现的基本思路,下面就正式开启Next.js的大门。

    What's NextJS ?


    Next.js gives you the best developer experience with all the features you need for production: hybrid static & server rendering, TypeScript support, smart bundling, route pre-fetching, and more. No config needed.
    原文引用一波:JS为您提供了生产所需的所有特性的最佳开发人员体验:混合静态和服务端渲染、TypeScript支持、智能绑定、路由预加载等等。不需要配置 开箱即用。

    Next.js特性总览

    大致了解了Next能为我们提供的功能后,我们先来熟悉下Next提供的几个命令行的作用。

    /// next.js/packages/next/bin/next.ts
    const commands: { [command: string]: () => Promise<cliCommand> } = {
      build: () => import('../cli/next-build').then((i) => i.nextBuild),
      start: () => import('../cli/next-start').then((i) => i.nextStart),
      export: () => import('../cli/next-export').then((i) => i.nextExport),
      dev: () => import('../cli/next-dev').then((i) => i.nextDev),
      lint: () => import('../cli/next-lint').then((i) => i.nextLint),
      telemetry: () => import('../cli/next-telemetry').then((i) => i.nextTelemetry),
    }
    

    通过create-next-app命令初始化一个Next项目,生成的目录结构如下:

    image.png

    通过目录文件可以得到一个完整的可运行的Next项目,主要的几个目录及文件

    /pages
    /pages/api
    /public
    /next.config.js

    因为Next实现了一套基于文件系统的路由,/pages就是作为路由根目录。
    /public是静态文件服务的根目录,下面所有文件都可被访问。
    next.config.js作为Next项目的配置文件。

    基本上就是开箱即可,做到了零配置。

    步入正题,Next作为一款服务端框架,它是怎么实现服务端渲染的行为呢?
    首先我们通过yarn dev命令,大致了解下它的运行过程。

    commands[command]()
      .then((exec) => exec(forwardedArgs))
      .then(() => {
        if (command === 'build') {
          // ensure process exits after build completes so open handles/connections
          // don't cause process to hang
          process.exit(0)
        }
      })
    
    if (command === 'dev') {
      const { CONFIG_FILE } = require('../shared/lib/constants')
      const { watchFile } = require('fs')
      watchFile(`${process.cwd()}/${CONFIG_FILE}`, (cur: any, prev: any) => {
        if (cur.size > 0 || prev.size > 0) {
          console.log(
            `\n> Found a change in ${CONFIG_FILE}. Restart the server to see the changes in effect.`
          )
        }
      })
    }
    

    以上是next.js/packages/next/bin/next.ts文件中的实现代码,当command不同引用不同的处理文件,特别是command === 'dev'的情况下,开启了对CONFIG_FILE(next.config.js) watchFile方法进行改动后提示服务重启的接听。

    接下来顺藤摸瓜,dev最终执行的是cli/next-dev.ts文件。

    image.png
    首先对用户输入的命令行参数进行解析,得到args,从源码可以看出对 --help & 自定义执行根目录的支持。

    yarn dev --help
    yarn dev /path #自定义执行根路径, 默认'.'

      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}`)
      }
    

    当dir不是有效存在路径,给出错误提示,并异常退出process.exit(1)

    接下来重点看下之后干了什么

    import startServer from '../server/lib/start-server'
    
    const port =
        args['--port'] || (process.env.PORT && parseInt(process.env.PORT)) || 3000
    const host = args['--hostname'] || '0.0.0.0'
    const appUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`
    
    startServer({ dir, dev: true, isNextDevCommand: true }, port, host)
    .then(async (app) => {
      startedDevelopmentServer(appUrl, `${host}:${port}`)
      // Start preflight after server is listening and ignore errors:
      preflight().catch(() => {})
      // Finalize server bootup:
      await app.prepare()
    }).catch(() => { //do something else... })
    

    startServer 方法;在yarn dev的情况下传入的参数dev & isNextDevCommand 写死为true, port 及 host。

    /// next.js/packages/next/server/lib/start-server.ts
    
    import http from 'http'
    import next from '../next'
    
    export default async function start(
      serverOptions: any,
      port?: number,
      hostname?: string
    ) {
      const app = next({
        ...serverOptions,
        customServer: false,
      })
      const srv = http.createServer(app.getRequestHandler())
      await new Promise<void>((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方法干了什么?

    /// next.js/packages/next/server/next.ts
    
    import './node-polyfill-fetch'
    import { default as Server, ServerConstructor } from './next-server'
    import { NON_STANDARD_NODE_ENV } from '../lib/constants'
    import * as log from '../build/output/log'
    import loadConfig, { NextConfig } from './config'
    import { resolve } from 'path'
    import {
      PHASE_DEVELOPMENT_SERVER,
      PHASE_PRODUCTION_SERVER,
    } from '../shared/lib/constants'
    import { IncomingMessage, ServerResponse } from 'http'
    import { UrlWithParsedQuery } from 'url'
    
    type NextServerConstructor = ServerConstructor & {
      /**
       * Whether to launch Next.js in dev mode - @default false
       */
      dev?: boolean
    }
    
    let ServerImpl: typeof Server
    
    const getServerImpl = async () => {
      if (ServerImpl === undefined)
        ServerImpl = (await import('./next-server')).default
      return ServerImpl
    }
    
    export class NextServer {
      private serverPromise?: Promise<Server>
      private server?: Server
      private reqHandlerPromise?: Promise<any>
      private preparedAssetPrefix?: string
      options: NextServerConstructor
    
      constructor(options: NextServerConstructor) {
        this.options = options
      }
    
      getRequestHandler() {
        return async (
          req: IncomingMessage,
          res: ServerResponse,
          parsedUrl?: UrlWithParsedQuery
        ) => {
          const requestHandler = await this.getServerRequestHandler()
          return requestHandler(req, res, parsedUrl)
        }
      }
      
      // 省略其他方法,着重关注服务是如何开启的
    
      private async createServer(
        options: NextServerConstructor & {
          conf: NextConfig
          isNextDevCommand?: boolean
        }
      ): Promise<Server> {
        if (options.dev) {
          const DevServer = require('./dev/next-dev-server').default
          return new DevServer(options)
        }
        return new (await getServerImpl())(options)
      }
    
      private async loadConfig() {
        const phase = this.options.dev
          ? PHASE_DEVELOPMENT_SERVER
          : PHASE_PRODUCTION_SERVER
        const dir = resolve(this.options.dir || '.')
        const conf = await loadConfig(phase, dir, this.options.conf)
        return conf
      }
    
      private async getServer() {
        if (!this.serverPromise) {
          setTimeout(getServerImpl, 10)
          this.serverPromise = this.loadConfig().then(async (conf) => {
            this.server = await this.createServer({
              ...this.options,
              conf,
            })
            if (this.preparedAssetPrefix) {
              this.server.setAssetPrefix(this.preparedAssetPrefix)
            }
            return this.server
          })
        }
        return this.serverPromise
      }
    
      private async getServerRequestHandler() {
        // Memoize request handler creation
        if (!this.reqHandlerPromise) {
          this.reqHandlerPromise = this.getServer().then((server) =>
            server.getRequestHandler().bind(server)
          )
        }
        return this.reqHandlerPromise
      }
    }
    
    // This file is used for when users run `require('next')`
    function createServer(options: NextServerConstructor): NextServer {
      const standardEnv = ['production', 'development', 'test']
      // do something else ...
    
      return new NextServer(options)
    }
    
    // Support commonjs `require('next')`
    module.exports = createServer
    exports = module.exports
    
    // Support `import next from 'next'`
    export default createServer
    
    

    可以看出dev通过http模块http.createServer([requestListener])启了一个Node服务,具体事件的监听函数将由getRequestHandler实现。

    /// next.js/packages/next/server/next.ts
    
    private async createServer(
        options: NextServerConstructor & {
          conf: NextConfig
          isNextDevCommand?: boolean
        }
      ): Promise<Server> {
        if (options.dev) {
          const DevServer = require('./dev/next-dev-server').default
          return new DevServer(options)
        }
        return new (await getServerImpl())(options)
      }
    

    最终真正执行的就是next-dev-server.ts这个文件,,当然DevServer也是继承了next-server中的方法。

    /// next.js/packages/next/server/dev/next-dev-server.ts
    
    import Server, {
      WrappedBuildError,
      ServerConstructor,
      FindComponentsResult,
    } from '../next-server'
    
    export default class DevServer extends Server 
    
    /// next.js/packages/next/server/lib/start-server.ts
    const srv = http.createServer(app.getRequestHandler())
    
    /// next.js/packages/next/server/next-server.ts
      public getRequestHandler() {
        return this.handleRequest.bind(this)
      }
    

    到此为止,我们可以清晰的看到next.js利用Node的http模块,开启了一个http服务,每条请求都有handleRequest方法处理。接下来我们重点看下基于文件系统的路由是怎么实现的?
    通过源码可以看到handleRequest方法体对basePath&i18n做了一系列的处理后,最终还是调用了run方法。

    return await this.run(req, res, parsedUrl)
    
      protected async run(
        req: IncomingMessage,
        res: ServerResponse,
        parsedUrl: UrlWithParsedQuery
      ): Promise<void> {
        this.handleCompression(req, res)
    
        try {
          const matched = await this.router.execute(req, res, parsedUrl)
          if (matched) {
            return
          }
        } catch (err) {
          if (err.code === 'DECODE_FAILED' || err.code === 'ENAMETOOLONG') {
            res.statusCode = 400
            return this.renderError(null, req, res, '/_error', {})
          }
          throw err
        }
    
        await this.render404(req, res, parsedUrl)
      }
    
    
    

    从源码上可以一眼就能看出处理逻辑,先进行请求体压缩,然后执行匹配路由,最后404页面兜底,整体流程还是简单明了的。到此为止,只是请求的链路处理,和基于文件系统的路由貌似没有多大关系,的确没有体现,接下来我们看下run方法里最核心的一段代码。

    await this.router.execute(req, res, parsedUrl)

    Next.js中一大核心主角:Router

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

    首先看下allRouter路由分布


    Route.png

    https://naotu.baidu.com/file/03959a75d00ad07532b72f45764bd2d4?token=13abf7d51654167a

    框架工具库:https://github.com/pillarjs/path-to-regexp

    相关文章

      网友评论

          本文标题:《Next.js》源码浅析

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