Ultra Simple Isomorphic with Next.js

Zet @ JSDC 2017

2017.11.04

slides: goo.gl/gahnKy

demo: goo.gl/ycnfWa

About me

  • 周昱安(Zet)
    • 國立中央大學資工所 – Web 智慧與資料探勘實驗室
    • EXMA-Square 實習前端工程師
    • 熱衷於前端技術開發、學習與分享
    • 主要使用 React 社群的相關技術
    • Github:tz5514

Isomorphic

名詞比較

  • Server-Side Rendering(SSR)
    • 是指將前端應用程式在伺服器端渲染成 HTML 字串的這個效果,例如:Prerender
  • Isomorphic
    • 是指瀏覽器端與伺服器端能夠「共用同一份程式碼」來分別渲染出 DOM 與 HTML 字串的這個架構,例如:共用 Router 設定、UI Component
  • Universal JavaScript
    • 指 JavaScript 程式碼能夠在不同的環境中運行的概念,例如:React Native、Electron
    • 常與 Isomorphic 這個字混用,在 Web 前端社群的討論中指的是差不多同一件事

Universal

Isomorphic

SSR

Why Isomorphic

  • 畫面首次加載速度 
    • 除了事先渲染 HTML 之外,SSR 還可以預先在伺服器端抓取 API 資料,令抵達使用者瀏覽器端後無需再發起並等待 AJAX Request
    • 純 Client-Side Rendering 方案傳送空殼 HTML 到瀏覽器並 JavaScript 執行之後,才開始產生 DOM 與進行資料初始化處理,導致畫面首次加載體驗不佳
  • 程式碼重用與設定共用
    • 應用程式在瀏覽器端與伺服器渲染跑的是完全不同的兩個流程,若分別針對寫成兩份獨立的程式碼的話,同步相關設定與維護將會耗費巨大的成本
  • SEO(搜尋引擎排名最佳化)
    • 純 Client-Side Rendering 會讓 HTML 結果只有空殼,導致 SEO 問題 

Client

Sever

Shared

External API

自行實作之難處

  • Router
    • 前後端的 Routing 完全是獨立運行的兩套流程,但是在 Isomorphic App 中他們應該要共用同一份設定
  • 資料同步問題
    • Isomorphic App 在 SSR 結束並 HTML 抵達前端時,會再次開始跑前端渲染,因此如果沒有將 SSR 時已經處理好的資料接力給前端當初始資料的話,會導致畫面不同步
  • Render
    • 前後端完全不同流程,前端以 SPA 的模式渲染 DOM,後端則是渲染成 HTML 字串
  • AJAX Call API
    • SSR 時必須讓 API 資料請求在 Render 流程開始前完成,UI 才會有正確的資料來渲染成 HTML
  • CSS Server-Side Rendering
    • CSS 樣式也必須進行 SRR,以避免一進入畫面有一瞬間看到沒有樣式的 HTML
  • 開發環境問題
    • Webpack 設定同步
    • Babel 設定同步
    • 環境變數同步
  • ....種種麻煩族繁不及備載,因而讓許多開發者怯步

「Isomorphic 環境架構好麻煩,我不能把精力好好的專注在開發 Business logic 嗎?」

Next.js

Next.js

  • Next.js 是由 ZEIT 打造並開源的 React Isomorphic Framework

  • 高度封裝許多自行實作 Isomorphic 時的麻煩事,極簡設定需求

  • 保持相當不錯的架構彈性,大多數設定允許自定義,也能很好的按需求擴充架構

  • 社群相當活躍,為現今 React 社群中最熱門的 Isomorphic / Universal 集成解決方案(18000+ Star),擁有許多相關的套件並快速增加中

  • 目前最新版本為 4.x,支援 React 16(Fiber)

  • 官方 Repo 有提供大量的與第三方套件整合的範例程式

First Next.js App

{
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^4.1.4",
    "react": "^16.0.0",
    "react-dom": "^16.0.0"
  }
}
  • 安裝套件
    • yarn add react react-dom next
  • 建立 Page Component
    • 在根目錄中建立一個 pages 資料夾
    • 在其中建立 index.js,並 export default 一個 React Component
  • 設定 npm script
    • 如右方 package.json 範例
  • 運行 Next Server
  • React 在 Next 中被自動全域引入,因此不需手動 require 或 import
export default () => (
  <div>Hello Next</div>
)

Router

File System Routing & Page

  • 在 Next 的架構中,一條 Route 就對應到一個 Page Component
  • Page Component 必須放置在根目錄的 pages 資料夾裡面
  • 根據 pages 資料夾中的檔案路逕自動映射出 Route 設定,並同時適用於前端與後端 Routing,無需另外手動設定
    • pages/index.js => /
    • pages/about.js => /about
    • pages/foo/bar.js => /foo/bar

<Link>

//index.js

import Link from 'next/link'

export default () => (
  <div>
    <Link href="/about?name=Zet">
      <a>about page</a>
    </Link>

    <Link href={{ 
      pathname: '/about',
      query: { name: 'Zet' } 
    }}>
      <a>about page</a>
    </Link>

    <Link href="/foo/bar">
      <button>bar page</button>
    </Link>
  </div>
)
  • 前端的 Routing 跳轉可以透過 Next 內建的 <Link> 組件來定義
  • 目標路由用 href 屬性指定
  • Next 的 <Link> 並不會產生 <a> 元素。若需要則必須手動在內部包一層 <a>渲染時會自動將 href 屬性加上去
  • href 屬性支援 Object 格式,會自動產生加上 Query String 的 URL
  • <Link> 由於跟 <a> 解藕,可以當作任何元素純粹的 onClick 跳轉效果替代品

Query String

// about.js

export default (props) => {
  return (
    <div>
      <h2>about page</h2>
      <p>Hi, I'm {props.url.query.name}.</p>
    </div>
  )
}
  • 所有的 Page Component 都會自動接收到一個名為 url 的 Props,其中 query 屬性能夠拿到當前頁面 Parse 過的 Query Object

命令式 Routing 跳轉

import Link from 'next/link'
import Router from 'next/router'

export default () => (
  <div>
    <Link href="/foo/bar">
      <button>bar page</button>
    </Link>

    <button onClick={() => Router.push('/foo/bar')}>
      bar page
    </button>

    <button onClick={() => Router.push({ 
      pathname: '/about',
      query: { name: 'Zet' } 
    })}>
      about page
    </button>
  </div>
)
  • 前端的 Routing 跳轉除了 <Link> 組件之外,也可以使用命令式的 API 來呼叫
    • 使用 Router 中的 push 方法來跳轉頁面
    • 可以接受 URL 或是 Object 格式作為參數

next-routes

  • Next 內建的 File System Router 算是底層的 API,除了直接使用之外也能透過封裝疊加出自己想要的 Router 功能
  • next-routes
    • 自定義 Route URL
    • Route 參數
    • 允許動態添加 Route
    • Request handler middleware for express

next-routes

// routes.js

const routes = module.exports = require('next-routes')()

routes
.add('index', '/', 'IndexPage')
.add('about', '/about/:name', 'AboutPage')
.add('BarPage', '/foo/bar', 'BarPage')
// IndexPage.js

import { Link, Router } from '../routes'

export default () => (
  <div>
    <Link route="about" params={{ name: 'Zet' }}>
      <a>about page</a>
    </Link>

    <button onClick={() => Router.pushRoute('about', { name: 'Zet' })}>
      about page
    </button>
  </div>
)

開發環境與設定擴充

Custom Server

// server.js
const next = require('next')
const routes = require('./routes')
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handler = routes.getRequestHandler(app);

// With express
const express = require('express');
app.prepare().then(() => {
  express().use(handler).listen(3000);
});
  • 允許自定義啟動 Next 的 Server
    • 用 Node 內建的 http 模組
    • 搭配 ExpressKoa 使用
  • 可以用於自定義 Route 攔截處理或是修改 Request & Response
  • 例如 next-routes 需要 Custom Server 的來引入自定義 Route
// package.json

{
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  }
}

HMR(Hot Reload)

  • Next 已內建完善而強大的 HMR 機制,開發中當程式碼發生修改時,將自動動態的插拔以更新瀏覽器畫面內容

    • 預設已內建並啟用,無須另外做任何設定

    • 發生了無法 HMR 的程式修改時,自動重新整理頁面

Code Splitting

  • Next 已內建 Code Splitting 設定,將每一個 page 切成獨立的 .js bundle 檔,並在首次進入該頁面時才加載,以最佳化頁面的首次載入速度與體驗

    • 預設已內建並啟用,無須另外做任何設定

    • 在 dev 模式時,首次進入該頁面時才會進行 Compile

    • 在 Production 模式可以按需預先加載指定的 Page 程式碼(Prefetch

Static File Serving

  • 根目錄底下建立 static 資料夾
    • 其中的檔案路徑將會自動被映射並 serve 到 /static 網址
export default () => (
  <div>
    <h1>Hello Firedoge</h1>
    <img src="/static/images/firedoge.jpg"/>
  </div>
)

Babel

  • 預設情況下 Next 已有內建的 Babel 設定
  • 可以在根目錄建立 .babelrc 檔案來覆寫 Babel 設定
  • 由於是覆寫,因此在 .babelrc 中必須要有 next/babel 這個 Next 內建的 Preset
  • 內建 Preset 的內容可以參考此處
// .babelrc

{
  "presets": [
    "next/babel",
    "stage-0"
  ]
}

推薦 Babel Plugin

// .babelrc

{
  "presets": [
    "next/babel",
    "stage-0"
  ],
  
  "plugins": [
    ["provide-modules", {
      "fetch": "isomorphic-fetch",
      "lodash": "_"
    }],

    ["transform-define", "./config/env.js"],

    ["module-resolver", {
      "root": ["./", "./src"]
    }]
  ]
}
// config/env.js

const prod = process.env.NODE_ENV === 'production'

module.exports = {
  'PRODUCTION': prod,
  'API_DOMAIN': 'http://localhost:3000'
}

Webpack

  • 預設情況下 Next 已有內建的 Webpack 設定
  • 在根目錄建立 next.config.js,並於其中定義一個函數來修改或擴充預設的設定
  • 這個 webpack 函數會傳入包含所有設定的 config object,並回傳新的 config
  • 不建議加上支援額外檔案格式的 Loader(例:css、scss、jpg)或是其他與模組系統相關的功能,因為僅有前端執行的程式碼會經由 Webpack 處理,而導致這些效果在 SSR 時的 Node 環境都無法正確運行。建議以相似效果的 Babel plugin 取代
// next.config.js

module.exports = {
  webpack: (config) => {
    config.module.rules = config.module.rules.map(rule => {
      if (rule.loader === 'babel-loader') {
        rule.options.cacheDirectory = false
      }
      return rule;
    });
    return config;
  }
}

Production Deploymnet

  • 部署 Next 應用程式為 Production 模式
    • next build:一次 build 好所有的頁面的程式碼,包含內建好的程式碼壓縮、混淆、Code Splitting,完全無須額外設定
    • next start:以 Production 模式執行 Next Server
      • 若為 Custom Server,加上環境變數執行之(例:NODE_ENV=production 
// package.json (without custom server)

{
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start"
  }
}
// package.json (with custom server)

{
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  }
}

<head>

  • 使用內建的 next/head 來動態管理 <head> 中的資料
  • 同時有效於 SSR
  • 可以調用於 Page Component 以內的任何地方
  • 當包含 <Head> 的 Component Unmount 時,會清除其產生的 head 資料
import Head from 'next/head'

export default () => (
  <div>
    <Head>
      <title>My page title</title>
    </Head>
    <p>Hello world!</p>
  </div>
)

Custom <Document>

  • Next 有預設的 HTML 模板(Page 外層的固定骨架)
  • 於 pages 資料夾中建立 _document.js 以自定義 Document
  • 這個 Class 不是一個 React Component,而僅用於定義 SSR 時的 HTML 外殼,
  • 與 React 前端無關,不能用於定義共用層的 Component
// pages/_document.js

import Document, { Head, Main, NextScript } from 'next/document'

export default class MyDocument extends Document {
  render() {
    return (
      <html>
        <Head>
          <title>Next.js</title>
        </Head>
        <body className="custom_class">
          <Main />
          <NextScript />
        </body>
      </html>
    )
  }
}

Styles

Styles with Isomorphic

  • 在 Next 中處理樣式引入大約能分成幾種方式
    • 透過 <link> 以 Static File 引入
      • 利用 Head 或 Document 以 Static File 普通的引入
      • 已存在的樣式或全域樣式,建議用此方法引入
    • 做一些特殊設定讓 import CSS 語法能夠在 SSR 時也不會出錯,並將結果以字串方式插入 <style>
    • 使用 CSS in JS 方案

styled-jsx

  • CSS in JS 方案,專門為了 React JSX 所設計
  • 已內建於 Next 中,亦可獨立使用
  • 以 Babel 做樣式 pre-build,效能良好
  • 自動 local scope 處理,亦可全域插入樣式
  • 支援配合 Sass / Less / PostCSS 使用
  • 支援動態樣式,根據 Props / State 反應樣式變化
  • 支援 SSR,且在 Next 無須任何額外設定
export default () => (
  <div>
    <p className="text">some text.</p>

    <style jsx>{`
      p.text {
        color: red;
      }
    `}</style>
  </div>
)

styled-jsx

/* styles.js */
import css from 'styled-jsx/css'

export const button = css`
  button { color: hotpink; }
`

export default css`
  div { color: green; }
`
import styles, { button } from './styles'

export default () => (
  <div>
    <button>styled-jsx</button>
    <style jsx>{styles}</style>
    <style jsx>{button}</style>
  </div>
)
  • 樣式重用與分離
export default () => (
  <div>
    <style jsx global>{`
      body {
        background: red
      }
    `}</style>
  </div>
)
  • 全域樣式
const Button = (props) => (
  <button>
     {props.children}
     <style jsx>{`
        button {
          padding: ${'large' in props ? '50' : '20'}px;
          background: ${props.backgroundColor};
          color: #999;
        }
     `}</style>
  </button>
)
  • 動態樣式

styled-components

  • 完全的 CSS in JS
    • 完全在 JavaScript 中撰寫 CSS 樣式程式碼,因此不需要像是 SASS 或 PostCSS 等 Processor,直接使用 JavaScript 的邏輯能力來組織樣式即可
  • 使用字串的方式來撰寫樣式語法:
    • 與原本的 CSS 開發體驗接近,不需要再用駝峰式屬性名
  • 會產生真正的 CSS 程式碼,支援所有 CSS 特性
    • 在 JavaScript 中撰寫的樣式最後會被產生成真正的 CSS 程式碼並打進 <head> 中,而非半殘且效能低落的 inline-style,因此可以使用 Media Query 或偽元素等完整的 CSS 功能
  • 自動處理命名作用域
    • 產生出來的樣式的皆會自動產生隨機的對應名,避免 CSS 全域樣式命名衝突的問題
class App extends React.PureComponent {
  render() {
    return (
      <div>
        <Button>Button</Button>
      </div>
    )
  }
}

const bgColor = '#009688';

const Button = styled.button`
  color: #FFF;
  background-color: ${bgColor};
  padding: 8px;
  border: 1px solid #DDD;
  border-radius: 10px;
`

styled-components

  • 樣式即組件
    • styled-components 將樣式直接依附於產生出的組件上面,因此能夠更有效的管理組件的樣式依賴,也能透過重用組件的方式來重用樣式,或是將樣式從組件上複製出來並產生其他組件
    • 傳送給 styled 產生出的組件的 Props,都會原封不動得轉傳給內部的 HTML Tag 類型的元素
    • 樣式產生出來的隨機 className 會自動的附在組件上,無需自行手動填寫
class App extends React.PureComponent {
  handleClick = () => {
    alert('clicked!');
  }
  render() {
    return (
      <div>
        <Button onClick={this.handleClick}>Click me</Button>
      </div>
    )
  }
}

const Button = styled.button`
  color: #FFF;
  background-color: #009688;
  padding: 8px;
  border: 1px solid #DDD;
  border-radius: 10px;
`

styled-components

  • 支援巢狀結構語法
    • 支援如 SASS 般的巢狀結構語法,能夠方便的撰寫層級的樣式
const Button = styled.button`
  color: #FFF;
  background-color: #009688;
  padding: 8px;
  border: 1px solid #DDD;
  border-radius: 10px;

  &:hover {
    background-color: green;
  }
`

styled-components

  • 組件 Props 與樣式狀態變化映射
    • 由於樣式被封裝在高階組件上,因此可以很直覺的直接使用自定義的 Props 來將組件狀態映射到樣式的狀態變化上
    • 不再需要蹩腳的手動組 className
    • 在樣式中插入一個接收 Props 並回傳字串的函數
class App extends React.PureComponent {
  state = {
    active: true
  }
  handleClick = () => {
    this.setState({
      active: !this.state.active
    })
  }

  render() {
    return (
      <div>
        <Button 
          onClick={this.handleClick} 
          active={this.state.active}
        >
          Click me
        </Button>
      </div>
    )
  }
}

const Button = styled.button`
  color: #FFF;
  background-color: #009688;
  padding: 8px;
  border: 1px solid #DDD;
  border-radius: 10px;
  opacity: ${props => props.active ? '1' : '0.5'};
`

styled-components

  • Styled Everything
    • 經由 styled 方法所產出來的組件,可以再次傳入 styled 方法進行二度加工
    • 自行定義的組件或第三方套件的組件,只要其內部有進行 this.props.className 的接收的話,就可以傳入 styled 方法進行樣式添加
const Button = styled.button`
  color: #FFF;
  background-color: #009688;
  padding: 8px;
  border: 1px solid #DDD;
  border-radius: 10px;
  opacity: ${props => props.active ? '1' : '0.5'};

  &:hover {
    background-color: green;
  }
`

const RedTextButton = styled(Button)`
  color: red;
`
class ButtonText extends React.PureComponent {
  render() {
    return (
      <span 
        className={this.props.className}
      >
        {this.props.text}
      </span>
    )
  }
}

const BlueButtonText = styled(ButtonText)`
  color: blue;
`

styled-components

  • 支援 Server-Side Rendering
    • 藉由 Document 中做收集樣式的前置處理,並將其作為 <style> 標籤打進 Head 中,讓 SSR 結果的 HTML 中包含該頁樣式
// pages/_document.js

import Document, { Head, Main, NextScript } from 'next/document'
import { ServerStyleSheet } from 'styled-components'

export default class MyDocument extends Document {
  static getInitialProps ({ renderPage }) {
    const sheet = new ServerStyleSheet();
    const page = renderPage(App => props => {
      return sheet.collectStyles(<App {...props}/>);
    });
    const styleTags = sheet.getStyleElement();
    return {
      ...page, 
      styleTags
    };
  }

  render() {
    return (
      <html>
        <Head>
          {this.props.styleTags}
        </Head>
        <body>
          <Main />
          <NextScript/>
        </body>
      </html>
    )
  }
}
// .babelrc

{
  "presets": [
    "next/babel"
  ],
  
  "plugins": [
    ["styled-components", {
      "ssr": true
    }]
  ]
}

Dataflow

(以 Redux 為例)

getInitialProps

// pages/index.js

import React from 'react'
import fetch from 'isomorphic-fetch'

export default class IndexPage extends React.Component {
  static async getInitialProps() {
    const url = 'https://api.myjson.com/bins/6hdd7';
    const response = await fetch(url);
    const data = await response.json();
    return { data };
  }

  render() {
    return (
      <div>
        {this.props.data.map(item => (
          <div>{item}</div>
        ))}
      </div>
    )
  }
}
  • 所有頂層 Page Component 都擁有 getInitialProps 這個特殊的生命週期,其餘普通 Component 則無此生命週期
  • 其為靜態方法,並且是 async function,可以在 Page Component 開始被實例化之前進行非同步操作的阻塞
  • 可以用於一些需要等待的頁面初始化處理,例如:請求 API 資料
  • 此方法的物件回傳值會與 Page Component 的實例 Props 合併

getInitialProps

  • getInitialProps 在下列兩種情況時會被執行到:
    • SSR 時:在後端執行 getInitialProps 到然後完成 SSR,抵達前端後不會再度觸發
    • 前端切頁時:在前端切到此頁面時先執行 getInitialProps,完成後才切換頁面

SSR:

在後端觸發

static getInitialProps()

constructor()

render()

(HTML)

抵達瀏覽器

前端不會觸發 getInitialProps

前端切頁:

在前端觸發

static getInitialProps()

constructor()

render()

(DOM)

其他頁面的 SSR

前端 Routing 切到本頁

getInitialProps

static async getInitialProps({ req, query }) {
  console.log('getInitialProps', (req) ? 'server' : 'client');

  const response = await fetch(`${API_DOMAIN}/api/news/list/${query.page}`);
  const data = await response.json();
  return { data };
}
  • getInitialProps 方法會接收一個 context object 參數,其中較常用到的屬性:
    • query:Route 網址的參數
    • req:Next Server 的 Request 物件(SSR 時才有)
    • res:Next Server 的 Response 物件(SSR 時才有
  • Redux 在 Isomorphic 架構中的運行流程大致如下:
    1. 建立後端 Store:SSR 開始時,在後端為此 Request 對應的此頁建立一個新的 Redux Store
    2. Action 預處理:在 SSR 正式開始 Render 前,完成此頁某些想在後端先做好的 Redux Action
    3. Render 並脫水:Action 完成後才開始進行 Render(阻塞非同步 Action),並且將後端的這個 Store 中的 State 轉換成純 JSON 格式的字串,附在 SSR 結果的 HTML 之中,結束 SSR 
    4. 覆水並 Render:抵達瀏覽器,前端利用 SSR 結果 HTML 中附的 JSON 資料當作 InitialState,建立前端環境的 Store,並進行前端的 Render
  • 若為前端切頁的話,則繼續沿用原本存在於前頁的 Store,不建立新的,也不會進行前述流程

後端

在後端為此頁建立一個新的 Redux Store

發起 Redux Action

render()

(HTML)

SSR 結束前將後端 Store 中的 State 資料轉成 JSON,附在回傳結果的 HTML中

前端

前端利用 HTML 中附的 JSON 資料當作 InitialState,建立前端環境的 Store

前端切頁時沿用前頁的 Store,不建立新的

Redux with Isomorphic

render()

(DOM)

新的 Request 發生

 SSR 流程開始

設定 Redux 環境

// config/store.js

import { createStore, combineReducers, applyMiddleware } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunkMiddleware from 'redux-thunk'
import withRedux from 'next-redux-wrapper'

import CounterReducer from 'Counter/redux/CounterReducer'

export const initStore = (initialState = {}) => createStore(
  combineReducers({
    counter: CounterReducer
  }), 
  initialState,
  composeWithDevTools(applyMiddleware(thunkMiddleware))
)

export default withRedux(initStore);
// pages/CounterPage.js

import reduxPage from 'config/store'
import CounterContainer from 'Counter/containers/CounterContainer'

class CounterPage extends React.Component {
  render() {
    return (
      <CounterContainer/>
    )
  }
}

export default reduxPage(CounterPage)

設定 Redux 環境

import reduxPage from 'config/store'
import CounterContainer from 'Counter/containers/CounterContainer'
import CounterAction from 'Counter/redux/CounterAction'

class CounterPage extends React.Component {
  static getInitialProps({ store, isServer }) {
    if (isServer) {
      store.dispatch(CounterAction.increment());
      store.dispatch(CounterAction.increment());
    }
  }

  render() {
    return (
      <CounterContainer/>
    )
  }
}

export default reduxPage(CounterPage)
  • 以 next-redux-wrapper 包裝過的 Page Component,其 getInitialProps 的 context 參數會多出兩種屬性,以供開發者進行 Render 開始前的 Action 呼叫
    • store:本頁面的 Redux Store 實例
    • isServer:true 代表當前為 SSR 環境,false 則為瀏覽器環境

Async Action

// NewsListPage.js

import reduxPage from 'config/store'
import NewsListContainer from 'NewsList/containers/NewsListContainer'
import NewsListAction from 'NewsList/redux/NewsListAction'
const { fetchNewsList } = NewsListAction;

class NewsListPage extends React.Component {
  static async getInitialProps({ store, isServer, query }) {
    if (isServer) {
       await store.dispatch(fetchNewsList(query.page));
    } else {
      store.dispatch(fetchNewsList(query.page));
    }
  }

  render() {
    return (
      <NewsListContainer/>
    )
  }
}
export default reduxPage(NewsListPage)
  • 以 redux-thunk 為例
    • SSR 時 await 來阻塞 Async Action
    • 前端切頁時發起 Action 但不阻塞
// NewsListAction.js

export default {
  fetchNewsList: (page) => async(dispatch) => {
    dispatch({ type: FETCH_NEWS_LIST.REQUEST });
    try {
      const url = `${API_DOMAIN}/api/news/list/${page}`;
      const response = await fetch(url);
      const data = await response.json();
      dispatch({ 
        type: FETCH_NEWS_LIST.SUCCESS, 
        payload: { data }
      });
    } catch (error) {
      dispatch({ 
        type: FETCH_NEWS_LIST.ERROR,
        payload: { error }
      });
    }
  }
}

Shared Component

Shared Component

  • 在 Next 中,每一個畫面的最頂層 Component 就是 Page Component,而沒有全部頁面的共用層可供定義
  • 可以透過不同頁面包裹相同的外層 Component 來達到「視覺上」的共用
  • 建議做成 Higher-Order Component 來進行包裹與重用
// Layout.js

import reduxPage from 'config/store'

export default (WrappedComponent) => {
  class Layout extends React.Component {
    render() {
      return (
        <div>
          fixed content
          <WrappedComponent {...this.props}/>
        </div>
      )
    }
  }

  Layout.getInitialProps = WrappedComponent.getInitialProps;
  return reduxPage(Layout);
}
// CounterPage.js

import wrapWithLayout from 'src/Layout'
import CounterContainer from 'Counter/containers/CounterContainer'
import CounterAction from 'Counter/redux/CounterAction'

class CounterPage extends React.Component {
  render() {
    return (
      <CounterContainer/>
    )
  }
}

export default wrapWithLayout(CounterPage)

Shared Component

  • 這種做法不是真正的共用,而是整個頁面被替換掉,但兩個 Page 之間有部分長得一樣,所以「畫面結果看起來像共用」
  • 應避免在共用的 Component 中使用 local state(this.state),以免在切換頁面時因為生命週期重來而導致狀態丟失
  • 若有資料狀態的需要,建議將資料放入 Redux 這種獨立的資料容器
  • 真正的 Shared Component 共用層在 Next 的社群中有很多的討論,相信未來不久後也會被實作

總結

  • 封裝大量 Isomorphic 的麻煩流程與環境,讓你專注於開發 Business logic 
  • 極簡設定需求,但保持可自定義與擴充的彈性,你值得擁有
  • 建議新專案導入,舊有專案若複雜且重構不易的話,建議採用 Prerender 等方案部分解決 SSR
  • 社群資源與能量強大,未來性可期

Q & A

Thanks! 

Ultra Simple Isomorphic with Next.js

By tz5514

Ultra Simple Isomorphic with Next.js

  • 5,304