├── .babelrc ├── .gitignore ├── README.md ├── bundles └── client.html ├── components ├── Container.jsx ├── Layout.jsx ├── MarkdownRenderer.jsx ├── PageLoading.jsx ├── Repo.jsx ├── SearchUser.jsx ├── comp.jsx └── with-repo-basic.jsx ├── ecosystem.config.js ├── global.config.js ├── lib ├── api.js ├── my-context.js ├── repo-basic-cache.js ├── utils.js └── with-redux.js ├── next.config.js ├── package.json ├── pages ├── _app.js ├── detail │ ├── index.js │ └── issues.js ├── index.js └── search.js ├── register-oauth.png ├── return.html ├── server.js ├── server ├── api.js ├── auth.js └── session-store.js ├── static └── favicon.png └── store └── store.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | [ 5 | "import", 6 | { 7 | "libraryName": "antd" 8 | } 9 | ], 10 | ["styled-components", { "ssr": true }] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.js 3 | .next 4 | .idea 5 | dest 6 | .vscode 7 | local-code -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React-SSR-With-NextJs 2 | 3 | #### 1、首先需要安装下载 redis 4 | 5 | • 官方版本是不支持 Windows 系统的,微软自己实现了支持 win64 位系统的版本 6 | 7 | • [redis-windows](https://github.com/microsoftarchive/redis/releases/tag/win-3.0.504) 8 | 9 | • [Redis 服务端下载](https://github.com/ServiceStack/redis-windows/raw/master/downloads/redis-latest.zip) 10 | 11 | • [Redis 客户端下载](https://redisdesktop.com/download) 12 | 13 | • [Redis 官网](https://redis.io/) 14 | 15 | • [Redis 中文网](http://www.redis.cn/documentation.html) 16 | 17 | *** 18 | 19 | #### 2、找到 github 上的 [开发者设置](https://github.com/settings/developers) ,在上面注册自己的 OAuth App 20 | 21 | * 记录 Client ID 和 Client Secret 到 global.config.js 中 22 | 23 | * Homepage URL:填写应用的域名端口(如:http://localhost:3000) 24 | 25 | * Authorization callback URL:后台服务地址 + '/auth'(如:http://localhost:3000/auth) 26 | 27 | ![图片](https://github.com/yjdjiayou/react-ssr-with-nextjs-demo/blob/master/register-oauth.png) 28 | 29 | *** 30 | 31 | #### 3、安装依赖 32 | `npm install ` 33 | 34 | *** 35 | 36 | #### 4、启动调试项目 37 | `npm run dev ` 38 | 39 | *** 40 | 41 | -------------------------------------------------------------------------------- /components/Container.jsx: -------------------------------------------------------------------------------- 1 | import { cloneElement } from 'react'; 2 | 3 | const style = { 4 | width: '100%', 5 | maxWidth: 1200, 6 | marginLeft: 'auto', 7 | marginRight: 'auto', 8 | paddingLeft: 20, 9 | paddingRight: 20, 10 | }; 11 | 12 | export default ({ children, renderer =
}) => { 13 | const newElement = cloneElement(renderer, { 14 | style: Object.assign({}, renderer.props.style, style), 15 | children, 16 | }); 17 | 18 | return newElement 19 | } 20 | -------------------------------------------------------------------------------- /components/Layout.jsx: -------------------------------------------------------------------------------- 1 | import {useState, useCallback} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {withRouter} from 'next/router'; 4 | import Link from 'next/link'; 5 | import { 6 | Button, 7 | Layout, 8 | Icon, 9 | Input, 10 | Avatar, 11 | Tooltip, 12 | Dropdown, 13 | Menu, 14 | } from 'antd'; 15 | 16 | import Container from './Container'; 17 | 18 | import {logout} from '../store/store'; 19 | 20 | const {Header, Content, Footer} = Layout; 21 | 22 | const githubIconStyle = { 23 | color: 'white', 24 | fontSize: 40, 25 | display: 'block', 26 | paddingTop: 10, 27 | marginRight: 20, 28 | }; 29 | 30 | const footerStyle = { 31 | textAlign: 'center', 32 | }; 33 | 34 | function MyLayout({children, user, logout, router}) { 35 | const urlQuery = router.query && router.query.query; 36 | 37 | const [search, setSearch] = useState(urlQuery || ''); 38 | 39 | const handleSearchChange = useCallback( 40 | event => { 41 | setSearch(event.target.value); 42 | }, 43 | [setSearch], 44 | ); 45 | 46 | const handleOnSearch = useCallback(() => { 47 | router.push(`/search?query=${search}`); 48 | }, [search]); 49 | 50 | const handleLogout = useCallback(() => { 51 | logout(); 52 | }, [logout]); 53 | 54 | const userDropDown = ( 55 | 56 | 57 | 58 | 登 出 59 | 60 | 61 | 62 | ); 63 | 64 | return ( 65 | 66 |
67 | }> 68 |
69 |
70 | 71 | 72 | 73 |
74 |
75 | 81 |
82 |
83 |
84 |
85 | {user && user.id ? ( 86 | 87 | 88 | 89 | 90 | 91 | ) : ( 92 | 93 | {/* 点击 a 标签,浏览器会向服务器发起请求*/} 94 | 95 | 96 | 97 | 98 | )} 99 |
100 |
101 |
102 |
103 | 104 | {children} 105 | 106 | 119 | 134 |
135 | ) 136 | } 137 | 138 | export default connect( 139 | function mapState(state) { 140 | return { 141 | user: state.user, 142 | } 143 | }, 144 | function mapReducer(dispatch) { 145 | return { 146 | logout: () => dispatch(logout()), 147 | } 148 | }, 149 | )(withRouter(MyLayout)) 150 | -------------------------------------------------------------------------------- /components/MarkdownRenderer.jsx: -------------------------------------------------------------------------------- 1 | import { memo, useMemo } from 'react' 2 | import 'github-markdown-css' 3 | import MarkdownIt from 'markdown-it' 4 | 5 | const md = new MarkdownIt({ 6 | html: true, 7 | linkify: true, 8 | }); 9 | 10 | function b64_to_utf8(str) { 11 | return decodeURIComponent(escape(atob(str))) 12 | } 13 | 14 | export default memo(function MarkdownRenderer({ content, isBase64 }) { 15 | const markdown = isBase64 ? b64_to_utf8(content) : content; 16 | 17 | const html = useMemo(() => md.render(markdown), [markdown]); 18 | 19 | return ( 20 |
21 |
22 |
23 | ) 24 | }) 25 | -------------------------------------------------------------------------------- /components/PageLoading.jsx: -------------------------------------------------------------------------------- 1 | import { Spin } from 'antd' 2 | 3 | export default () => ( 4 |
5 | 6 | 20 |
21 | ) 22 | -------------------------------------------------------------------------------- /components/Repo.jsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { Icon } from 'antd' 3 | 4 | import { getLastUpdated } from '../lib/utils' 5 | 6 | function getLicense(license) { 7 | return license ? `${license.spdx_id} license` : '' 8 | } 9 | 10 | export default ({ repo }) => { 11 | return ( 12 |
13 |
14 |

15 | 16 | {repo.full_name} 17 | 18 |

19 |

{repo.description}

20 |

21 | {repo.license ? ( 22 | {getLicense(repo.license)} 23 | ) : null} 24 | 25 | {getLastUpdated(repo.updated_at)} 26 | 27 | 28 | {repo.open_issues_count} open issues 29 | 30 |

31 |
32 |
33 | {repo.language} 34 | 35 | {repo.stargazers_count} 36 | 37 |
38 | 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /components/SearchUser.jsx: -------------------------------------------------------------------------------- 1 | import {useState, useCallback, useRef} from 'react'; 2 | import {Select, Spin} from 'antd'; 3 | import debounce from 'lodash/debounce'; 4 | 5 | import api from '../lib/api'; 6 | 7 | const Option = Select.Option; 8 | 9 | function SearchUser({onChange, value}) { 10 | const lastFetchIdRef = useRef(0); 11 | const [fetching, setFetching] = useState(false); 12 | const [options, setOptions] = useState([]); 13 | 14 | const fetchUser = useCallback( 15 | debounce(value => { 16 | lastFetchIdRef.current += 1; 17 | const fetchId = lastFetchIdRef.current; 18 | setFetching(true); 19 | setOptions([]); 20 | 21 | api.request({ 22 | url: `/search/users?q=${value}`, 23 | }).then(resp => { 24 | if (fetchId !== lastFetchIdRef.current) { 25 | return 26 | } 27 | const data = resp.data.items.map(user => ({ 28 | text: user.login, 29 | value: user.login, 30 | })); 31 | 32 | setFetching(false); 33 | setOptions(data) 34 | }) 35 | }, 500), 36 | [], 37 | ); 38 | 39 | const handleChange = value => { 40 | setOptions([]); 41 | setFetching(false); 42 | onChange(value); 43 | }; 44 | 45 | return ( 46 | 63 | ) 64 | } 65 | 66 | export default SearchUser 67 | -------------------------------------------------------------------------------- /components/comp.jsx: -------------------------------------------------------------------------------- 1 | export default ({ children }) => Lazy Component 2 | -------------------------------------------------------------------------------- /components/with-repo-basic.jsx: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import Repo from './Repo'; 3 | import Link from 'next/link'; 4 | import {withRouter} from 'next/router'; 5 | 6 | import api from '../lib/api'; 7 | import {getCache, cache} from '../lib/repo-basic-cache'; 8 | 9 | const isServer = typeof window === 'undefined'; 10 | 11 | function makeQuery(queryObject) { 12 | const query = Object.entries(queryObject) 13 | .reduce((result, entry) => { 14 | result.push(entry.join('=')); 15 | return result 16 | }, []) 17 | .join('&'); 18 | return `?${query}` 19 | } 20 | 21 | 22 | export default function (Comp, type = 'index') { 23 | function WithDetail({repoBasic, router, ...rest}) { 24 | const query = makeQuery(router.query); 25 | 26 | useEffect(() => { 27 | if (!isServer) { 28 | cache(repoBasic) 29 | } 30 | },[repoBasic]); 31 | 32 | return ( 33 |
34 |
35 | 36 |
37 | {type === 'index' ? ( 38 | Readme 39 | ) : ( 40 | 41 | Readme 42 | 43 | )} 44 | {type === 'issues' ? ( 45 | Issues 46 | ) : ( 47 | 48 | Issues 49 | 50 | )} 51 |
52 |
53 |
54 | 55 |
56 | 72 |
73 | ) 74 | } 75 | 76 | WithDetail.getInitialProps = async context => { 77 | const {router, ctx} = context; 78 | const {owner, name} = ctx.query; 79 | 80 | const full_name = `${owner}/${name}`; 81 | 82 | let pageData = {}; 83 | // HOC 需要执行传递进来的组件的 getInitialProps 84 | if (Comp.getInitialProps) { 85 | pageData = await Comp.getInitialProps(context); 86 | } 87 | if (getCache(full_name)) { 88 | return { 89 | repoBasic: getCache(full_name), 90 | ...pageData, 91 | } 92 | } 93 | const repoBasic = await api.request( 94 | { 95 | url: `/repos/${owner}/${name}`, 96 | }, 97 | ctx.req, 98 | ctx.res, 99 | ); 100 | 101 | return { 102 | repoBasic: repoBasic.data, 103 | ...pageData, 104 | } 105 | }; 106 | 107 | return withRouter(WithDetail); 108 | } 109 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'next-project', 5 | script: './server.js', 6 | instances: 1, 7 | autorestart: true, 8 | watch: false, 9 | max_memory_restart: '1G', 10 | env: { 11 | NODE_ENV: 'production', 12 | }, 13 | }, 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /global.config.js: -------------------------------------------------------------------------------- 1 | const GITHUB_OAUTH_URL = 'https://github.com/login/oauth/authorize'; 2 | 3 | // 希望得到的授权 4 | const SCOPE = 'user'; 5 | 6 | // https://github.com/settings/developers 7 | // 在 github 上注册的 App 的 Client ID 8 | const client_id = ''; 9 | // 在 github 上注册的 App 的 Client Secret 10 | const client_secret = ''; 11 | 12 | module.exports = { 13 | github: { 14 | request_token_url: 'https://github.com/login/oauth/access_token', 15 | client_id, 16 | client_secret, 17 | }, 18 | GITHUB_OAUTH_URL, 19 | OAUTH_URL: `${GITHUB_OAUTH_URL}?client_id=${client_id}&scope=${SCOPE}`, 20 | }; 21 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const github_base_url = 'https://api.github.com'; 4 | 5 | async function requestGithub(method, url, data, headers) { 6 | return await axios({ 7 | method, 8 | url: `${github_base_url}${url}`, 9 | data, 10 | headers, 11 | }) 12 | } 13 | 14 | const isServer = typeof window === 'undefined'; 15 | 16 | 17 | async function request({ method = 'GET', url, data = {} }, req, res) { 18 | if (!url) { 19 | throw Error('url must provide') 20 | } 21 | // 这里需要区分是客户端还是服务端 22 | if (isServer) { 23 | const session = req.session; 24 | const githubAuth = session.githubAuth || {}; 25 | const headers = {}; 26 | // 向 github 服务器发请求需要将 token 传入到 headers 27 | if (githubAuth.access_token) { 28 | headers['Authorization'] = `${githubAuth.token_type} ${ 29 | githubAuth.access_token 30 | }` 31 | } 32 | return await requestGithub(method, url, data, headers); 33 | } 34 | else { 35 | // 因为客户端不直接向 github 服务器请求,而是向自己的服务器请求 36 | // 然后自己的服务器去请求github 服务器 37 | // 所以这里不需要将 token 传入到 headers 38 | return await axios({ 39 | method, 40 | // 在客户端的请求前面统一加上 /github 41 | url: `/github${url}`, 42 | data, 43 | }) 44 | } 45 | } 46 | 47 | module.exports = { 48 | request, 49 | requestGithub, 50 | }; 51 | -------------------------------------------------------------------------------- /lib/my-context.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | export default React.createContext('') 3 | -------------------------------------------------------------------------------- /lib/repo-basic-cache.js: -------------------------------------------------------------------------------- 1 | // 按照时间来计算的缓存策略 2 | import LRU from 'lru-cache' 3 | 4 | // 使用 LRU 缓存更新策略 5 | const GITHUB_DATA_CACHE = new LRU({ 6 | maxAge: 1000 * 60 * 60, 7 | }); 8 | 9 | export function cache(data, key) { 10 | key = key || data.full_name || data.name; 11 | GITHUB_DATA_CACHE.set(key, data); 12 | } 13 | 14 | export function getCache(key) { 15 | return GITHUB_DATA_CACHE.get(key); 16 | } 17 | 18 | export function cacheArray(dataArr) { 19 | if (dataArr && Array.isArray(dataArr)) { 20 | dataArr.forEach(item => cache(item)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | 3 | export function getLastUpdated(time) { 4 | return moment(time).fromNow() 5 | } 6 | -------------------------------------------------------------------------------- /lib/with-redux.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 用于包裹 App 的 HOC Redux 组件 3 | * 为什么要用 HOC 尼? 4 | * 因为创建 redux 的逻辑是独立的 5 | * 如果放到 _app.js 里面的话,之后再新增一些别的逻辑代码 6 | * 那么会导致 _app.js 这个文件的逻辑很复杂,所以用 HOC 将 redux 的逻辑抽离处理 7 | */ 8 | import React from 'react' 9 | import createSore from '../store/store' 10 | 11 | // 判断当前环境是否处于服务端 12 | const isServer = typeof window === 'undefined'; 13 | const __NEXT_REDUX_STORE__ = '__NEXT_REDUX_STORE__'; 14 | 15 | function getOrCreateStore(initialState) { 16 | // 服务端需要每次都去新建一个 store 17 | if (isServer) { 18 | return createSore(initialState); 19 | } 20 | 21 | if (!window[__NEXT_REDUX_STORE__]) { 22 | window[__NEXT_REDUX_STORE__] = createSore(initialState); 23 | } 24 | // 在客户端页面切换时,每次都会调用这个方法,不能每次都去新建一个 store,所以需要缓存一个 store 25 | return window[__NEXT_REDUX_STORE__]; 26 | } 27 | 28 | // 因为 Next.js 会给 _app.js 默认导出的 App 组件传递一些属性 29 | // 又因为这里的 HOC 包裹了 App,所以这里的 HOC 也会接收到 Next.js 传递过来的属性 30 | export default AppComp => { 31 | class WithReduxApp extends React.Component { 32 | constructor(props) { 33 | super(props); 34 | // 为防止每次服务端重新渲染时,都使用同一个 store,需要每次都重新创建一个 store 35 | // 如果不重新创建 store ,store 中的状态就不会重置,下一次服务端渲染的时候,还会保留上一次的值 36 | this.reduxStore = getOrCreateStore(props.initialReduxState); 37 | } 38 | 39 | render() { 40 | const { Component, pageProps, ...rest } = this.props; 41 | 42 | return ( 43 | 49 | ) 50 | } 51 | } 52 | 53 | // 如果这里不需要自定义 getInitialProps 方法的话,需要默认给 HOC 定义一个 getInitialProps 方法 54 | // 因为 Next.js 在页面渲染之前会去执行一下组件的 getInitialProps 55 | // WithReduxApp.getInitialProps = AppComp.getInitialProps 56 | 57 | // getInitialProps 方法会在服务端渲染时执行一次,客户端每次页面跳转,也会执行一次 58 | // 所以在客户端页面切换时,不能每次都去新建一个 store 59 | 60 | // getInitialProps 返回的内容,会被序列化成字符串,写到在服务端返回给客户端的页面中(查看 return.html) 61 | // 客户端会去读取这个 script 中的字符串内容,然后转换成 JS 对象,最终在客户端生成一个 store 62 | 63 | 64 | WithReduxApp.getInitialProps = async ctx => { 65 | let reduxStore; 66 | 67 | if (isServer) { 68 | const { req } = ctx.ctx; 69 | const session = req.session; 70 | 71 | if (session && session.userInfo) { 72 | // 在服务端渲染时,默认传递用户的数据 73 | // 当用户输入 url ,浏览器向服务端请求页面时,在服务端渲染时把数据放进去 74 | // 这样浏览器请求返回的页面中就已经有用户数据了,可以立马显示了 75 | // 不用像以前一样,浏览器先渲染好一部分内容,然后浏览器发送 ajax 请求去获取用户数据 76 | // 等待服务器响应完成后,获取数据再去渲染,这其中等待的时间还是挺长的 77 | reduxStore = getOrCreateStore({ 78 | user: session.userInfo, 79 | }) 80 | } else { 81 | reduxStore = getOrCreateStore(); 82 | } 83 | } 84 | else { 85 | reduxStore = getOrCreateStore(); 86 | } 87 | 88 | ctx.reduxStore = reduxStore; 89 | 90 | let appProps = {}; 91 | // 如果 AppComp.getInitialProps 存在 92 | // 就去获取一下 App 的初始数据 93 | if (typeof AppComp.getInitialProps === 'function') { 94 | appProps = await AppComp.getInitialProps(ctx); 95 | } 96 | 97 | return { 98 | ...appProps, 99 | // 这里不能直接返回一个 store 100 | // 因为 store 里面会有很多方法,在服务端序列化时,很难转成字符串,在客户端又很难反序列化出来 101 | initialReduxState: reduxStore.getState(), 102 | } 103 | }; 104 | 105 | return WithReduxApp 106 | } 107 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const withCss = require('@zeit/next-css'); 3 | const withBundleAnalyzer = require('@zeit/next-bundle-analyzer'); 4 | const config = require('./global.config'); 5 | 6 | const configs = { 7 | // 编译文件的输出目录 8 | distDir: 'dest', 9 | // 是否给每个路由生成 Etag 10 | // 如果 Nginx 配置了,那么这里就不需要配置了 11 | generateEtags: true, 12 | // 页面内容缓存配置 13 | onDemandEntries: { 14 | // 内容在内存中缓存的时长(ms) 15 | maxInactiveAge: 25 * 1000, 16 | // 同时缓存多少个页面 17 | pagesBufferLength: 2, 18 | }, 19 | // 在pages目录下那种后缀的文件会被认为是页面 20 | pageExtensions: ['jsx', 'js'], 21 | // 配置buildId 22 | generateBuildId: async () => { 23 | if (process.env.YOUR_BUILD_ID) { 24 | return process.env.YOUR_BUILD_ID 25 | } 26 | 27 | // 返回null使用默认的unique id 28 | return null 29 | }, 30 | // 手动修改webpack config 31 | webpack(config, options) { 32 | return config 33 | }, 34 | // 修改webpackDevMiddleware配置 35 | webpackDevMiddleware: config => { 36 | return config 37 | }, 38 | // 可以在页面上通过 procsess.env.customKey 获取 value 39 | env: { 40 | customKey: 'value', 41 | }, 42 | // 下面两个要通过 'next/config' 来读取 43 | // 只有在服务端渲染时才会获取的配置 44 | serverRuntimeConfig: { 45 | mySecret: 'secret', 46 | secondSecret: process.env.SECOND_SECRET, 47 | }, 48 | // 在服务端渲染和客户端渲染都可获取的配置 49 | publicRuntimeConfig: { 50 | staticFolder: '/static', 51 | }, 52 | }; 53 | 54 | if (typeof require !== 'undefined') { 55 | require.extensions['.css'] = file => { 56 | } 57 | } 58 | 59 | module.exports = withBundleAnalyzer( 60 | withCss({ 61 | webpack(config) { 62 | config.plugins.push(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)); 63 | return config 64 | }, 65 | publicRuntimeConfig: { 66 | GITHUB_OAUTH_URL: config.GITHUB_OAUTH_URL, 67 | OAUTH_URL: config.OAUTH_URL, 68 | }, 69 | analyzeBrowser: ['browser', 'both'].includes(process.env.BUNDLE_ANALYZE), 70 | bundleAnalyzerConfig: { 71 | server: { 72 | analyzerMode: 'static', 73 | reportFilename: '../bundles/server.html', 74 | }, 75 | browser: { 76 | analyzerMode: 'static', 77 | reportFilename: '../bundles/client.html', 78 | }, 79 | }, 80 | }), 81 | ); 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "node server.js", 8 | "build": "next build", 9 | "start": "cross-env NODE_ENV=production node server.js", 10 | "export": "next export", 11 | "analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@zeit/next-bundle-analyzer": "^0.1.2", 17 | "@zeit/next-css": "^1.0.1", 18 | "antd": "^3.15.2", 19 | "atob": "^2.1.2", 20 | "axios": "^0.18.0", 21 | "babel-plugin-import": "^1.11.0", 22 | "babel-plugin-styled-components": "^1.10.0", 23 | "cross-env": "^5.2.0", 24 | "github-markdown-css": "^3.0.1", 25 | "ioredis": "^4.9.0", 26 | "koa": "^2.7.0", 27 | "koa-body": "^4.1.0", 28 | "koa-router": "^7.4.0", 29 | "koa-session": "^5.10.1", 30 | "lodash": "^4.17.11", 31 | "lru-cache": "^5.1.1", 32 | "markdown-it": "^8.4.2", 33 | "moment": "^2.24.0", 34 | "next": "^8.0.3", 35 | "qiniu": "^7.2.1", 36 | "react": "^16.8.5", 37 | "react-dom": "^16.8.5", 38 | "react-redux": "^6.0.1", 39 | "redux": "^4.0.1", 40 | "redux-devtools-extension": "^2.13.8", 41 | "redux-thunk": "^2.3.0", 42 | "styled-components": "^4.2.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import 'antd/dist/antd.css' 2 | import App, {Container} from 'next/app' 3 | import {Provider} from 'react-redux' 4 | import Router from 'next/router' 5 | 6 | import Layout from '../components/Layout' 7 | import PageLoading from '../components/PageLoading' 8 | 9 | import WithReduxHoc from '../lib/with-redux' 10 | 11 | class MyApp extends App { 12 | state = { 13 | context: 'value', 14 | loading: false, 15 | }; 16 | 17 | startLoading = () => { 18 | this.setState({ 19 | loading: true, 20 | }) 21 | }; 22 | 23 | stopLoading = () => { 24 | this.setState({ 25 | loading: false, 26 | }) 27 | }; 28 | 29 | componentDidMount() { 30 | Router.events.on('routeChangeStart', this.startLoading); 31 | Router.events.on('routeChangeComplete', this.stopLoading); 32 | Router.events.on('routeChangeError', this.stopLoading); 33 | } 34 | 35 | componentWillUnmount() { 36 | Router.events.off('routeChangeStart', this.startLoading); 37 | Router.events.off('routeChangeComplete', this.stopLoading); 38 | Router.events.off('routeChangeError', this.stopLoading); 39 | } 40 | 41 | static async getInitialProps(ctx) { 42 | const {Component} = ctx; 43 | console.log('app init'); 44 | let pageProps = {}; 45 | if (Component.getInitialProps) { 46 | pageProps = await Component.getInitialProps(ctx); 47 | } 48 | return { 49 | pageProps, 50 | } 51 | } 52 | 53 | render() { 54 | const {Component, pageProps, reduxStore} = this.props; 55 | 56 | return ( 57 | 58 | 59 | {this.state.loading ? : null} 60 | 61 | 62 | 63 | 64 | 65 | ) 66 | } 67 | } 68 | 69 | export default WithReduxHoc(MyApp) 70 | -------------------------------------------------------------------------------- /pages/detail/index.js: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic' 2 | import withRepoBasic from '../../components/with-repo-basic' 3 | import api from '../../lib/api' 4 | import {useEffect} from "react"; 5 | import {cache, getCache} from "../../lib/repo-basic-cache"; 6 | 7 | const MDRenderer = dynamic(() => import('../../components/MarkdownRenderer')); 8 | const isServer = typeof window === 'undefined'; 9 | 10 | function Detail({readme, full_name}) { 11 | useEffect(() => { 12 | if (!isServer) { 13 | cache(readme,full_name) 14 | } 15 | }, [readme,full_name]); 16 | return 17 | } 18 | 19 | Detail.getInitialProps = async ({ctx}) => { 20 | const { 21 | query: {owner, name}, 22 | req, 23 | res, 24 | } = ctx; 25 | const full_name = `${owner}/${name}/readme`; 26 | if (getCache(full_name)) { 27 | return { 28 | readme: getCache(full_name), 29 | } 30 | } 31 | const readmeResp = await api.request( 32 | { 33 | url: `/repos/${full_name}`, 34 | }, 35 | req, 36 | res, 37 | ); 38 | return { 39 | full_name, 40 | readme: readmeResp.data, 41 | } 42 | }; 43 | 44 | export default withRepoBasic(Detail, 'index') 45 | -------------------------------------------------------------------------------- /pages/detail/issues.js: -------------------------------------------------------------------------------- 1 | import {useState, useCallback, useEffect} from 'react' 2 | import {Avatar, Button, Select, Spin} from 'antd' 3 | import dynamic from 'next/dynamic' 4 | 5 | import {getLastUpdated} from '../../lib/utils' 6 | 7 | import withRepoBasic from '../../components/with-repo-basic' 8 | import SearchUser from '../../components/SearchUser' 9 | 10 | const MdRenderer = dynamic(() => import('../../components/MarkdownRenderer')); 11 | 12 | import api from '../../lib/api' 13 | import {cache, getCache} from "../../lib/repo-basic-cache"; 14 | 15 | 16 | function IssueDetail({issue}) { 17 | return ( 18 |
19 | 20 |
21 | 24 |
25 | 34 |
35 | ) 36 | } 37 | 38 | function IssueItem({issue}) { 39 | const [showDetail, setShowDetail] = useState(false); 40 | 41 | const toggleShowDetail = useCallback(() => { 42 | setShowDetail(detail => !detail) 43 | }, []); 44 | 45 | return ( 46 |
47 |
48 | 56 |
57 | 58 |
59 |
60 |
61 | {issue.title} 62 | {issue.labels.map(label => ( 63 |
66 |

67 | Updated at {getLastUpdated(issue.updated_at)} 68 |

69 |
70 | 99 |
100 | {showDetail ? : null} 101 |
102 | ) 103 | } 104 | 105 | function makeQuery(creator, state, labels) { 106 | let creatorStr = creator ? `creator=${creator}` : ''; 107 | let stateStr = state ? `state=${state}` : ''; 108 | let labelStr = ''; 109 | if (labels && labels.length > 0) { 110 | labelStr = `labels=${labels.join(',')}` 111 | } 112 | 113 | const arr = []; 114 | 115 | if (creatorStr) arr.push(creatorStr); 116 | if (stateStr) arr.push(stateStr); 117 | if (labelStr) arr.push(labelStr); 118 | 119 | return `?${arr.join('&')}` 120 | } 121 | 122 | function Label({label}) { 123 | return ( 124 | <> 125 | 126 | {label.name} 127 | 128 | 138 | 139 | ) 140 | } 141 | 142 | const isServer = typeof window === 'undefined'; 143 | 144 | const Option = Select.Option; 145 | 146 | 147 | function Issues({initialIssues, labels, owner, name}) { 148 | const [creator, setCreator] = useState(); 149 | const [state, setState] = useState(); 150 | const [label, setLabel] = useState([]); 151 | const [issues, setIssues] = useState(initialIssues); 152 | const [fetching, setFetching] = useState(false); 153 | 154 | useEffect(() => { 155 | if (!isServer) { 156 | cache(initialIssues, `${owner}/${name}/issues`); 157 | } 158 | }, [owner, name, initialIssues]); 159 | 160 | useEffect(() => { 161 | if (!isServer) { 162 | cache(labels, `${owner}/${name}/labels`); 163 | } 164 | }, [owner, name, labels]); 165 | 166 | const handleCreatorChange = useCallback(value => { 167 | setCreator(value) 168 | }, []); 169 | 170 | const handleStateChange = useCallback(value => { 171 | setState(value) 172 | }, []); 173 | 174 | const handleLabelChange = useCallback(value => { 175 | setLabel(value) 176 | }, []); 177 | 178 | const handleSearch = useCallback(() => { 179 | setFetching(true); 180 | api.request({ 181 | url: `/repos/${owner}/${name}/issues${makeQuery( 182 | creator, 183 | state, 184 | label, 185 | )}`, 186 | }) 187 | .then(resp => { 188 | setIssues(resp.data); 189 | setFetching(false) 190 | }) 191 | .catch(err => { 192 | console.error(err); 193 | setFetching(false) 194 | }) 195 | }, [owner, name, creator, state, label]); 196 | 197 | return ( 198 |
199 |
200 | 201 | 211 | 224 | 227 |
228 | {fetching ? ( 229 |
230 | 231 |
232 | ) : ( 233 |
234 | {issues.map(issue => ( 235 | 236 | ))} 237 |
238 | )} 239 | 256 |
257 | ) 258 | } 259 | 260 | Issues.getInitialProps = async ({ctx}) => { 261 | 262 | const {owner, name} = ctx.query; 263 | 264 | const full_name = `${owner}/${name}`; 265 | const cachedIssues = getCache(full_name + '/issues'); 266 | const cachedLabels = getCache(full_name + '/labels'); 267 | const resultArr = await Promise.all([ 268 | cachedIssues ? Promise.resolve({data: cachedIssues}) : 269 | await api.request( 270 | { 271 | url: `/repos/${full_name}/issues`, 272 | }, 273 | ctx.req, 274 | ctx.res, 275 | ), 276 | cachedLabels 277 | ? Promise.resolve({data: cachedLabels}) 278 | : await api.request( 279 | { 280 | url: `/repos/${full_name}/labels`, 281 | }, 282 | ctx.req, 283 | ctx.res, 284 | ) 285 | ]); 286 | 287 | return { 288 | owner, 289 | name, 290 | initialIssues: resultArr[0].data, 291 | labels: resultArr[1].data, 292 | } 293 | }; 294 | 295 | export default withRepoBasic(Issues, 'issues') 296 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import {Button, Icon, Tabs} from 'antd'; 3 | import getCofnig from 'next/config'; 4 | import {connect} from 'react-redux'; 5 | import Router, {withRouter} from 'next/router'; 6 | 7 | import Repo from '../components/Repo'; 8 | import {cacheArray,getCache} from '../lib/repo-basic-cache'; 9 | 10 | const api = require('../lib/api'); 11 | 12 | const {publicRuntimeConfig} = getCofnig(); 13 | 14 | let cachedUserRepos, cachedUserStaredRepos; 15 | 16 | const isServer = typeof window === 'undefined'; 17 | 18 | function Index({userRepos, userStaredRepos, user, router}) { 19 | 20 | const tabKey = router.query.key || '1'; 21 | 22 | const handleTabChange = activeKey => { 23 | Router.push(`/?key=${activeKey}`); 24 | }; 25 | 26 | useEffect(() => { 27 | if (!isServer) { 28 | // 这里对客户端做的数据缓存不大好,一旦 github 的数用户据发生了变化,除非页面刷新,否则无法同步数据 29 | cachedUserRepos = userRepos; 30 | cachedUserStaredRepos = userStaredRepos; 31 | 32 | // 所以这里使用 setTimeout 实现缓存更新策略 33 | setTimeout(() => { 34 | cachedUserRepos = null; 35 | cachedUserStaredRepos = null; 36 | }, 1000 * 60 * 10); 37 | 38 | } 39 | }, [userRepos, userStaredRepos]); 40 | 41 | useEffect(() => { 42 | if (!isServer) { 43 | // 使用 LRU 缓存数据 44 | cacheArray(userRepos); 45 | cacheArray(userStaredRepos); 46 | } 47 | }, [userRepos, userStaredRepos]); 48 | 49 | if (!user || !user.id) { 50 | return ( 51 |
52 |

亲,您还没有登录哦~

53 | 56 | 65 |
66 | ) 67 | } 68 | 69 | return ( 70 |
71 |
72 | user avatar 73 | {user.login} 74 | {user.name} 75 | {user.bio} 76 |

77 | 78 | {user.email} 79 |

80 |
81 |
82 | 83 | 84 | {userRepos.map(repo => ( 85 | 86 | ))} 87 | 88 | 89 | {userStaredRepos.map(repo => ( 90 | 91 | ))} 92 | 93 | 94 |
95 | 129 |
130 | ) 131 | } 132 | 133 | Index.getInitialProps = async ({ctx, reduxStore}) => { 134 | // 这段代码在服务端运行时会报错,需要对请求区分处理 135 | // 因为在客户端运行的时候,浏览器会在 URL 前面加上域名和端口 136 | // 最终的 URL => http://localhost:3000/github/search/repositories 137 | // 但是在服务端运行的时候,由于服务端是没有域名的,只有 127.0.0.1 ,并且默认端口是 80 138 | // 最终的 URL => http://127.0.0.1:80/github/search/repositories ,这个请求会被发送到服务端本地的 80 端口去 139 | // 80 端口不是最终想要请求的地址,所以会报错 140 | // const result = await axios 141 | // .get('/github/search/repositories') 142 | // .then(resp => console.log(resp)) 143 | 144 | const user = reduxStore.getState().user; 145 | if (!user || !user.id) { 146 | return { 147 | isLogin: false, 148 | } 149 | } 150 | 151 | if (!isServer) { 152 | if (cachedUserRepos && cachedUserStaredRepos) { 153 | return { 154 | userRepos: cachedUserRepos, 155 | userStaredRepos: cachedUserStaredRepos, 156 | } 157 | } 158 | } 159 | 160 | const userRepos = await api.request( 161 | { 162 | url: '/user/repos', 163 | }, 164 | ctx.req, 165 | ctx.res, 166 | ); 167 | 168 | const userStaredRepos = await api.request( 169 | { 170 | url: '/user/starred', 171 | }, 172 | ctx.req, 173 | ctx.res, 174 | ); 175 | 176 | return { 177 | isLogin: true, 178 | userRepos: userRepos.data, 179 | userStaredRepos: userStaredRepos.data, 180 | } 181 | }; 182 | 183 | export default withRouter( 184 | connect(function mapState(state) { 185 | return { 186 | user: state.user, 187 | } 188 | })(Index), 189 | ) 190 | -------------------------------------------------------------------------------- /pages/search.js: -------------------------------------------------------------------------------- 1 | import { memo, isValidElement, useEffect } from 'react'; 2 | import { withRouter } from 'next/router'; 3 | import { Row, Col, List, Pagination } from 'antd'; 4 | import Link from 'next/link'; 5 | 6 | import Repo from '../components/Repo'; 7 | import { cacheArray } from '../lib/repo-basic-cache'; 8 | 9 | const api = require('../lib/api'); 10 | 11 | const LANGUAGES = ['JavaScript', 'HTML', 'CSS', 'TypeScript', 'Java', 'Rust']; 12 | const SORT_TYPES = [ 13 | { 14 | name: 'Best Match', 15 | }, 16 | { 17 | name: 'Most Stars', 18 | value: 'stars', 19 | order: 'desc', 20 | }, 21 | { 22 | name: 'Fewest Stars', 23 | value: 'stars', 24 | order: 'asc', 25 | }, 26 | { 27 | name: 'Most Forks', 28 | value: 'forks', 29 | order: 'desc', 30 | }, 31 | { 32 | name: 'Fewest Forks', 33 | value: 'forks', 34 | order: 'asc', 35 | }, 36 | ]; 37 | 38 | /** 39 | * sort: 排序方式 40 | * order: 排序顺序 41 | * lang: 仓库的项目开发主语言 42 | * page:分页页面 43 | */ 44 | 45 | const selectedItemStyle = { 46 | borderLeft: '2px solid #e36209', 47 | fontWeight: 100, 48 | }; 49 | 50 | 51 | const per_page = 20; 52 | 53 | const isServer = typeof window === 'undefined'; 54 | const FilterLink = memo(({ name, query, lang, sort, order, page }) => { 55 | let queryString = `?query=${query}`; 56 | if (lang) queryString += `&lang=${lang}`; 57 | if (sort) queryString += `&sort=${sort}&order=${order || 'desc'}`; 58 | if (page) queryString += `&page=${page}`; 59 | 60 | queryString += `&per_page=${per_page}`; 61 | 62 | return ( 63 | 64 | {isValidElement(name) ? name : {name}} 65 | 66 | ) 67 | }); 68 | 69 | function Search({ router, repos }) { 70 | const { ...querys } = router.query; 71 | const { lang, sort, order, page } = router.query; 72 | 73 | useEffect(() => { 74 | if (!isServer) cacheArray(repos.items) 75 | }); 76 | 77 | return ( 78 |
79 | 80 | 81 | 语言} 84 | style={{ marginBottom: 20 }} 85 | dataSource={LANGUAGES} 86 | renderItem={item => { 87 | const selected = lang === item; 88 | 89 | return ( 90 | 91 | {selected ? ( 92 | {item} 93 | ) : ( 94 | 95 | )} 96 | 97 | ) 98 | }} 99 | /> 100 | 排序} 103 | dataSource={SORT_TYPES} 104 | renderItem={item => { 105 | let selected = false; 106 | if (item.name === 'Best Match' && !sort) { 107 | selected = true; 108 | } else if (item.value === sort && item.order === order) { 109 | selected = true; 110 | } 111 | return ( 112 | 113 | {selected ? ( 114 | {item.name} 115 | ) : ( 116 | 122 | )} 123 | 124 | ) 125 | }} 126 | /> 127 | 128 | 129 |

{repos.total_count} 个仓库

130 | {repos.items.map(repo => ( 131 | 132 | ))} 133 |
134 | { 139 | const p = 140 | type === 'page' ? page : type === 'prev' ? page - 1 : page + 1; 141 | const name = type === 'page' ? page : ol; 142 | return 143 | }} 144 | /> 145 |
146 | 147 |
148 | 166 |
167 | ) 168 | } 169 | 170 | Search.getInitialProps = async ({ ctx }) => { 171 | const { query, sort, lang, order, page } = ctx.query; 172 | 173 | if (!query) { 174 | return { 175 | repos: { 176 | total_count: 0, 177 | }, 178 | } 179 | } 180 | 181 | // ?q=react+language:javascript&sort=stars&order=desc&page=2 182 | let queryString = `?q=${query}`; 183 | if (lang) queryString += `+language:${lang}`; 184 | if (sort) queryString += `&sort=${sort}&order=${order || 'desc'}`; 185 | if (page) queryString += `&page=${page}`; 186 | queryString += `&per_page=${per_page}`; 187 | 188 | const result = await api.request( 189 | { 190 | url: `/search/repositories${queryString}`, 191 | }, 192 | ctx.req, 193 | ctx.res, 194 | ); 195 | 196 | return { 197 | repos: result.data, 198 | } 199 | }; 200 | 201 | export default withRouter(Search) 202 | -------------------------------------------------------------------------------- /register-oauth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yjdjiayou/react-ssr-with-nextjs-demo/03f756b4b20338e3867351eeb1998e3cf9cf944f/register-oauth.png -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const Router = require('koa-router'); 3 | const next = require('next'); 4 | const session = require('koa-session'); 5 | const Redis = require('ioredis'); 6 | const koaBody = require('koa-body'); 7 | const atob = require('atob'); 8 | 9 | const auth = require('./server/auth'); 10 | const api = require('./server/api'); 11 | 12 | const RedisSessionStore = require('./server/session-store'); 13 | 14 | const dev = process.env.NODE_ENV !== 'production'; 15 | const app = next({dev}); 16 | const handle = app.getRequestHandler(); 17 | 18 | // 创建 Redis client 19 | // 如果通过这里连接 Redis 时,就不需要再通过命令行连接 Redis 20 | const redis = new Redis({ 21 | port: 6379 22 | }); 23 | 24 | // 因为 Node.js 没有 window 对象,所以无法使用 atob 25 | // 这里通过插件给 Nodejs 全局增加一个 atob 方法 26 | global.atob = atob; 27 | 28 | app.prepare().then(() => { 29 | const server = new Koa(); 30 | const router = new Router(); 31 | 32 | server.keys = ['develop Github App']; 33 | 34 | server.use(koaBody()); 35 | 36 | const SESSION_CONFIG = { 37 | key: 'qwer', 38 | maxAge: 1000000, 39 | // 将 Koa 的 session 存储到 Redis 中 40 | store: new RedisSessionStore(redis), 41 | }; 42 | 43 | server.use(session(SESSION_CONFIG, server)); 44 | 45 | // 配置处理 Github OAuth 登录 的中间件 46 | // 必须放在上面的 session 中间件后面 47 | auth(server); 48 | api(server); 49 | 50 | server.use(router.routes()); 51 | 52 | server.use(async (ctx, next) => { 53 | ctx.req.session = ctx.session; 54 | await handle(ctx.req, ctx.res); 55 | ctx.respond = false; 56 | }); 57 | 58 | server.use(async (ctx, next) => { 59 | ctx.res.statusCode = 200; 60 | await next(); 61 | }); 62 | 63 | server.listen(3000, () => { 64 | console.log('koa server listening on 3000'); 65 | }); 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /server/api.js: -------------------------------------------------------------------------------- 1 | const { requestGithub } = require('../lib/api'); 2 | 3 | module.exports = server => { 4 | server.use(async (ctx, next) => { 5 | const path = ctx.path; 6 | const method = ctx.method; 7 | 8 | if (path.startsWith('/github/')) { 9 | console.log(ctx.request.body); 10 | const session = ctx.session; 11 | const githubAuth = session && session.githubAuth; 12 | const headers = {}; 13 | if (githubAuth && githubAuth.access_token) { 14 | headers['Authorization'] = `${githubAuth.token_type} ${ 15 | githubAuth.access_token 16 | }` 17 | } 18 | // 向 github 服务器发起请求 19 | const result = await requestGithub( 20 | method, 21 | ctx.url.replace('/github/', '/'), 22 | ctx.request.body || {}, 23 | headers, 24 | ); 25 | 26 | ctx.status = result.status; 27 | ctx.body = result.data 28 | } else { 29 | await next() 30 | } 31 | }) 32 | }; 33 | -------------------------------------------------------------------------------- /server/auth.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const config = require('../global.config'); 4 | 5 | const {client_id, client_secret, request_token_url} = config.github; 6 | 7 | module.exports = server => { 8 | server.use(async (ctx, next) => { 9 | if (ctx.path === '/auth') { 10 | const code = ctx.query.code; 11 | if (!code) { 12 | ctx.body = 'code not exist'; 13 | return 14 | } 15 | const result = await axios({ 16 | method: 'POST', 17 | url: request_token_url, 18 | data: { 19 | client_id, 20 | client_secret, 21 | code, 22 | }, 23 | headers: { 24 | Accept: 'application/json', 25 | }, 26 | }); 27 | 28 | if (result.status === 200 && (result.data && !result.data.error)) { 29 | ctx.session.githubAuth = result.data; 30 | 31 | const {access_token, token_type} = result.data; 32 | 33 | const userInfoResp = await axios({ 34 | method: 'GET', 35 | url: 'https://api.github.com/user', 36 | headers: { 37 | Authorization: `${token_type} ${access_token}`, 38 | }, 39 | }); 40 | 41 | ctx.session.userInfo = userInfoResp.data; 42 | 43 | // 认证成功后,返回到之前的页面 44 | ctx.redirect((ctx.session && ctx.session.urlBeforeOAuth) || '/'); 45 | ctx.session.urlBeforeOAuth = '' 46 | } else { 47 | const errorMsg = result.data && result.data.error; 48 | ctx.body = `request token failed ${errorMsg}` 49 | } 50 | } else { 51 | await next() 52 | } 53 | }); 54 | 55 | server.use(async (ctx, next) => { 56 | const path = ctx.path; 57 | const method = ctx.method; 58 | if (path === '/logout' && method === 'POST') { 59 | ctx.session = null; 60 | ctx.body = `logout success` 61 | } else { 62 | await next() 63 | } 64 | }); 65 | 66 | server.use(async (ctx, next) => { 67 | const path = ctx.path; 68 | const method = ctx.method; 69 | // 客户端会通过点击 a 标签,向服务器发起请求 70 | if (path === '/prepare-auth' && method === 'GET') { 71 | const {url} = ctx.query; 72 | ctx.session.urlBeforeOAuth = url; 73 | ctx.redirect(config.OAUTH_URL) 74 | } else { 75 | await next() 76 | } 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /server/session-store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 操作 Redis 的方法封装 3 | */ 4 | 5 | 6 | function getRedisSessionId(sid) { 7 | return `ssid:${sid}` 8 | } 9 | 10 | class RedisSessionStore { 11 | constructor(client) { 12 | this.client = client; 13 | } 14 | 15 | /** 16 | * 获取 Redis 中存储的 session 数据 17 | * @param sid 18 | */ 19 | async get(sid) { 20 | console.log('get session', sid); 21 | const id = getRedisSessionId(sid); 22 | // 相当于执行 Redis 的 get 命令 23 | const data = await this.client.get(id); 24 | if (!data) { 25 | return null; 26 | } 27 | try { 28 | const result = JSON.parse(data); 29 | return result; 30 | } catch (err) { 31 | console.error(err); 32 | } 33 | } 34 | 35 | /** 36 | * 存储 session 数据到 Redis 37 | * @param sid 38 | * @param sess => session 39 | * @param ttl => 过期时间 40 | */ 41 | async set(sid, sess, ttl) { 42 | console.log('set session', sid); 43 | const id = getRedisSessionId(sid); 44 | if (typeof ttl === 'number') { 45 | ttl = Math.ceil(ttl / 1000); 46 | } 47 | try { 48 | const sessStr = JSON.stringify(sess); 49 | if (ttl) { 50 | await this.client.setex(id, ttl, sessStr); 51 | } else { 52 | await this.client.set(id, sessStr); 53 | } 54 | } catch (err) { 55 | console.error(err); 56 | } 57 | } 58 | 59 | /** 60 | * 从 Redis 当中删除某个 session 61 | * @param sid 62 | */ 63 | async destroy(sid) { 64 | console.log('destroy session', sid); 65 | const id = getRedisSessionId(sid); 66 | await this.client.del(id); 67 | } 68 | } 69 | 70 | module.exports = RedisSessionStore; 71 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yjdjiayou/react-ssr-with-nextjs-demo/03f756b4b20338e3867351eeb1998e3cf9cf944f/static/favicon.png -------------------------------------------------------------------------------- /store/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 2 | import ReduxThunk from 'redux-thunk'; 3 | import { composeWithDevTools } from 'redux-devtools-extension'; 4 | import axios from 'axios'; 5 | 6 | const userInitialState = {}; 7 | 8 | const LOGOUT = 'LOGOUT'; 9 | 10 | function userReducer(state = userInitialState, action) { 11 | switch (action.type) { 12 | case LOGOUT: { 13 | return {} 14 | } 15 | default: 16 | return state 17 | } 18 | } 19 | 20 | const allReducers = combineReducers({ 21 | user: userReducer, 22 | }); 23 | 24 | // action creators 25 | export function logout() { 26 | return dispatch => { 27 | axios 28 | .post('/logout') 29 | .then(resp => { 30 | if (resp.status === 200) { 31 | dispatch({ 32 | type: LOGOUT, 33 | }) 34 | } else { 35 | console.log('logout failed', resp) 36 | } 37 | }) 38 | .catch(err => { 39 | console.log('logout failed', err) 40 | }) 41 | } 42 | } 43 | 44 | // 为防止每次服务端重新渲染时,都使用同一个 store,这里不能默认创建并导出 store 45 | // 需要用函数来创建 store 46 | // 如果不重新创建 store ,store 中的状态就不会重置,下一次服务端渲染的时候,还会保留上一次的值 47 | export default function initializeStore(state) { 48 | const store = createStore( 49 | allReducers, 50 | Object.assign( 51 | {}, 52 | { 53 | user: userInitialState, 54 | }, 55 | state, 56 | ), 57 | composeWithDevTools(applyMiddleware(ReduxThunk)), 58 | ); 59 | 60 | return store 61 | } 62 | --------------------------------------------------------------------------------