Nextjs初探


Nextjs 目录结构

  • pages下所有文件的文件名对应页面的子路径(理解为 nextjs 中的路由体系,区别于 KOA 的路由)

    两个例外:_app.js、__document.js

  • components:组件

  • lib:utils 库等

  • .next:nextjs 编译生成的文件,正式环境需要的文件

  • next.config.js:配置文件

路由基础

Nextjs 提供的Link组件:

  • 前端路由跳转

    <Link href="/a">
      <Button type="primary">用户列表页</Button>
    </Link>
  • 需要制定渲染内容(任何支持 onclick 的组件),直接子节点必须唯一

    // error!
    <Link>
      <Button>按钮</Button>
      <span>111</span>
    </Link>

可以用 Router 模块进行跳转:

Router.push("/test/b");

动态路由

nextjs 的路由传递参数 只能通过query

因为 nextjs 是通过文件夹路径来生成路由路径,所以动态路由只能使用query的方式(例如’/a?id=1’,而不能采用’/a/1’)

// 两种方式
<Link href="/a?id=1"></Link>
...
Router.push({
  pathname: '/b',
  query: {
    id: 2,
  },
})

在相应的页面中如何拿到路由的参数呢?

import { withRouter } from "next/router";
import Comp from "../components/comp";

const A = ({ router }) => <Comp>A, id is {router.query.id}</Comp>;

export default withRouter(A);

就是用withRouter作为 A 的一个高阶组件,传入一个 router 对象,通过router.query.param的方式获取参数

路由映射

目的:为了实现/a/id的这种常规的显示方式

通过as这个属性:

<Link href="/a?id=1" as="/a/1">
  <Button type="primary">用户列表页</Button>
</Link>

通过传入 Router.push 第二个参数一个路径:

Router.push({...}, '/b/2',);

但是你现在直接访问(或刷新)http://localhost:3000/a/1,会发现是 404 页面

因为前面的路由映射仅仅是 Link 标签点击事件后触发的跳转,是在浏览器端进行的,而服务端渲染是不知情的;访问该页面依旧会去找pages/a/1.js这个文件,找不到所以爆出404 error

附上官方对 as 和 href 的定义

  • href: the path inside pages directory.
  • as: the path that will be rendered in the browser URL bar.

这就是 nextjs 中路由的缺陷了,所以我们要使用 koa 的路由配置来代替 nextjs 本身的路由

// server.js
const server = new Koa();
const router = new Router();

router.get("/a/:id", async (ctx, next) => {
  const id = ctx.params.id;
  await handle(ctx.req, ctx.res, {
    pathname: "/a",
    query: { id }
  });
  ctx.response = false;
});

// 我的理解是:在koa路由中未定义的,将交给nextjs路由继续处理
router.get("*", async ctx => {
  await handle(ctx.req, ctx.res);
  // 屏蔽koa中对response的内置处理,让nextjs来接手
  ctx.response = false;
});

server.use(router.routes());

这里还会产生一个问题:页面并有将 id 值渲染出来,query对象为空,如何解决呢?

我们可以通过为组件添加getInitialProps来解决

具体原因来自于 Nextjs 的自动预渲染(Automatic prerendering)机制

  • if getInitialProps is present, Next.js will not prerender the page. Instead, Next.js will use its default behavior and render the page on-demand, per-request (meaning Server-Side Rendering).
  • If getInitialProps is absent, Next.js will statically optimize your page automatically by prerendering it to static HTML. During prerendering, the router’s query object will be empty since we do not have query information to provide during this phase. Any query values will be populated client side after hydration.
import { withRouter } from "next/router";

const PageB = props => {
  const { router } = props;
  return (
    <span>
      test b, {router.query.id}, {props.postId}
    </span>
  );
};

PageB.getInitialProps = async ({ req, query }) => {
  return {
    postId: query.id
  };
};

export default withRouter(PageB);

路由事件

可以理解为路由生命周期,官方文档列出的路由事件如下:

  • routeChangeStart(url) - Fires when a route starts to change
  • routeChangeComplete(url) - Fires when a route changed completely
  • routeChangeError(err, url) - Fires when there’s an error when changing routes, or a route load is cancelled
  • beforeHistoryChange(url) - Fires just before changing the browser’s history
  • hashChangeStart(url) - Fires when the hash will change but not the page
  • hashChangeComplete(url) - Fires when the hash has changed but not the page

我们先写一段函数在控制台打印出router event

const events = [
  "routeChangeStart",
  "routeChangeComplete",
  "routeChangeError",
  "beforeHistoryChange",
  "hashChangeStart",
  "hashChangeComplete"
];

function makeEvent(type) {
  return (...args) => {
    console.log(type, ...args);
  };
}

events.forEach(event => {
  // 开启某事件监听
  Router.events.on(event, makeEvent(event));
});

然后我们点击前面页面的按钮,可以发现控制台打印如下router event信息:

beforeHistoryChange这个钩子代表 Web API 中的History API:可以在不刷新页面的前提下动态改变浏览器的 url 地址以及动态的修改当前页面上的资源

hashChangeStarthashChangeComplete两个钩子则是代表触发 Web API 中的hashchange事件:当 URL 的片段标识符更改时,将触发hashchange事件 (跟在#符号后面的 URL 部分,包括#符号)

例子如下:

<Link href="#aaa">
  <Button type="primary">用户列表页</Button>
</Link>

Nextjs 获取数据规范

SSR 痛点:完成客户端和服务端数据同步

重中之重:getInitialProps

这应该是 NextJS 解决服务端和浏览器端同时渲染问题上最巧妙的发明之一,我刚开始还简单理解为一个生命周期钩子…惭愧…

PageB.getInitialProps = async ({ req, query }) => {
  console.log("*******run getInitialProps!!!*********");
  return {
    postId: query.id,
    name: "xuanxiao"
  };
};

getInitialProps入参对象属性如下:

  • pathname - URL 的 path 部分
  • query - URL 的 query 部分,并被解析成对象
  • asPath - 显示在浏览器中的实际路径(包含查询部分),为String类型
  • req - HTTP 请求对象 (只有服务器端有)
  • res - HTTP 返回对象 (只有服务器端有)
  • jsonPageRes - 获取数据响应对象 (只有客户端有)
  • err - 渲染过程中的任何错误

只有pages/下的文件中定义的getInitialProps才会生效(每个路由都对应 pages 下的“页面”)

getInitialProps的返回值会作为组件PageBprops传入,然后再进行页面组件渲染


问题:如果是通过单页应用路由跳转,上述console.log是在浏览器的控制台打出的;如果是通过刷新页面,则是在服务端的控制台打出,为什么?

这就是getInitialProps的原理所在了

在刷新页面时是通过服务端渲染获得页面传给浏览器端,相应页面组件对应的 getInitialProps 被调用,return 的结果除了进行 React 渲染以外,还被放在一个内嵌的且 id 为__NEXT_DATA__script标签中:

所以在浏览器端渲染获取数据时,可以直接从上述script标签中组装 props 等数据并传递给组件去渲染(这里利用的是React Hydrate),而无需再次请求 API 获取数据。

那什么时候getInitialProps在浏览器端执行呢?

就是在单页应用路由切换的时候,这时候跟服务端就完全没关系了,纯粹靠浏览器执行getInitialProps获取数据并进行 React 页面组件渲染。

对于用户来说,完全有可能通过单页应用路由切换或直接访问的方式访问某页面,渲染的方式也可能是服务端渲染或浏览器端渲染,但是有 Nextjs 帮我们决定执行getInitialProps的时机,开发者只需完善getInitialProps获取 API 数据的逻辑就好了。

Nextjs 自定义 App 组件

Nextjs 使用App组件来初始化页面,我们可以重写它来控制页面的初始化流程

新建文件./pages/_app.js

import App from "next/app";

class MyApp extends App {
  render() {
    const { Component, pageProps } = this.props;
    return <Component></Component>;
  }
}

这里的 Component 就是指 pages 下的每个页面组件,pageProps 就是传入页面组件的参数

但是写到这一步我发现,每个页面中自定义的getInitialProps所 return 的数据都没有都没有传给相应的页面组件,而每个页面的这些参数都存在 App 组件的this.props.pageProps中。

// 需要将pageProps传给每个页面组件
...
return <Component {...pageProps}></Component>

所以目前我理解的每个页面自顶向下的参数流向,应该是下面这样:


问题:这个时候如果给 MyApp 自定义getInitialProps方法,会发现页面组件中通过组件自身的getInitialProps传递的参数并没有渲染到页面上,_app.js如下:

// _app.js
class MyApp extends App {
  // 自定义该方法后会发现相应组件参数并没有被渲染到页面上
  static async getInitialProps() {
    return {};
  }

  render() {
    const { Component, pageProps } = this.props;
    return <Component {...pageProps}></Component>;
  }
}

这是因为自定义了getInitialProps后,我们需要手动返回pageProps参数给 App 组件

App 组件的getInitialProps可以接收一个参数appContext,打印下看看:

// appContext
{ AppTree: [Function: AppTree],
  Component:
   { [Function: WithRouteWrapper]
     displayName: 'withRouter(PageB)',
     getInitialProps: [AsyncFunction],
     contextTypes: { router: [Function] } },
  router:
   ServerRouter {
     route: '/b',
     pathname: '/b',
     query: { id: '2' },
     asPath: '/b/2' },
  ctx:
   { err: undefined,
     req:
      IncomingMessage {
        _readableState: [ReadableState],
        readable: true,
        _events: [Object],
        _eventsCount: 1,
        _maxListeners: undefined,
        socket: [Socket],
        connection: [Socket],
        httpVersionMajor: 1,
        httpVersionMinor: 1,
        httpVersion: '1.1',
        complete: true,
        headers: [Object],
        rawHeaders: [Array],
        trailers: {},
        rawTrailers: [],
        aborted: false,
        upgrade: false,
        url: '/b/2',
        method: 'GET',
        statusCode: null,
        statusMessage: null,
        client: [Socket],
        _consuming: false,
        _dumped: false,
        _parsedUrl: [Url] },
     res:
      ServerResponse {
        _events: [Object],
        _eventsCount: 2,
        _maxListeners: undefined,
        output: [],
        outputEncodings: [],
        outputCallbacks: [],
        outputSize: 0,
        writable: true,
        _last: false,
        chunkedEncoding: false,
        shouldKeepAlive: true,
        useChunkedEncodingByDefault: true,
        sendDate: true,
        _removedConnection: false,
        _removedContLen: false,
        _removedTE: false,
        _contentLength: null,
        _hasBody: true,
        _trailer: '',
        finished: false,
        _headerSent: false,
        socket: [Socket],
        connection: [Socket],
        _header: null,
        _onPendingData: [Function: bound updateOutgoingData],
        _sent100: false,
        _expect_continue: false,
        statusCode: 200,
        __onFinished: [Function],
        locals: {},
        flush: [Function: flush],
        write: [Function: write],
        end: [Function: end],
        on: [Function: on],
        writeHead: [Function: writeHead],
        [Symbol(isCorked)]: false,
        [Symbol(outHeadersKey)]: null },
     pathname: '/b',
     query: { id: '2' },
     asPath: '/b/2',
     AppTree: [Function: AppTree] } }

可以看到queryComponent均包含其中

所以我们在getInitialProps中加入如下代码:

static async getInitialProps(appContext) {
  // 获取页面Component
  const { Component } = appContext;
  // 通过相应页面Component获取返回的参数,然后传入App的props
  // getInitialProps()传入参数为appContext.ctx上下文
  const pageProps = await Component.getInitialProps(appContext.ctx);
  return { pageProps };
}

官方有一个更便捷的方法,如下,但我觉得上面的方法更好理解

static async getInitialProps(appContext) {
  const appProps = await App.getInitialProps(appContext);
  return { ...appProps };
}

每次切换页面时,App 下的getInitialProps都会调用。

Nextjs 自定义 Document

文件:/pages/_document.js

Document 组件就是自定义页面中htmlbody等标签的内容,相当于我们可以插入一些通用的 css 等样式进去

只有在服务端渲染的时候才会被执行

与前面自定义 App 组件一样,自定义 Document 组件时,rendergetInitialProps方法一旦重写,就必须包含必要的代码部分。

import Document, { Html, Head, Main, NextScript } from "next/document";

class MyDocument extends Document {
  static async getInitialProps(ctx) {
    // 必须调用Document.getInitialProps获取'html'参数等
    const props = await Document.getInitialProps(ctx);
    return {
      ...props
    };
  }
  render() {
    return (
      <Html>
        <Head>
          <title>My App</title>
          <style>{`.test {color: red}`}</style>
        </Head>
        <body className="test">
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

Nextjs 加载样式文件

Styled-jsx

如何加载 css:在 nextjs 中默认是 styled-jsx,nextjs 中已经集成,无需额外配置。

// 定义样式
<style jsx>{`
  a {
    color: red;
  }
`}</style>

通过如下方式实现组件与组件之间样式的隔离

定义全局样式:

const color = "red";
...
<style jsx global>{`
  a {
    color: ${color};
  }
`}</style>

将样式加入 head 是在 ssr 中完成的,所以在浏览器刷新的过程中不会出现样式突然跳变的情况,有更好的用户体验。


Styled-component

这个在以前 react 开发中就是比较常见的css-in-js方案,现在我尝试在 nextjs 中使用它

先安装

npm install styled-components babel-plugin-styled-components

修改_document.js中的关键方法 getInitialProps,在 SSR 阶段将生成的<style>...</style>添加到<head>

// _document.js
...
import { ServerStyleSheet } from 'styled-components'

export default class MyDocument extends Document {
  static async getInitialProps(ctx) {
    const originalRenderPage = ctx.renderPage;
    const sheet = new ServerStyleSheet();
    try {
      ctx.renderPage = () =>
        originalRenderPage({
            // 这里相当于生成一个HOC,包裹着App
          enhanceApp: App => props => sheet.collectStyles(<App {...props} />),
        });

      const props = await Document.getInitialProps(ctx);
      return {
        ...props,
        styles: (
          <>
            {props.styles}
              // 将生成的style标签注入其中
            {sheet.getStyleElement()}
          </>
        ),
      };
    } finally {
      sheet.seal();
    }
  }
}

添加一个样式:

const Title = styled.h1`
  color: yellow;
  font-size: 40px;
`;
...
export default const PageB = props => {
  return (
    <>
      <Title>This is title</Title>
      ...
    </>
  );
};

查看页面源代码可以发现,在头部生成了相应的<style>标签

Nextjs 中的 LazyLoading

有时候我们不需要某个组件在所有页面的 js 中都被加载,这就需要用到 nextjs 中的dynamic component

这类组件只有需要在 app 中被渲染时才会被加载

lazyload modules

动态加载模块

PageB.getInitialProps = async ({ query }) => {
  const moment = await import('moment');
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        ...
        time: moment.default(Date.now() - 60 * 1000).fromNow(),
      });
    }, 2000);
  });

  return promise;
};

lazyload components

动态加载组件:只有在组件被渲染时才会被加载

dynamic实现很可能用了 React.lazy

import dynamic from "next/dynamic";
// lazyload component
const Comp = dynamic(() => import("../components/comp"));

Nextjs 配置:next.config.js

nextjs 的基本配置如下:

const config = {
  // 编译文件输出路径 默认为'.next'
  distDir: "dest",
  // 是否为每个路由生成Etags
  // HTTP ETag是HTTP协议提供的若干机制中的一种Web缓存验证机制,并且允许客户端进行缓存协商。
  generateEtags: true,
  // 页面内容缓存配置
  onDemandEntries: {
    // 页面在内存中缓存的时间(单位:秒)
    maxInactiveAge: 25 * 1000,
    // 同时缓存几个页面
    pagesBufferLength: 2
  },
  // 在pages目录下哪种文件后缀会被认为是页面
  pageExtensions: ["mdx", "jsx", "js"],
  // 配置buildId
  generateBuildId: async () => {
    // When process.env.YOUR_BUILD_ID is undefined we fall back to the default
    if (process.env.YOUR_BUILD_ID) {
      return process.env.YOUR_BUILD_ID;
    }
    // 返回null使用默认的unique_id
    return null;
  },
  // 修改webpack config
  webpack(config, options) {
    return config;
  },
  // 修改webpackDevMiddleware config
  webpackDevMiddleware: config => {
    // Perform customizations to webpack dev middleware config
    // Important: return the modified config
    return config;
  },
  // 可以通过process.env.customKey获取value
  env: {
    customKey: "value"
  },
  // serverRuntimeConfig只有在服务端渲染时才会获取,下面两个配置需要通过next/config模块进行获取
  serverRuntimeConfig: {
    // Will only be available on the server side
    mySecret: "secret",
    secondSecret: process.env.SECOND_SECRET // Pass through env variables
  },
  // 该配置在SSR和CSR都会获取
  publicRuntimeConfig: {
    // Will be available on both server and client
    staticFolder: "/static"
  }
};

使用具体配置只需module.export = { // ...config }即可


文章作者: 玄霄
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 玄霄 !
评论
 上一篇
学习React Hooks 学习React Hooks
1. Hook 的定义React Hooks 设计的目的:加强版函数组件,让函数组件也拥有类组件的功能。 “Hook”的意思是钩子,React Hooks 想要达到的效果就是在尽量使用纯函数,且如需要外部功能或副作用,就用 Hooks 将它
2019-09-26
下一篇 
VSCode 配置ESLint + Prettier 统一前端代码风格 VSCode 配置ESLint + Prettier 统一前端代码风格
原理 使用 eslint 检查代码 使用 prettier 作为 eslint 的插件来格式化代码 安装 VSCode 插件直接通过商店安装即可,这里附上官网链接: ESLint Prettier 项目中的配置配置 ESLint
2019-09-09
  目录