手把手帶你用next搭建一個完善的react服務端渲染項目(集成antd、redux、樣式解決方案)

  • 2020 年 4 月 10 日
  • 筆記

前言

本文參考了慕課網jokcy老師的React16.8+Next.js+Koa2開發Github全棧項目,也算是做個筆記吧。

源碼地址

github.com/sl1673495/n…

介紹

Next.js 是一個輕量級的 React 服務端渲染應用框架。

官網:nextjs.org 中文官網:nextjs.frontendx.cn

當使用 React 開發系統的時候,常常需要配置很多繁瑣的參數,如 Webpack 配置、Router 配置和服務器配置等。如果需要做 SEO,要考慮的事情就更多了,怎麼讓服務端渲染和客戶端渲染保持一致是一件很麻煩的事情,需要引入很多第三方庫。針對這些問題,Next.js提供了一個很好的解決方案,使開發人員可以將精力放在業務上,從繁瑣的配置中解放出來。下面我們一起來從零開始搭建一個完善的next項目。

項目的初始化

首先安裝 create-next-app 腳手架

npm i -g create-next-app  

然後利用腳手架建立 next 項目

create-next-app next-github  cd next-github  npm run dev  

可以看到 pages 文件夾下的 index.js

生成的目錄結構很簡單,我們稍微加幾個內容

├── README.md  ├── components // 非頁面級共用組件  │   └── nav.js  ├── package-lock.json  ├── package.json  ├── pages // 頁面級組件 會被解析成路由  │   └── index.js  ├── lib // 一些通用的js  ├── static // 靜態資源  │   └── favicon.ico    

啟動項目之後,默認端口啟動在 3000 端口,打開 localhost:3000 後,默認訪問的就是 index.js 里的內容

把 next 作為 Koa 的中間件使用。(可選)

如果要集成koa的話,可以參考這一段。 在根目錄新建 server.js 文件

// server.js    const Koa = require('koa')  const Router = require('koa-router')  const next = require('next')    const dev = process.env.NODE_ENV !== 'production'  const app = next({ dev })  const handle = app.getRequestHandler()    const PORT = 3001  // 等到pages目錄編譯完成後啟動服務響應請求  app.prepare().then(() => {    const server = new Koa()    const router = new Router()      server.use(async (ctx, next) => {      await handle(ctx.req, ctx.res)      ctx.respond = false    })      server.listen(PORT, () => {      console.log(`koa server listening on ${PORT}`)    })  })  複製代碼

然後把package.json中的dev命令改掉

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

ctx.reqctx.res 是 node 原生提供的

之所以要傳遞 ctx.reqctx.res,是因為 next 並不只是兼容 koa 這個框架,所以需要傳遞 node 原生提供的 reqres

集成 css

next 中默認不支持直接 import css 文件,它默認為我們提供了一種 css in js 的方案,所以我們要自己加入 next 的插件包進行 css 支持

yarn add @zeit/next-css  

如果項目根目錄下沒有的話 我們新建一個next.config.js 然後加入如下代碼

const withCss = require('@zeit/next-css')    if (typeof require !== 'undefined') {    require.extensions['.css'] = file => {}  }    // withCss得到的是一個next的config配置  module.exports = withCss({})  複製代碼

集成 ant-design

yarn add antd  yarn add babel-plugin-import // 按需加載插件  

在根目錄下新建.babelrc文件

{    "presets": ["next/babel"],    "plugins": [      [        "import",        {          "libraryName": "antd"        }      ]    ]  }  

這個 babel 插件的作用是把

import { Button } from 'antd'  

解析成

import Button from 'antd/lib/button'  

這樣就完成了按需引入組件

在 pages 文件夾下新建_app.js,這是 next 提供的讓你重寫 App 組件的方式,在這裡我們可以引入 antd 的樣式

pages/_app.js

import App from 'next/app'    import 'antd/dist/antd.css'    export default App  

next 中的路由

利用Link組件進行跳轉

import Link from 'next/link'  import { Button } from 'antd'    const LinkTest = () => (    <div>      <Link href="/a">        <Button>跳轉到a頁面</Button>      </Link>    </div>  )    export default LinkTest  複製代碼

利用Router模塊進行跳轉

import Link from 'next/link'  import Router from 'next/router'  import { Button } from 'antd'    export default () => {    const goB = () => {      Router.push('/b')    }      return (      <>        <Link href="/a">          <Button>跳轉到a頁面</Button>        </Link>        <Button onClick={goB}>跳轉到b頁面</Button>      </>    )  }  複製代碼

動態路由

在 next 中,只能通過query來實現動態路由,不支持/b/:id 這樣的定義方法

首頁

import Link from 'next/link'  import Router from 'next/router'  import { Button } from 'antd'    export default () => {    const goB = () => {      Router.push('/b?id=2')      // 或      Router.push({        pathname: '/b',        query: {          id: 2,        },      })    }      return <Button onClick={goB}>跳轉到b頁面</Button>  }  複製代碼

B 頁面

import { withRouter } from 'next/router'    const B = ({ router }) => <span>這是B頁面, 參數是{router.query.id}</span>  export default withRouter(B)  

此時跳轉到 b 頁面的路徑是/b?id=2

如果真的想顯示成/b/2這種形式的話, 也可以通過Link上的as屬性來實現

<Link href="/a?id=1" as="/a/1">    <Button>跳轉到a頁面</Button>  </Link>  

或在使用Router

Router.push(    {      pathname: '/b',      query: {        id: 2,      },    },    '/b/2'  )  

但是使用這種方法,在頁面刷新的時候會 404 是因為這種別名的方法只是在前端路由跳轉的時候加上的 刷新時請求走了服務端就認不得這個路由了

使用 koa 可以解決這個問題

// server.js    const Koa = require('koa')  const Router = require('koa-router')  const next = require('next')    const dev = process.env.NODE_ENV !== 'production'  const app = next({ dev })  const handle = app.getRequestHandler()    const PORT = 3001  // 等到pages目錄編譯完成後啟動服務響應請求  app.prepare().then(() => {    const server = new Koa()    const router = new Router()      // start    // 利用koa-router去把/a/1這種格式的路由    // 代理到/a?id=1去,這樣就不會404了    router.get('/a/:id', async ctx => {      const id = ctx.params.id      await handle(ctx.req, ctx.res, {        pathname: '/a',        query: {          id,        },      })      ctx.respond = false    })    server.use(router.routes())    // end      server.use(async (ctx, next) => {      await handle(ctx.req, ctx.res)      ctx.respond = false    })      server.listen(PORT, () => {      console.log(`koa server listening on ${PORT}`)    })  })  複製代碼

Router 的鉤子

在一次路由跳轉中,先後會觸發 routeChangeStart beforeHistoryChange routeChangeComplete

如果有錯誤的話,則會觸發 routeChangeError

監聽的方式是

Router.events.on(eventName, callback)  

自定義 document

  • 只有在服務端渲染的時候才會被調用
  • 用來修改服務端渲染的文檔內容
  • 一般用來配合第三方 css in js 方案使用

在 pages 下新建_document.js,我們可以根據需求去重寫。

import Document, { Html, Head, Main, NextScript } from 'next/document'    export default class MyDocument extends Document {    // 如果要重寫render 就必須按照這個結構來寫    render() {      return (        <Html>          <Head>            <title>ssh-next-github</title>          </Head>          <body>            <Main />            <NextScript />          </body>        </Html>      )    }  }  複製代碼

自定義 app

next 中,pages/_app.js 這個文件中暴露出的組件會作為一個全局的包裹組件,會被包在每一個頁面組件的外層,我們可以用它來

  • 固定 Layout
  • 保持一些共用的狀態
  • 給頁面傳入一些自定義數據 pages/_app.js

給個簡單的例子,先別改_app.js 里的代碼,否則接下來 getInitialProps 就獲取不到數據了,這個後面再處理。

import App, { Container } from 'next/app'  import 'antd/dist/antd.css'  import React from 'react'    export default class MyApp extends App {    render() {      // Component就是我們要包裹的頁面組件      const { Component } = this.props      return (        <Container>          <Component />        </Container>      )    }  }  複製代碼

封裝 getInitialProps

getInitialProps 的作用非常強大,它可以幫助我們同步服務端和客戶端的數據,我們應該盡量把數據獲取的邏輯放在 getInitialProps 里,它可以:

  • 在頁面中獲取數據
  • 在 App 中獲取全局數據

基本使用

通過 getInitialProps 這個靜態方法返回的值 都會被當做 props 傳入組件

const A = ({ name }) => (    <span>這是A頁面, 通過getInitialProps獲得的name是{name}</span>  )    A.getInitialProps = () => {    return {      name: 'ssh',    }  }  export default A  複製代碼

但是需要注意的是,只有 pages 文件夾下的組件(頁面級組件)才會調用這個方法。next 會在路由切換前去幫你調用這個方法,這個方法在服務端渲染和客戶端渲染都會執行。(刷新前端跳轉) 並且如果服務端渲染已經執行過了,在進行客戶端渲染時就不會再幫你執行了。

異步場景

異步場景可以通過 async await 來解決,next 會等到異步處理完畢 返回了結果後以後再去渲染頁面

const A = ({ name }) => (    <span>這是A頁面, 通過getInitialProps獲得的name是{name}</span>  )    A.getInitialProps = async () => {    const result = Promise.resolve({ name: 'ssh' })    await new Promise(resolve => setTimeout(resolve, 1000))    return result  }  export default A  複製代碼

在_app.js 里獲取數據

我們重寫一些_app.js 里獲取數據的邏輯

import App, { Container } from 'next/app'  import 'antd/dist/antd.css'  import React from 'react'    export default class MyApp extends App {    // App組件的getInitialProps比較特殊    // 能拿到一些額外的參數    // Component: 被包裹的組件    static async getInitialProps(ctx) {      const { Component } = ctx      let pageProps = {}        // 拿到Component上定義的getInitialProps      if (Component.getInitialProps) {        // 執行拿到返回結果        pageProps = await Component.getInitialProps(ctx)      }        // 返回給組件      return {        pageProps,      }    }      render() {      const { Component, pageProps } = this.props      return (        <Container>          {/* 把pageProps解構後傳遞給組件 */}          <Component {...pageProps} />        </Container>      )    }  }  複製代碼

封裝通用 Layout

我們希望每個頁面跳轉以後,都可以有共同的頭部導航欄,這就可以利用_app.js 來做了。

在 components 文件夾下新建 Layout.jsx:

import Link from 'next/link'  import { Button } from 'antd'    export default ({ children }) => (    <header>      <Link href="/a">        <Button>跳轉到a頁面</Button>      </Link>      <Link href="/b">        <Button>跳轉到b頁面</Button>      </Link>      <section className="container">{children}</section>    </header>  )  複製代碼

在_app.js 里

// 省略  import Layout from '../components/Layout'    export default class MyApp extends App {    // 省略      render() {      const { Component, pageProps } = this.props      return (        <Container>          {/* Layout包在外面 */}          <Layout>            {/* 把pageProps解構後傳遞給組件 */}            <Component {...pageProps} />          </Layout>        </Container>      )    }  }  複製代碼

document title 的解決方案

例如在 pages/a.js 這個頁面中,我希望網頁的 title 是 a,在 b 頁面中我希望 title 是 b,這個功能 next 也給我們提供了方案

pages/a.js

import Head from 'next/head'    const A = ({ name }) => (    <>      <Head>        <title>A</title>      </Head>      <span>這是A頁面, 通過getInitialProps獲得的name是{name}</span>    </>  )    export default A  複製代碼

樣式的解決方案(css in js)

next 默認採用的是 styled-jsx 這個庫 github.com/zeit/styled…

需要注意的點是:組件內部的 style 標籤,只有在組件渲染後才會被加到 head 里生效,組件銷毀後樣式就失效。

組件內部樣式

next 默認提供了樣式的解決方案,在組件內部寫的話默認的作用域就是該組件,寫法如下:

const A = ({ name }) => (    <>      <span className="link">這是A頁面</span>      <style jsx>        {`          .link {            color: red;          }        `}      </style>    </>  )    export default A  )  複製代碼

我們可以看到生成的 span 標籤變成了

<span class="jsx-3081729934 link">這是A頁面</span>  

生效的 css 樣式變成了

.link.jsx-3081729934 {    color: red;  }  

通過這種方式做到了組件級別的樣式隔離,並且 link 這個 class 假如在全局有定義樣式的話,也一樣可以得到樣式。

全局樣式

<style jsx global>    {`      .link {        color: red;      }    `}  </style>  

樣式的解決方案(styled-component)

首先安裝依賴

yarn add styled-components babel-plugin-styled-components  

然後我們在.babelrc 中加入 plugin

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

在 pages/_document.js 里加入 jsx 的支持,這裡用到了 next 給我們提供的一個覆寫 app 的方法,其實就是利用高階組件。

import Document, { Html, Head, Main, NextScript } from 'next/document'  import { ServerStyleSheet } from 'styled-components'    export default class MyDocument extends Document {    static async getInitialProps(ctx) {      const sheet = new ServerStyleSheet()      // 劫持原本的renderPage函數並重寫      const originalRenderPage = ctx.renderPage        try {        ctx.renderPage = () =>          originalRenderPage({            // 根App組件            enhanceApp: App => props => sheet.collectStyles(<App {...props} />),          })        // 如果重寫了getInitialProps 就要把這段邏輯重新實現        const props = await Document.getInitialProps(ctx)        return {          ...props,          styles: (            <>              {props.styles}              {sheet.getStyleElement()}            </>          ),        }      } finally {        sheet.seal()      }    }      // 如果要重寫render 就必須按照這個結構來寫    render() {      return (        <Html>          <Head />          <body>            <Main />            <NextScript />          </body>        </Html>      )    }  }  複製代碼

然後在 pages/a.js 中

import styled from 'styled-components'    const Title = styled.h1`    color: yellow;    font-size: 40px;  `  const A = ({ name }) => (    <>      <Title>這是A頁面</Title>    </>  )    export default A  複製代碼

next 中的 LazyLoading

next 中默認幫我們開啟了 LazyLoading,切換到對應路由才會去加載對應的 js 模塊。

LazyLoading 一般分為兩類

  • 異步加載模塊
  • 異步加載組件

首先我們利用 moment 這個庫演示一下異步加載模塊的展示。

異步加載模塊

我們在 a 頁面中引入 moment 模塊 // pages/a.js

import styled from 'styled-components'  import moment from 'moment'    const Title = styled.h1`    color: yellow;    font-size: 40px;  `  const A = ({ name }) => {    const time = moment(Date.now() - 60 * 1000).fromNow()    return (      <>        <Title>這是A頁面, 時間差是{time}</Title>      </>    )  }    export default A  複製代碼

這會帶來一個問題,如果我們在多個頁面中都引入了 moment,這個模塊默認會被提取到打包後的公共的 vendor.js 里。

我們可以利用 webpack 的動態 import 語法

A.getInitialProps = async ctx => {    const moment = await import('moment')    const timeDiff = moment.default(Date.now() - 60 * 1000).fromNow()    return { timeDiff }  }  

這樣只有在進入了 A 頁面以後,才會下載 moment 的代碼。

異步加載組件

next 官方為我們提供了一個dynamic方法,使用示例:

import dynamic from 'next/dynamic'    const Comp = dynamic(import('../components/Comp'))    const A = ({ name, timeDiff }) => {    return (      <>        <Comp />      </>    )  }    export default A    

使用這種方式引入普通的 react 組件,這個組件的代碼就只會在 A 頁面進入後才會被下載。

next.config.js 完整配置

next 回去讀取根目錄下的next.config.js文件,每一項都用注釋標明了,可以根據自己的需求來使用。

const withCss = require('@zeit/next-css')    const configs = {    // 輸出目錄    distDir: 'dest',    // 是否每個路由生成Etag    generateEtags: true,    // 本地開發時對頁面內容的緩存    onDemandEntries: {      // 內容在內存中緩存的時長(ms)      maxInactiveAge: 25 * 1000,      // 同時緩存的頁面數      pagesBufferLength: 2,    },    // 在pages目錄下會被當做頁面解析的後綴    pageExtensions: ['jsx', 'js'],    // 配置buildId    generateBuildId: async () => {      if (process.env.YOUR_BUILD_ID) {        return process.env.YOUR_BUILD_ID      }        // 返回null默認的 unique id      return null    },    // 手動修改webpack配置    webpack(config, options) {      return config    },    // 手動修改webpackDevMiddleware配置    webpackDevMiddleware(config) {      return config    },    // 可以在頁面上通過process.env.customkey 獲取 value    env: {      customkey: 'value',    },    // 下面兩個要通過 'next/config' 來讀取    // 可以在頁面上通過引入 import getConfig from 'next/config'來讀取      // 只有在服務端渲染時才會獲取的配置    serverRuntimeConfig: {      mySecret: 'secret',      secondSecret: process.env.SECOND_SECRET,    },    // 在服務端渲染和客戶端渲染都可獲取的配置    publicRuntimeConfig: {      staticFolder: '/static',    },  }    if (typeof require !== 'undefined') {    require.extensions['.css'] = file => {}  }    // withCss得到的是一個nextjs的config配置  module.exports = withCss(configs)  複製代碼

ssr 流程

next 幫我們解決了 getInitialProps 在客戶端和服務端同步的問題,

next 會把服務端渲染時候得到的數據通過NEXT_DATA這個 key 注入到 html 頁面中去。

比如我們之前舉例的 a 頁面中,大概是這樣的格式

script id="__NEXT_DATA__" type="application/json">        {          "dataManager":"[]",          "props":            {              "pageProps":{"timeDiff":"a minute ago"}            },          "page":"/a",          "query":{},          "buildId":"development",          "dynamicBuildId":false,          "dynamicIds":["./components/Comp.jsx"]        }        </script>  

引入 redux (客戶端普通寫法)

yarn add redux

在根目錄下新建 store/store.js 文件

// store.js

import { createStore, applyMiddleware } from 'redux'  import ReduxThunk from 'redux-thunk'    const initialState = {    count: 0,  }    function reducer(state = initialState, action) {    switch (action.type) {      case 'add':        return {          count: state.count + 1,        }        break        default:        return state    }  }    // 這裡暴露出的是創建store的工廠方法  // 每次渲染都需要重新創建一個store實例  // 防止服務端一直復用舊實例 無法和客戶端狀態同步  export default function initializeStore() {    const store = createStore(reducer, initialState, applyMiddleware(ReduxThunk))    return store  }  複製代碼

引入 react-redux

yarn add react-redux 然後在_app.js 中用這個庫提供的 Provider 包裹在組件的外層 並且傳入你定義的 store

import { Provider } from 'react-redux'  import initializeStore from '../store/store'    ...  render() {      const { Component, pageProps } = this.props      return (        <Container>          <Layout>            <Provider store={initializeStore()}>              {/* 把pageProps解構後傳遞給組件 */}              <Component {...pageProps} />            </Provider>          </Layout>        </Container>      )    }    複製代碼

在組件內部

import { connect } from 'react-redux'    const Index = ({ count, add }) => {    return (      <>        <span>首頁 state的count是{count}</span>        <button onClick={add}>增加</button>      </>    )  }    function mapStateToProps(state) {    const { count } = state    return {      count,    }  }    function mapDispatchToProps(dispatch) {    return {      add() {        dispatch({ type: 'add' })      },    }  }  export default connect(    mapStateToProps,    mapDispatchToProps  )(Index)  複製代碼

利用 hoc 集成 redux 和 next

在上面 引入 redux (客戶端普通寫法) 介紹中,我們簡單的和平常一樣去引入了 store,但是這種方式在我們使用 next 做服務端渲染的時候有個很嚴重的問題,假如我們在 Index 組件的 getInitialProps 中這樣寫

Index.getInitialProps = async ({ reduxStore }) => {    store.dispatch({ type: 'add' })    return {}  }  

進入 index 頁面以後就會報一個錯誤

Text content did not match. Server: "1" Client: "0"  

並且你每次刷新 這個 Server 後面的值都會加 1,這意味着如果多個瀏覽器同時訪問,store里的count就會一直遞增,這是很嚴重的 bug。

這段報錯的意思就是服務端的狀態和客戶端的狀態不一致了,服務端拿到的count是 1,但是客戶端的count卻是 0,其實根本原因就是服務端解析了 store.js 文件以後拿到的 store和客戶端拿到的 store 狀態不一致,其實在同構項目中,服務端和客戶端會持有各自不同的 store,並且在服務端啟動了的生命周期中 store 是保持同一份引用的,所以我們必須想辦法讓兩者狀態統一,並且和單頁應用中每次刷新以後store重新初始化這個行為要一致。在服務端解析過拿到 store 以後,直接讓客戶端用服務端解析的值來初始化 store。

總結一下,我們的目標有:

  • 每次請求服務端的時候(頁面初次進入,頁面刷新),store 重新創建。
  • 前端路由跳轉的時候,store 復用之前創建好的。
  • 這種判斷不能寫在每個組件的 getInitialProps 里,想辦法抽象出來。

所以我們決定利用hoc來實現這個邏輯復用。

首先我們改造一下 store/store.js,不再直接暴露出 store 對象,而是暴露一個創建 store 的方法,並且允許傳入初始狀態來進行初始化。

import { createStore, applyMiddleware } from 'redux'  import ReduxThunk from 'redux-thunk'    const initialState = {    count: 0,  }    function reducer(state = initialState, action) {    switch (action.type) {      case 'add':        return {          count: state.count + 1,        }        break        default:        return state    }  }    export default function initializeStore(state) {    const store = createStore(      reducer,      Object.assign({}, initialState, state),      applyMiddleware(ReduxThunk)    )    return store  }  複製代碼

在 lib 目錄下新建 with-redux-app.js,我們決定用這個 hoc 來包裹_app.js 里導出的組件,每次加載 app 都要通過我們這個 hoc。

import React from 'react'  import initializeStore from '../store/store'    const isServer = typeof window === 'undefined'  const __NEXT_REDUX_STORE__ = '__NEXT_REDUX_STORE__'    function getOrCreateStore(initialState) {    if (isServer) {      // 服務端每次執行都重新創建一個store      return initializeStore(initialState)    }    // 在客戶端執行這個方法的時候 優先返回window上已有的store    // 而不能每次執行都重新創建一個store 否則狀態就無限重置了    if (!window[__NEXT_REDUX_STORE__]) {      window[__NEXT_REDUX_STORE__] = initializeStore(initialState)    }    return window[__NEXT_REDUX_STORE__]  }    export default Comp => {    class withReduxApp extends React.Component {      constructor(props) {        super(props)        // getInitialProps創建了store 這裡為什麼又重新創建一次?        // 因為服務端執行了getInitialProps之後 返回給客戶端的是序列化後的字符串        // redux里有很多方法 不適合序列化存儲        // 所以選擇在getInitialProps返回initialReduxState初始的狀態        // 再在這裡通過initialReduxState去創建一個完整的store        this.reduxStore = getOrCreateStore(props.initialReduxState)      }        render() {        const { Component, pageProps, ...rest } = this.props        return (          <Comp            {...rest}            Component={Component}            pageProps={pageProps}            reduxStore={this.reduxStore}          />        )      }    }      // 這個其實是_app.js的getInitialProps    // 在服務端渲染和客戶端路由跳轉時會被執行    // 所以非常適合做redux-store的初始化    withReduxApp.getInitialProps = async ctx => {      const reduxStore = getOrCreateStore()      ctx.reduxStore = reduxStore        let appProps = {}      if (typeof Comp.getInitialProps === 'function') {        appProps = await Comp.getInitialProps(ctx)      }        return {        ...appProps,        initialReduxState: reduxStore.getState(),      }    }      return withReduxApp  }  複製代碼

在_app.js 中引入 hoc

import App, { Container } from 'next/app'  import 'antd/dist/antd.css'  import React from 'react'  import { Provider } from 'react-redux'  import Layout from '../components/Layout'  import initializeStore from '../store/store'  import withRedux from '../lib/with-redux-app'  class MyApp extends App {    // App組件的getInitialProps比較特殊    // 能拿到一些額外的參數    // Component: 被包裹的組件    static async getInitialProps(ctx) {      const { Component } = ctx      let pageProps = {}        // 拿到Component上定義的getInitialProps      if (Component.getInitialProps) {        // 執行拿到返回結果`        pageProps = await Component.getInitialProps(ctx)      }        // 返回給組件      return {        pageProps,      }    }      render() {      const { Component, pageProps, reduxStore } = this.props      return (        <Container>          <Layout>            <Provider store={reduxStore}>              {/* 把pageProps解構後傳遞給組件 */}              <Component {...pageProps} />            </Provider>          </Layout>        </Container>      )    }  }    export default withRedux(MyApp)  複製代碼

這樣,我們就實現了在 next 中集成 redux。