《Next.js》源码浅析

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容