├── now.json ├── .env.development ├── .env.production ├── .stylelintignore ├── sandbox.config.json ├── .stylelintrc ├── src ├── client │ ├── constants │ │ └── url.js │ ├── components │ │ ├── loading │ │ │ └── index.js │ │ ├── not-found │ │ │ └── index.js │ │ ├── redirect-with-status │ │ │ └── index.js │ │ ├── status │ │ │ └── index.js │ │ ├── layout │ │ │ └── index.js │ │ ├── comment │ │ │ └── index.js │ │ └── article │ │ │ └── index.js │ ├── entry │ │ ├── models │ │ │ ├── index.js │ │ │ └── news.js │ │ ├── index.js │ │ ├── routers.js │ │ ├── app.js │ │ └── index.scss │ ├── lib │ │ └── util.js │ └── containers │ │ └── home │ │ ├── feed │ │ └── index.js │ │ ├── detail │ │ └── index.js │ │ └── user │ │ └── index.js ├── shared │ ├── lib │ │ └── http │ │ │ ├── index.js │ │ │ ├── client.js │ │ │ └── server.js │ └── service │ │ └── news.js ├── server │ ├── app.js │ ├── views │ │ └── home.njk │ └── server.js └── .babelrc.js ├── scripts └── webpack │ ├── config │ ├── webpack.config.parts │ │ ├── index.js │ │ ├── alias.js │ │ ├── load_js.js │ │ └── load_css.js │ ├── createConfig.js │ ├── paths.js │ ├── webpack.config.client.js │ ├── webpack.config.base.js │ ├── webpack.config.server.js │ └── env.js │ └── build.js ├── .eslintrc ├── .gitignore ├── package.json └── README.md /now.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | PORT=4000 -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | PORT=4000 -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | src/client/entry/index.scss -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "node" 3 | } -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard" 3 | } 4 | -------------------------------------------------------------------------------- /src/client/constants/url.js: -------------------------------------------------------------------------------- 1 | export const serverUrl = 'https://hacker-news.firebaseio.com/v0'; 2 | -------------------------------------------------------------------------------- /src/shared/lib/http/index.js: -------------------------------------------------------------------------------- 1 | import client from './client'; 2 | import server from './server'; 3 | 4 | export default (__BROWSER__ ? client : server); 5 | -------------------------------------------------------------------------------- /src/client/components/loading/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Loading extends React.Component { 4 | render() { 5 | return
Loading
; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/shared/lib/http/client.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | const instance = axios.create(); 3 | instance.interceptors.response.use( 4 | response => { 5 | return response; 6 | }, 7 | err => { 8 | return Promise.reject(err); 9 | } 10 | ); 11 | export default instance; 12 | -------------------------------------------------------------------------------- /src/server/app.js: -------------------------------------------------------------------------------- 1 | import process from 'process'; 2 | import { startServer } from './server'; 3 | process.addListener('uncaughtException', err => { 4 | console.error('uncaughtException:', err); 5 | process.exit(1); 6 | }); 7 | process.on('unhandledRejection', err => { 8 | console.error('unhandleRejection:', err); 9 | }); 10 | startServer(); 11 | -------------------------------------------------------------------------------- /scripts/webpack/config/webpack.config.parts/index.js: -------------------------------------------------------------------------------- 1 | module.exports = (target, env) => { 2 | return { 3 | load_css_module: require('./load_css')(target, env).load_css_module, 4 | load_css: require('./load_css')(target, env).load_css, 5 | load_js: require('./load_js')(target, env).load_js, 6 | alias: require('./alias')(target, env) 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /scripts/webpack/config/webpack.config.parts/alias.js: -------------------------------------------------------------------------------- 1 | const paths = require('../paths'); 2 | module.exports = () => { 3 | const alias = () => { 4 | return { 5 | resolve: { 6 | modules: ['node_modules', paths.appClientDir], 7 | alias: { 8 | shared: paths.appSharedDir 9 | } 10 | } 11 | }; 12 | }; 13 | return alias; 14 | }; 15 | -------------------------------------------------------------------------------- /src/client/components/not-found/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Status from 'components/status'; 3 | class NotFound extends Component { 4 | render() { 5 | return ( 6 | 7 |
8 |

Sorry, can’t find that.

9 |
10 |
11 | ); 12 | } 13 | } 14 | 15 | export default NotFound; 16 | -------------------------------------------------------------------------------- /scripts/webpack/config/webpack.config.parts/load_js.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | const load_js = ({ include, exclude }) => ({ 3 | module: { 4 | rules: [ 5 | { 6 | test: /\.(js|jsx|mjs)$/, 7 | use: 'babel-loader', 8 | include, 9 | exclude 10 | } 11 | ] 12 | } 13 | }); 14 | return { 15 | load_js 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /src/shared/lib/http/server.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as AxiosLogger from 'axios-logger'; 3 | const instance = axios.create(); 4 | instance.interceptors.request.use(AxiosLogger.requestLogger); 5 | instance.interceptors.response.use( 6 | response => { 7 | AxiosLogger.responseLogger(response); 8 | return response; 9 | }, 10 | err => { 11 | return Promise.reject(err); 12 | } 13 | ); 14 | export default instance; 15 | -------------------------------------------------------------------------------- /scripts/webpack/config/createConfig.js: -------------------------------------------------------------------------------- 1 | module.exports = (target = 'web', env = 'dev') => { 2 | const IS_PROD = env === 'prod'; 3 | // 设置 TARGET 和 NODE_ENV 控制 webpack的config内容 4 | process.env.NODE_ENV = IS_PROD ? 'production' : 'development'; 5 | process.env.TARGET = target; 6 | if (target === 'web') { 7 | return require('./webpack.config.client')(target, env); 8 | } else { 9 | return require('./webpack.config.server')(target, env); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/client/components/redirect-with-status/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect, Route } from 'react-router-dom'; 3 | const RedirectWithStatus = ({ from, to, status, exact }) => ( 4 | { 6 | if (staticContext) { 7 | staticContext.status = status; 8 | } 9 | return ; 10 | }} 11 | /> 12 | ); 13 | 14 | export default RedirectWithStatus; 15 | -------------------------------------------------------------------------------- /src/client/components/status/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Route } from 'react-router-dom'; 3 | 4 | class Status extends Component { 5 | render() { 6 | return ( 7 | { 9 | if (staticContext) { 10 | staticContext.status = this.props.code; 11 | } 12 | return this.props.children; 13 | }} 14 | /> 15 | ); 16 | } 17 | } 18 | 19 | export default Status; 20 | -------------------------------------------------------------------------------- /src/client/entry/models/index.js: -------------------------------------------------------------------------------- 1 | import { init } from '@rematch/core'; 2 | import immerPlugin from '@rematch/immer'; 3 | import { news } from './news'; 4 | const initPlugin = initialState => { 5 | return { 6 | config: { 7 | redux: { 8 | initialState 9 | } 10 | } 11 | }; 12 | }; 13 | 14 | export function createStore(initialState) { 15 | const store = init({ 16 | models: { news }, 17 | plugins: [immerPlugin(), initPlugin(initialState)] 18 | }); 19 | return store; 20 | } 21 | -------------------------------------------------------------------------------- /src/.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | const env = api.env(); 3 | console.log('env:', env); 4 | return { 5 | presets: [ 6 | [ 7 | '@babel/env', 8 | { 9 | modules: 'commonjs', 10 | useBuiltIns: 'usage' 11 | } 12 | ], 13 | '@babel/react' 14 | ], 15 | plugins: [ 16 | '@babel/plugin-proposal-class-properties', 17 | '@babel/plugin-syntax-dynamic-import', 18 | 'react-loadable/babel', 19 | 'babel-plugin-macros' 20 | ] 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/client/components/layout/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | export default class Layout extends React.Component { 3 | render() { 4 | return ( 5 |
6 | 14 | {this.props.children} 15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/server/views/home.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SSR with RR 5 | 6 | {% for item in css_bundles %} 7 | 8 | {% endfor %} 9 | 10 | 11 | 12 |
{{markup|safe}}
13 | 14 | {% for item in js_bundles %} 15 | 16 | {% endfor %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/client/components/comment/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Comments extends React.Component { 4 | render() { 5 | const { comment } = this.props; 6 | return ( 7 |
  • 8 |
    9 | [-] 10 | {comment.by} 11 | {comment.time} 12 |
    13 |
    17 |
  • 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/client/lib/util.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export function relativeTime(time) { 4 | return moment(new Date(time * 1000)).fromNow(); 5 | } 6 | 7 | export function domain(url) { 8 | return url && url.split('/')[2]; 9 | } 10 | 11 | export function delay(ms) { 12 | return new Promise(resolve => { 13 | return setTimeout(resolve, ms); 14 | }); 15 | } 16 | export async function mockSuccess(delay_ms, result = {}) { 17 | await delay(delay_ms); 18 | return result; 19 | } 20 | export async function mockFail(delay_ms) { 21 | await delay(delay_ms); 22 | throw 'error'; //tslint:disable-line 23 | } 24 | -------------------------------------------------------------------------------- /src/client/containers/home/feed/index.js: -------------------------------------------------------------------------------- 1 | import Article from 'components/article'; 2 | import Layout from 'components/layout'; 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | class Feed extends React.Component { 6 | render() { 7 | const { list } = this.props; 8 | return ( 9 | 10 |
    11 | {list.map((item, idx) => ( 12 |
    13 | ))} 14 | 15 | ); 16 | } 17 | } 18 | const mapState = state => { 19 | return { 20 | list: state.news.list 21 | }; 22 | }; 23 | const mapDispatch = dispatch => { 24 | return { 25 | loadList: dispatch.news.loadList 26 | }; 27 | }; 28 | 29 | export default connect( 30 | mapState, 31 | mapDispatch 32 | )(Feed); 33 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:prettier/recommended", "eslint:recommended"], 3 | "plugins": ["react"], 4 | "parser": "babel-eslint", 5 | "env": { 6 | "browser": true, 7 | "commonjs": true, 8 | "node": true, 9 | "es6": true 10 | }, 11 | "parserOptions": { 12 | "ecmaVersion": 8, 13 | "sourceType": "module", 14 | "ecmaFeatures": { 15 | "jsx": true 16 | } 17 | }, 18 | "rules": { 19 | "no-console": "error", 20 | "strict": ["error", "global"], 21 | "curly": "warn", 22 | "no-undef": "error", 23 | "no-unused-vars": "error", 24 | "react/jsx-uses-react": "error", 25 | "react/jsx-uses-vars": "error", 26 | "react/jsx-no-undef": "error", 27 | "react/react-in-jsx-scope": "error" 28 | 29 | 30 | }, 31 | "globals": { 32 | "__BROWSER__":true, 33 | "__NODE__": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /scripts/webpack/config/paths.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const appDirectory = fs.realpathSync(process.cwd()); 4 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 5 | 6 | module.exports = { 7 | appLoadableManifest: resolveApp('output/react-loadable.json'), // module到chunk的映射文件 8 | appManifest: resolveApp('output/manifest.json'), // client编译manifest 9 | appBuild: resolveApp('output'), //client && server编译生成目录 10 | appSrc: resolveApp('src'), // cliet && server source dir 11 | appPath: resolveApp('.'), // 项目根目录 12 | dotenv: resolveApp('.env'), // .env文件 13 | appClientDir: resolveApp('src/client'), // client目录 14 | appServerDir: resolveApp('src/server'), // server目录 15 | appSharedDir: resolveApp('src/shared'), // shared目录 16 | appClientEntry: resolveApp('src/client/entry'), // client 的webpack入口 17 | appServerEntry: resolveApp('src/server/app') // server 的webpack入口 18 | }; 19 | -------------------------------------------------------------------------------- /src/shared/service/news.js: -------------------------------------------------------------------------------- 1 | import { serverUrl } from 'constants/url'; 2 | import http from 'shared/lib/http'; 3 | async function request(api, opts) { 4 | const result = await http.get(`${serverUrl}/${api}`, opts); 5 | return result.data; 6 | } 7 | async function getTopStories(page = 1, pageSize = 10) { 8 | let idList = []; 9 | try { 10 | idList = await request('topstories.json', { 11 | params: { 12 | page, 13 | pageSize 14 | } 15 | }); 16 | } catch (err) { 17 | idList = []; 18 | } 19 | // parallel GET detail 20 | const newsList = await Promise.all( 21 | idList.slice(0, 10).map(id => { 22 | return request(`item/${id}.json`); 23 | }) 24 | ); 25 | return newsList; 26 | } 27 | 28 | async function getItem(id) { 29 | return await request(`item/${id}.json`); 30 | } 31 | 32 | async function getUser(id) { 33 | return await request(`user/${id}.json`); 34 | } 35 | 36 | export { getTopStories, getItem, getUser }; 37 | -------------------------------------------------------------------------------- /scripts/webpack/config/webpack.config.client.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.config.base'); 2 | const merge = require('webpack-merge'); 3 | const webpack = require('webpack'); 4 | const manifestPlugin = require('webpack-manifest-plugin'); 5 | const { ReactLoadablePlugin } = require('react-loadable/webpack'); 6 | const paths = require('./paths'); 7 | module.exports = (target, env) => 8 | merge(baseConfig(target, env), { 9 | entry: { 10 | main: paths.appClientEntry 11 | }, 12 | target: 'web', 13 | mode: 'development', 14 | devtool: 'source-map', 15 | output: { 16 | filename: '[name].[chunkhash:8].js', 17 | chunkFilename: '[name].chunk.[chunkhash:8].js', 18 | }, 19 | plugins: [ 20 | new manifestPlugin(), 21 | new webpack.DefinePlugin({ 22 | __BROWSER__: JSON.stringify(true), 23 | __NODE__: JSON.stringify(false) 24 | }), 25 | new ReactLoadablePlugin({ 26 | filename: paths.appLoadableManifest 27 | }) 28 | ] 29 | }); 30 | -------------------------------------------------------------------------------- /scripts/webpack/config/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const paths = require('./paths'); 3 | const webpack = require('webpack'); 4 | const merge = require('webpack-merge'); 5 | const root = process.cwd(); 6 | const { getClientEnv } = require('./env'); 7 | const envs = getClientEnv(); 8 | 9 | const baseConfig = (target, env) => { 10 | const parts = require('./webpack.config.parts')(target, env); 11 | return merge( 12 | { 13 | context: process.cwd(), 14 | watch: true, 15 | mode: 'production', 16 | output: { 17 | path: path.join(root, 'output'), 18 | filename: 'server.js', 19 | publicPath: '/' 20 | }, 21 | plugins: [new webpack.DefinePlugin(envs.stringified)] 22 | }, 23 | parts.load_css_module({ exclude: [paths.appBuild] }), 24 | parts.load_css({ 25 | include: [paths.appSrc], 26 | exclude: [paths.appBuild, /\.module\.css$/] 27 | }), 28 | parts.load_js({ exclude: paths.appBuild }), 29 | parts.alias() 30 | ); 31 | }; 32 | 33 | module.exports = baseConfig; 34 | -------------------------------------------------------------------------------- /src/client/entry/index.js: -------------------------------------------------------------------------------- 1 | import App from './app'; 2 | import { Provider } from 'react-redux'; 3 | import { BrowserRouter, StaticRouter } from 'react-router-dom'; 4 | import Loadable from 'react-loadable'; 5 | import routes from './routers'; 6 | import ReactDOM from 'react-dom'; 7 | import React from 'react'; 8 | import { createStore } from './models'; 9 | 10 | const clientRender = async () => { 11 | const store = createStore(window.__INITIAL_STATE__); 12 | await Loadable.preloadReady(); 13 | return ReactDOM.hydrate( 14 | 15 | 16 | 17 | 18 | , 19 | document.getElementById('root') 20 | ); 21 | }; 22 | 23 | const serverRender = props => { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default (__BROWSER__ ? clientRender() : serverRender); 34 | 35 | export { serverRender as App, routes, createStore }; 36 | -------------------------------------------------------------------------------- /src/client/components/article/index.js: -------------------------------------------------------------------------------- 1 | import * as Util from 'lib/util'; 2 | import * as React from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | export default class Article extends React.Component { 5 | static defaultProps = { 6 | index: 0 7 | }; 8 | render() { 9 | const { item, index } = this.props; 10 | return ( 11 |
    12 | {index}. 13 |

    14 | 15 | {item.title} 16 | 17 | ({Util.domain(item.url)}) 18 |

    19 |

    20 | 21 | {item.score} points by{' '} 22 | {item.by} 23 | 24 | {Util.relativeTime(item.time)} 25 | 26 | |{' '} 27 | 28 | {item.descendants} comments 29 | 30 | 31 |

    32 |
    33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/client/entry/models/news.js: -------------------------------------------------------------------------------- 1 | import { getItem, getTopStories, getUser } from 'shared/service/news'; 2 | 3 | export const news = { 4 | state: { 5 | detail: { 6 | item: {}, 7 | comments: [] 8 | }, 9 | user: {}, 10 | list: [] 11 | }, 12 | reducers: { 13 | updateUser(state, payload) { 14 | state.user = payload; 15 | }, 16 | updateList(state, payload) { 17 | state.list = payload; 18 | }, 19 | updateDetail(state, payload) { 20 | state.detail = payload; 21 | } 22 | }, 23 | effects: dispatch => ({ 24 | async loadUser(user_id) { 25 | const userInfo = await getUser(user_id); 26 | dispatch.news.updateUser(userInfo); 27 | }, 28 | async loadList(page = 1) { 29 | const list = await getTopStories(page); 30 | dispatch.news.updateList(list); 31 | }, 32 | async loadDetail(item_id) { 33 | const newsInfo = await getItem(item_id); 34 | const commentList = await Promise.all( 35 | newsInfo.kids.map(_id => getItem(_id)) 36 | ); 37 | dispatch.news.updateDetail({ 38 | item: newsInfo, 39 | comments: commentList 40 | }); 41 | } 42 | }) 43 | }; 44 | -------------------------------------------------------------------------------- /src/client/containers/home/detail/index.js: -------------------------------------------------------------------------------- 1 | import Article from 'components/article'; 2 | import Comment from 'components/comment'; 3 | import Layout from 'components/layout'; 4 | import React from 'react'; 5 | import { connect } from 'react-redux'; 6 | 7 | class Detail extends React.Component { 8 | render() { 9 | const { item, comments } = this.props; 10 | return ( 11 | 12 |
    13 |
    14 | {comments.length > 0 && ( 15 |
      16 | {comments.map(comment => ( 17 | 18 | ))} 19 |
    20 | )} 21 | {comments.length === 0 &&

    No comments yet.

    } 22 |
    23 |
    24 | ); 25 | } 26 | } 27 | const mapState = state => { 28 | return { 29 | item: state.news.detail.item, 30 | comments: state.news.detail.comments 31 | }; 32 | }; 33 | const mapDispath = dispatch => { 34 | return { 35 | loadDetail: dispatch.news.loadDetail 36 | }; 37 | }; 38 | export default connect( 39 | mapState, 40 | mapDispath 41 | )(Detail); 42 | -------------------------------------------------------------------------------- /scripts/webpack/config/webpack.config.server.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.config.base'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | const webpack = require('webpack'); 4 | const copyWebpackPlugin = require('copy-webpack-plugin'); 5 | const merge = require('webpack-merge'); 6 | const path = require('path'); 7 | const paths = require('./paths'); 8 | 9 | module.exports = (target, env) => 10 | merge(baseConfig(target, env), { 11 | node: { 12 | __console: false, 13 | __dirname: false, 14 | __filename: false 15 | }, 16 | mode: 'development', 17 | devtool: 'source-map', 18 | entry: paths.appServerEntry, 19 | target: 'node', 20 | output: { 21 | filename: 'server.js', 22 | libraryTarget: 'commonjs2' 23 | }, 24 | externals: [nodeExternals()], 25 | plugins: [ 26 | new webpack.optimize.LimitChunkCountPlugin({ 27 | maxChunks: 1 28 | }), // ssr 情况下禁止前端拆包 29 | new copyWebpackPlugin([ 30 | { 31 | from: path.join(paths.appServerDir, 'views'), 32 | to: path.join(paths.appBuild, 'views') 33 | } 34 | ]), 35 | new webpack.DefinePlugin({ 36 | __BROWSER__: JSON.stringify(false), 37 | __NODE__: JSON.stringify(true) 38 | }) 39 | ] 40 | }); 41 | -------------------------------------------------------------------------------- /src/client/entry/routers.js: -------------------------------------------------------------------------------- 1 | import NotFound from 'components/not-found'; 2 | import Loading from 'components/loading'; 3 | import Loadable from 'react-loadable'; 4 | export default [ 5 | { 6 | name: 'detail', 7 | path: '/news/item/:item_id', 8 | component: Loadable({ 9 | loader: () => 10 | import(/* webpackChunkName: "detail" */ '../containers/home/detail'), 11 | loading: Loading, 12 | delay: 500 13 | }), 14 | async asyncData({ dispatch }, { params }) { 15 | await dispatch.news.loadDetail(params.item_id); 16 | } 17 | }, 18 | { 19 | name: 'user', 20 | path: '/news/user/:user_id', 21 | component: Loadable({ 22 | loader: () => 23 | import(/* webpackChunkName: "user" */ '../containers/home/user'), 24 | loading: Loading, 25 | delay: 500 26 | }), 27 | async asyncData(store, { params }) { 28 | await store.dispatch.news.loadUser(params.user_id); 29 | } 30 | }, 31 | { 32 | name: 'feed', 33 | path: '/news/feed/:page', 34 | component: Loadable({ 35 | loader: () => 36 | import(/* webpackChunkName: "feed" */ '../containers/home/feed'), 37 | loading: Loading, 38 | delay: 500 39 | }), 40 | async asyncData(store, { params }) { 41 | await store.dispatch.news.loadList(params.page); 42 | } 43 | }, 44 | { 45 | name: '404', 46 | component: NotFound 47 | } 48 | ]; 49 | -------------------------------------------------------------------------------- /scripts/webpack/config/env.js: -------------------------------------------------------------------------------- 1 | const paths = require('./paths'); 2 | const NODE_ENV = process.env.NODE_ENV; 3 | const fs = require('fs'); 4 | const dotenv = require('dotenv'); 5 | if (!NODE_ENV) { 6 | throw new Error( 7 | '!The NODE_ENV environtment variable is required but not defined' 8 | ); 9 | } 10 | // 一次加载.env.production.local, .env.production, .env.local, .env,前者优先级最高 11 | const dotenvFiles = [ 12 | `${paths.dotenv}.${NODE_ENV}.local`, 13 | `${paths.dotenv}.${NODE_ENV}`, 14 | `${paths.dotenv}.local`, 15 | paths.dotenv 16 | ]; 17 | dotenvFiles.forEach(dotenvFile => { 18 | if (fs.existsSync(dotenvFile)) { 19 | dotenv.config({ 20 | path: dotenvFile 21 | }); 22 | } 23 | }); 24 | const getClientEnv = () => { 25 | const raw = { 26 | NODE_ENV: process.env.NODE_ENV || 'development', 27 | PORT: process.env.PORT || 3000, 28 | PUBLIC_PATH: process.env.PUBLIC_PATH || '/', 29 | ENABLE_SPLIT: process.env.ENABLE_SPLIT || true, 30 | appLoadalbeManifest: paths.appLoadableManifest, 31 | appManifest: paths.appManifest, 32 | appBuild: paths.appBuild, 33 | appSrc: paths.appSrc, 34 | appPath: paths.appPath 35 | }; 36 | const stringified = Object.keys(raw).reduce((env, key) => { 37 | env[`process.env.${key}`] = JSON.stringify(raw[key]); 38 | return env; 39 | }, {}); 40 | console.log('raw:', raw); 41 | return { 42 | raw, 43 | stringified 44 | }; 45 | }; 46 | module.exports = { 47 | getClientEnv 48 | }; 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | # 编译生成目录 79 | output/ 80 | 81 | # 本地env 82 | .env.local -------------------------------------------------------------------------------- /src/client/containers/home/user/index.js: -------------------------------------------------------------------------------- 1 | import Layout from 'components/layout'; 2 | import * as Util from 'lib/util'; 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | class User extends React.Component { 6 | render() { 7 | const { user } = this.props; 8 | return ( 9 | 10 |
    11 |
      12 |
    • 13 | user: {user.id} 14 |
    • 15 |
    • 16 | created:{' '} 17 | {Util.relativeTime(user.created)} 18 |
    • 19 |
    • 20 | karma: {user.karma} 21 |
    • 22 |
    • 23 | about: 24 |
      28 |
    • 29 |
    30 |

    31 | 32 | submissions 33 | 34 |
    35 | 36 | comments 37 | 38 |

    39 |
    40 |
    41 | ); 42 | } 43 | } 44 | const mapState = state => { 45 | return { 46 | user: state.news.user 47 | }; 48 | }; 49 | const mapDispatch = dispatch => { 50 | return { 51 | loadUser: dispatch.news.loadUser 52 | }; 53 | }; 54 | export default connect( 55 | mapState, 56 | mapDispatch 57 | )(User); 58 | -------------------------------------------------------------------------------- /src/client/entry/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Switch, Route, matchPath, withRouter } from 'react-router-dom'; 4 | import RedirectWithStatus from 'components/redirect-with-status'; 5 | import Routers from './routers'; 6 | import './index.scss'; 7 | class Home extends React.Component { 8 | componentDidMount() { 9 | const { history } = this.props; // 客户端的数据预取操作 10 | this.unlisten = history.listen(async location => { 11 | for (const route of Routers) { 12 | const match = matchPath(location.pathname, route); 13 | if (match) { 14 | await route.asyncData({ dispatch: this.props.dispatch }, match); 15 | } 16 | } 17 | }); 18 | } 19 | componentWillUnmount() { 20 | this.unlisten(); 21 | } 22 | render() { 23 | return ( 24 |
    25 | 26 | 32 | {Routers.map(({ name, path, component }) => { 33 | return ; 34 | })} 35 | 41 | {Routers.map(({ name, path, component }) => { 42 | return ; 43 | })} 44 | 45 |
    46 | ); 47 | } 48 | } 49 | 50 | const mapDispatch = dispatch => { 51 | return { 52 | dispatch 53 | }; 54 | }; 55 | 56 | export default withRouter( 57 | connect( 58 | undefined, 59 | mapDispatch 60 | )(Home) 61 | ); 62 | -------------------------------------------------------------------------------- /scripts/webpack/build.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'production'; 2 | require('./config/env'); 3 | require('colors'); 4 | process.on('unhandledRejection', err => { 5 | console.error('error:', err); 6 | throw err; 7 | }); 8 | const paths = require('./config/paths'); 9 | const fs = require('fs-extra'); 10 | const createConfig = require('./config/createConfig'); 11 | const formatWeebpackMessages = require('react-dev-utils/formatWebpackMessages'); 12 | const webpack = require('webpack'); 13 | async function build() { 14 | fs.emptyDirSync(paths.appBuild); 15 | const clientConfig = createConfig('web', 'prod'); 16 | const serverConfig = createConfig('node', 'web'); 17 | console.log('Production build'); 18 | console.log('Compiling client....'); 19 | try { 20 | await compile(clientConfig); 21 | } catch (err) { 22 | console.error(`Build client error: ${err.message}`.red); 23 | process.exit(1); 24 | } 25 | console.log('Build client success'.green); 26 | console.log('Compiling server....'); 27 | try { 28 | await compile(serverConfig); 29 | } catch (err) { 30 | console.error(`Build server error: ${err.message}`.red); 31 | process.exit(1); 32 | } 33 | console.log('Build server success'.green); 34 | } 35 | function compile(config) { 36 | let compiler; 37 | try { 38 | compiler = webpack(config); 39 | } catch (e) { 40 | console.error('Failed to compile.', e.message); 41 | process.exit(1); 42 | } 43 | return new Promise((resolve, reject) => { 44 | compiler.run((err, stats) => { 45 | if (err) { 46 | reject(err); 47 | } else { 48 | const messages = formatWeebpackMessages(stats.toJson({}, true)); 49 | if (messages.errors.length) { 50 | return reject(new Error(messages.errors.join('\n\n'))); 51 | } else { 52 | resolve(stats); 53 | } 54 | } 55 | }); 56 | }); 57 | } 58 | build(); 59 | -------------------------------------------------------------------------------- /src/server/server.js: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import React from 'react'; 3 | import serve from 'koa-static'; 4 | import path from 'path'; 5 | import koaNunjucks from 'koa-nunjucks-2'; 6 | import { matchPath } from 'react-router-dom'; 7 | import { renderToString } from 'react-dom/server'; 8 | import { getBundles } from 'react-loadable/webpack'; 9 | import Loadable from 'react-loadable'; 10 | import serialize from 'serialize-javascript'; 11 | import { App, createStore, routes } from '../client/entry'; 12 | const manifest = require(process.env.appManifest); 13 | const stats = require(process.env.appLoadalbeManifest); 14 | 15 | const app = new Koa(); 16 | app.use(async (ctx, next) => { 17 | try { 18 | await next(); 19 | } catch (err) { 20 | // eslint-disable-next-line no-console 21 | console.error('exception:', err); 22 | ctx.status = 500; 23 | } 24 | }); 25 | app.use(serve(process.env.appBuild)); 26 | app.use( 27 | koaNunjucks({ 28 | ext: 'njk', 29 | path: path.join(__dirname, 'views'), 30 | configureEnvironment: env => { 31 | env.addGlobal('serialize', obj => serialize(obj, { isJSON: true })); 32 | } 33 | }) 34 | ); 35 | app.use(async ctx => { 36 | const store = createStore(); 37 | const context = {}; 38 | const promises = []; 39 | routes.some(route => { 40 | const match = matchPath(ctx.url, route); 41 | if (match) { 42 | route.asyncData && promises.push(route.asyncData(store, match)); 43 | } 44 | }); 45 | await Promise.all(promises); 46 | const modules = []; 47 | const markup = renderToString( 48 | modules.push(moduleName)}> 49 | 50 | 51 | ); 52 | const bundles = getBundles(stats, modules); 53 | // eslint-disable-next-line 54 | console.log('bundles:', modules,bundles); 55 | const js_bundles = bundles.filter(({ file }) => file.endsWith('.js')); 56 | const css_bundles = bundles.filter(({ file }) => file.endsWith('.css')); 57 | if (context.url) { 58 | ctx.status = context.status; 59 | ctx.redirect(context.url); 60 | return; 61 | } 62 | await ctx.render('home', { 63 | markup, 64 | initial_state: store.getState(), 65 | manifest, 66 | css_bundles, 67 | js_bundles 68 | }); 69 | }); 70 | export async function startServer() { 71 | await Loadable.preloadAll(); 72 | app.listen(process.env.PORT || 3000, () => { 73 | // eslint-disable-next-line no-console 74 | console.log('start server at port:', process.env.PORT || 3000); 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ssr-learn", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "dev": "webpack -w & nodemon output/server.js", 7 | "clean": "rimraf output", 8 | "start": "node output/server.js", 9 | "build": "node ./scripts/webpack/build.js", 10 | "codesandbox": "codesandbox ./ ", 11 | "now": "now" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 18 | "@babel/polyfill": "^7.0.0", 19 | "@rematch/core": "^1.0.6", 20 | "@rematch/immer": "^1.1.0", 21 | "axios": "^0.18.0", 22 | "axios-logger": "^0.1.2", 23 | "babel-plugin-dynamic-import-node": "^2.2.0", 24 | "copy-webpack-plugin": "^4.6.0", 25 | "dotenv": "^6.1.0", 26 | "koa": "^2.6.1", 27 | "koa-nunjucks-2": "^3.0.2", 28 | "koa-router": "^7.4.0", 29 | "koa-static": "^5.0.0", 30 | "moment": "^2.22.2", 31 | "node-sass": "^4.10.0", 32 | "nunjucks": "^3.1.4", 33 | "react": "^16.6.0", 34 | "react-dom": "^16.6.0", 35 | "react-loadable": "^5.5.0", 36 | "react-redux": "^5.1.0", 37 | "react-router-dom": "^4.3.1", 38 | "resolve-url-loader": "^3.0.0", 39 | "sass-loader": "^7.1.0", 40 | "serialize-javascript": "^1.5.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "^7.1.2", 44 | "@babel/core": "^7.1.2", 45 | "@babel/node": "^7.0.0", 46 | "@babel/plugin-proposal-class-properties": "^7.1.0", 47 | "@babel/preset-env": "^7.1.0", 48 | "@babel/preset-react": "^7.0.0", 49 | "autoprefixer": "^9.3.1", 50 | "babel-eslint": "^10.0.1", 51 | "babel-loader": "^8.0.4", 52 | "babel-plugin-macros": "^2.4.2", 53 | "colors": "^1.3.2", 54 | "css-loader": "^1.0.1", 55 | "eslint": "^5.8.0", 56 | "eslint-config-prettier": "^3.1.0", 57 | "eslint-plugin-prettier": "^3.0.0", 58 | "eslint-plugin-react": "^7.11.1", 59 | "fs-extra": "^7.0.0", 60 | "husky": "^1.1.3", 61 | "import-all.macro": "^2.0.3", 62 | "lint-staged": "^8.0.4", 63 | "mini-css-extract-plugin": "^0.4.4", 64 | "nodemon": "^1.18.5", 65 | "npm-run-all": "^4.1.3", 66 | "postcss-flexbugs-fixes": "^4.1.0", 67 | "postcss-loader": "^3.0.0", 68 | "prettier": "^1.14.3", 69 | "react-dev-utils": "^6.1.0", 70 | "rimraf": "^2.6.2", 71 | "style-loader": "^0.23.1", 72 | "stylelint": "^9.7.1", 73 | "stylelint-config-standard": "^18.2.0", 74 | "webpack": "^4.23.1", 75 | "webpack-cli": "^3.1.2", 76 | "webpack-dev-server": "^3.1.10", 77 | "webpack-manifest-plugin": "^2.0.4", 78 | "webpack-merge": "^4.1.4", 79 | "webpack-node-externals": "^1.7.2" 80 | }, 81 | "husky": { 82 | "hooks": { 83 | "pre-commit": "lint-staged" 84 | } 85 | }, 86 | "lint-staged": { 87 | "src/**/*.{css,scss}": [ 88 | "prettier --write", 89 | "stylelint --syntax=scss --fix", 90 | "git add" 91 | ], 92 | "src/**/*.{js,jsx,ts,tsx}": [ 93 | "eslint --fix", 94 | "git add" 95 | ] 96 | }, 97 | "prettier": { 98 | "singleQuote": true, 99 | "semi": true 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/client/entry/index.scss: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | font-family: Verdana; 4 | font-size: 13px; 5 | height: 100%; 6 | } 7 | 8 | ul { 9 | list-style-type: none; 10 | padding: 0; 11 | margin: 0; 12 | } 13 | 14 | a { 15 | color: #000; 16 | cursor: pointer; 17 | text-decoration: none; 18 | } 19 | 20 | #wrapper { 21 | background-color: #f6f6ef; 22 | width: 85%; 23 | min-height: 80px; 24 | margin: 0 auto; 25 | } 26 | 27 | #header, 28 | #wrapper { 29 | position: relative; 30 | } 31 | 32 | #header { 33 | background-color: #f60; 34 | height: 24px; 35 | } 36 | 37 | #header h1 { 38 | font-weight: 700; 39 | font-size: 13px; 40 | display: inline-block; 41 | vertical-align: middle; 42 | margin: 0; 43 | } 44 | 45 | #header .source { 46 | color: #fff; 47 | font-size: 11px; 48 | position: absolute; 49 | top: 4px; 50 | right: 4px; 51 | } 52 | 53 | #header .source a { 54 | color: #fff; 55 | margin-left: 10px; 56 | } 57 | 58 | #header .source a:hover { 59 | text-decoration: underline; 60 | } 61 | 62 | #yc { 63 | border: 1px solid #fff; 64 | margin: 2px; 65 | display: inline-block; 66 | } 67 | 68 | #yc, 69 | #yc img { 70 | vertical-align: middle; 71 | } 72 | 73 | .view { 74 | position: absolute; 75 | background-color: #f6f6ef; 76 | width: 100%; 77 | -webkit-transition: opacity 0.2s ease; 78 | transition: opacity 0.2s ease; 79 | box-sizing: border-box; 80 | padding: 8px 20px; 81 | } 82 | 83 | .view.v-enter, 84 | .view.v-leave { 85 | opacity: 0; 86 | } 87 | 88 | @media screen and (max-width: 700px) { 89 | body, 90 | html { 91 | margin: 0; 92 | } 93 | 94 | #wrapper { 95 | width: 100%; 96 | } 97 | } 98 | 99 | .news-view { 100 | padding-left: 5px; 101 | padding-right: 15px; 102 | } 103 | 104 | .news-view.loading::before { 105 | content: 'Loading...'; 106 | position: absolute; 107 | top: 16px; 108 | left: 20px; 109 | } 110 | 111 | .news-view .nav { 112 | padding: 10px 10px 10px 40px; 113 | margin-top: 10px; 114 | border-top: 2px solid #f60; 115 | } 116 | 117 | .news-view .nav a { 118 | margin-right: 10px; 119 | } 120 | 121 | .news-view .nav a:hover { 122 | text-decoration: underline; 123 | } 124 | 125 | .item { 126 | padding: 2px 0 2px 40px; 127 | position: relative; 128 | -webkit-transition: background-color 0.2s ease; 129 | transition: background-color 0.2s ease; 130 | } 131 | 132 | .item p { 133 | margin: 2px 0; 134 | } 135 | 136 | .item .index, 137 | .item .title:visited { 138 | color: #828282; 139 | } 140 | 141 | .item .index { 142 | position: absolute; 143 | width: 30px; 144 | text-align: right; 145 | left: 0; 146 | top: 4px; 147 | } 148 | 149 | .item .domain, 150 | .item .subtext { 151 | font-size: 11px; 152 | color: #828282; 153 | } 154 | 155 | .item .domain a, 156 | .item .subtext a { 157 | color: #828282; 158 | } 159 | 160 | .item .subtext a:hover { 161 | text-decoration: underline; 162 | } 163 | 164 | .item-view .item { 165 | padding-left: 0; 166 | margin-bottom: 30px; 167 | } 168 | 169 | .item-view .item .index { 170 | display: none; 171 | } 172 | 173 | .item-view .poll-options { 174 | margin-left: 30px; 175 | margin-bottom: 40px; 176 | } 177 | 178 | .item-view .poll-options li { 179 | margin: 12px 0; 180 | } 181 | 182 | .item-view .poll-options p { 183 | margin: 8px 0; 184 | } 185 | 186 | .item-view .poll-options .subtext { 187 | color: #828282; 188 | font-size: 11px; 189 | } 190 | 191 | .item-view .itemtext { 192 | color: #828282; 193 | margin-top: 0; 194 | margin-bottom: 30px; 195 | } 196 | 197 | .item-view .itemtext p { 198 | margin: 10px 0; 199 | } 200 | 201 | .comhead { 202 | font-size: 11px; 203 | margin-bottom: 8px; 204 | } 205 | 206 | .comhead, 207 | .comhead a { 208 | color: #828282; 209 | } 210 | 211 | .comhead a:hover { 212 | text-decoration: underline; 213 | } 214 | 215 | .comhead .toggle { 216 | margin-right: 4px; 217 | } 218 | 219 | .comment-content { 220 | margin: 0 0 16px 24px; 221 | word-wrap: break-word; 222 | } 223 | 224 | .comment-content code { 225 | white-space: pre-wrap; 226 | } 227 | 228 | .child-comments { 229 | margin: 8px 0 8px 22px; 230 | } 231 | 232 | .user-view { 233 | color: #828282; 234 | } 235 | 236 | .user-view li { 237 | margin: 5px 0; 238 | } 239 | 240 | .user-view .label { 241 | display: inline-block; 242 | min-width: 60px; 243 | } 244 | 245 | .user-view .about { 246 | margin-top: 1em; 247 | } 248 | 249 | .user-view .links a { 250 | text-decoration: underline; 251 | } 252 | -------------------------------------------------------------------------------- /scripts/webpack/config/webpack.config.parts/load_css.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer'); 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | module.exports = (target, env) => { 4 | const IS_NODE = target === 'node'; 5 | const IS_DEV = env === 'dev'; 6 | const IS_PROD = env === 'prod'; 7 | const postCssOptions = { 8 | ident: 'postcss', // https://webpack.js.org/guides/migrating/#complex-options 9 | plugins: () => [ 10 | require('postcss-flexbugs-fixes'), 11 | autoprefixer({ 12 | browsers: [ 13 | '>1%', 14 | 'last 4 versions', 15 | 'Firefox ESR', 16 | 'not ie < 9' // React doesn't support IE8 anyway 17 | ], 18 | flexbox: 'no-2009' 19 | }) 20 | ] 21 | }; 22 | const postcss_loader = { 23 | loader: 'postcss-loader', 24 | options: postCssOptions 25 | }; 26 | const css_loaders = [ 27 | postcss_loader, 28 | { 29 | loader: 'resolve-url-loader' 30 | }, 31 | { 32 | loader: 'sass-loader', 33 | options: { 34 | sourceMap: true 35 | } 36 | } 37 | ]; 38 | const load_css = ({ include, exclude }) => { 39 | let css_loader_config = {}; 40 | 41 | if (IS_NODE) { 42 | // servre编译只需要能够解析css,并不需要实际的生成css文件 43 | css_loader_config = [ 44 | { 45 | loader: 'css-loader', 46 | options: { 47 | importLoaders: 1 48 | } 49 | }, 50 | ...css_loaders 51 | ]; 52 | } else if (IS_DEV) { 53 | // client 编译且为development下,使用style-loader以便支持热更新 54 | css_loader_config = [ 55 | 'style-loader', 56 | { 57 | loader: 'css-loader', 58 | options: { 59 | importLoaders: 1 60 | } 61 | }, 62 | ...css_loaders 63 | ]; 64 | } else { 65 | // client编译且为production下,需要将css抽取出单独的css文件,并且需要对css进行压缩 66 | css_loader_config = [ 67 | MiniCssExtractPlugin.loader, 68 | { 69 | loader: require.resolve('css-loader'), 70 | options: { 71 | importLoaders: 1, 72 | modules: false, // 不支持css module 73 | minimize: true // 压缩编译后生成的css文件 74 | } 75 | }, 76 | ...css_loaders 77 | ]; 78 | } 79 | return { 80 | // client && prod 下开启extractCss插件 81 | plugins: [ 82 | !IS_NODE && 83 | IS_PROD && 84 | new MiniCssExtractPlugin({ 85 | filename: 'static/css/[name].[contenthash:8].css', 86 | chunkFilename: 'static/css/[name].[contenthash:8].chunk.css', 87 | allChunks: true // 不对css进行code spliting,包含所有的css, 对css的code splitting 暂时还有些问题 88 | }) 89 | ].filter(x => x), 90 | module: { 91 | rules: [ 92 | { 93 | test: /\.(css|scss)$/, 94 | use: css_loader_config, 95 | exclude, 96 | include 97 | } 98 | ] 99 | } 100 | }; 101 | }; 102 | 103 | const load_css_module = ({ include, exclude }) => { 104 | let css_module_config = {}; 105 | if (IS_NODE) { 106 | // servre编译只需要能够解析css,并不需要实际的生成css文件 107 | css_module_config = [ 108 | { 109 | loader: 'css-loader/locals', 110 | options: { 111 | importLoaders: 1, 112 | modules: true, 113 | localIdentName: '[path]__[name]___[local]' 114 | } 115 | }, 116 | ...css_loaders 117 | ]; 118 | } else if (IS_DEV) { 119 | // client 编译且为development下,使用style-loader以便支持热更新 120 | css_module_config = [ 121 | 'style-loader', 122 | { 123 | loader: 'css-loader', 124 | options: { 125 | importLoaders: 1, 126 | modules: true, 127 | localIdentName: '[path]__[name]___[local]' 128 | } 129 | }, 130 | ...css_loaders 131 | ]; 132 | } else { 133 | // client编译且为production下,需要将css抽取出单独的css文件,并且需要对css进行压缩 134 | css_module_config = [ 135 | MiniCssExtractPlugin.loader, 136 | { 137 | loader: require.resolve('css-loader'), 138 | options: { 139 | importLoaders: 1, 140 | modules: true, 141 | localIdentName: '[path]__[name]___[local]', 142 | minimize: true // 压缩编译后生成的css文件 143 | } 144 | }, 145 | ...css_loaders 146 | ]; 147 | } 148 | return { 149 | module: { 150 | rules: [ 151 | { 152 | test: /\.module\.(css|scss)$/, 153 | use: css_module_config, 154 | include, 155 | exclude 156 | } 157 | ] 158 | } 159 | }; 160 | }; 161 | return { 162 | load_css, 163 | load_css_module 164 | }; 165 | }; 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # REACT 服务端渲染最全教程 2 | 3 | 本系列将从零到一讲述如何搭建一个支持多页面+ 单页面 + Code Split + SSR + i18n + Redux 的 HackerNews。重点讲述构建复杂 SSR 系统碰到的各种问题。所有中间过程都可以在 codesandbox 上查看。 4 | 首先编写一个最基础的 SSR 渲染页面,我们服务端使用 Koa ,前端使用 React。 5 | 6 | ### 创建 React 组件 7 | 8 | ```jsx 9 | // src/client/app.js 10 | import React from 'react'; 11 | export default class App extends React.Component { 12 | render() { 13 | return
    welcome to ssr world
    ; 14 | } 15 | } 16 | ``` 17 | 18 | ### 与服务端集成 19 | 20 | ```jsx 21 | // src/server/server.js 22 | import Koa from 'koa'; 23 | import React from 'react'; 24 | import { renderToString } from 'react-dom/server'; 25 | import App from '../client/app'; 26 | 27 | const app = new Koa(); 28 | app.use(async ctx => { 29 | const markup = renderToString(); 30 | ctx.body = ` 31 | 32 | 33 | SSR-demo1 34 | 35 | 36 | 37 |
    ${markup}
    38 | 39 | 40 | `; 41 | }); 42 | export async function startServer() { 43 | app.listen(process.env.PORT, () => { 44 | console.log('start server at port:', process.env.PORT); 45 | }); 46 | } 47 | 48 | // src/server/app.js 49 | import { startServer } from './server'; 50 | startServer(); 51 | ``` 52 | 53 | 此时的实现十分简陋,仅能够实现最基础的服务端渲染 React 组件,[在线示例:demo1](https://codesandbox.io/s/31v6pq0zk5)。 54 | 虽然代码十分简单,但是整个项目的编译+部署的过程仍然存在一些值得注意的地方。 55 | 整个项目的目录结构如下所示 56 | 57 | ```sh 58 | . 59 | ├── README.md 60 | ├── now.json // now部署配置 61 | ├── output 62 | │ └── server.js // 前后端编译生成代码 63 | ├── package-lock.json 64 | ├── package.json 65 | ├── sandbox.config.json // sandbox部署配置 66 | ├── src 67 | │ ├── .babelrc //babel配置 68 | │ ├── client 69 | │ │ └── app.js // 前端组件代码 70 | │ └── server 71 | │ ├── app.js // server运维相关逻辑 72 | │ └── server.js // server相关业务逻辑 73 | └── webpack.config.js // 编译配置 74 | ``` 75 | 76 | 我们使用 webpack 编译服务端代码,webpack 配置和一般前端代码的配置无太大区别 77 | 78 | ```js 79 | const path = require('path'); 80 | const nodeExternals = require('webpack-node-externals'); 81 | const serverConfig = { 82 | entry: './src/server/app.js', // entry指向 server的入口 83 | mode: 'development', 84 | target: 'node', // 使用类node环境(使用node.js的require来加载chunk) 85 | externals: [nodeExternals()], // webpack打包不打包node_modules里的模块 86 | output: { 87 | path: path.join(__dirname, 'output'), 88 | filename: 'server.js', 89 | publicPath: '/' 90 | }, 91 | module: { 92 | rules: [{ test: /\.(js)$/, use: 'babel-loader' }] 93 | } 94 | }; 95 | 96 | module.exports = [serverConfig]; 97 | ``` 98 | 99 | #### 代码编译 100 | 101 | 在服务端渲染的情况下,服务端需要导入 React 组件,因为 node 原生不支持 jsx 的语法,如果想直接使用 jsx 语法,势必需要对 react 组件代码进行编译。 102 | 对于服务端渲染,其代码可以分为两部分,react 组件代码(`src/client/app.js`),server 相关代码(`src/server/app.js`),根据不同的处理方式,可分为如下几种编译方式: 103 | 104 | 1. 仅对 react 组件代码使用 webpack 进行编译,server 使用原生的 js,好处是前后端完全分离。 105 | 2. 对 react 组件和 server 一起使用 babel 进行编译。好处是开发模式配置比较简单,使用 babel-node 即可,问题是需要做一些 hack。 106 | 3. 对 react 组件和 server 一起使用 webpack 进行编译。好处是尽可能复用 webpack 的配置,且使用者配置比较简单。 107 | 108 | 与前端编译不同的地方在于 109 | 110 | 1. target 为 node:使用 require 去加载 chunk 111 | 2. externals: 编译时不编译 node_modules 的模块,与前端编译不同,前端编译时需要将 node_modules 里模块打包而服务端则时在运行时加载 node_modules 里的模块,好处包括: 112 | - 减小编译文件内容加快编译速度。 113 | - 防止重复执行同一 node_module 下模块, 假如该模块存在副作用则可能会造成错误,一个常见的场景是 client 和 server 会公用一些模块例如 react-loadable,由于 node 的 require 缓存是基于路径的,如果对 client 进行了编译但没对 server 进行编译,这回导致 client 引用了 react-loadble 和 server 引用了 react-loadable,但是 client 对 react-loadable 进行了打包,导致 react-loadable 二次加载,然而 react-loadable 的加载具有副作用,导致 react-loadable 的部分功能失效。 114 | 115 | 我们同样需要进行 babel 配置,因为使用了 react, 所以需要对 babel 进行配置 116 | 117 | ```js 118 | module.exports = { 119 | presets: [ 120 | [ 121 | "@babel/env", 122 | { 123 | modules:false // module交给webpack处理,支持treeshake 124 | targets: { 125 | node: "current" 126 | } 127 | } 128 | ], 129 | "@babel/react" 130 | ] 131 | }; 132 | ``` 133 | 134 | 这里值得注意的是`@babel/env`的 module 设置为 false,可以更好地支持 treeshaking,减小最终的打包大小。 135 | 136 | ### hydrate 137 | 138 | [在线示例 2-hydrate](https://codesandbox.io/s/9469r7xxlo) 139 | 现在我们的页面只是一个纯 html 页面,并不支持任何交互,为了支持用户交互我们需要对页面进行 hydrate 操作。 140 | 此时我们不仅需要编译 server 的代码,还需要编译 client 的代码。因此我们需要两份配置文件,但是 client 和 server 的编译配置有很多共同的地方, 141 | 因此考虑使用 webpack-merge 进行复用。此时有三个配置文件 142 | 143 | ```jsx 144 | // scripts/webpack/config/webpack.config.base.js 145 | const path = require('path'); 146 | const webpack = require('webpack'); 147 | const baseConfig = { 148 | context: process.cwd(), 149 | mode: 'production', 150 | output: { 151 | path: path.join(root, 'output'), 152 | filename: 'server.js', 153 | publicPath: '/' 154 | }, 155 | module: { 156 | rules: [{ test: /\.(js)$/, use: 'babel-loader' }] 157 | } 158 | }; 159 | 160 | module.exports = baseConfig; 161 | ``` 162 | 163 | ```jsx 164 | // scripts/webpack/config/webpack.config.server.js 165 | module.exports = merge(baseConfig, { 166 | mode: 'development', 167 | devtool: 'source-map', 168 | entry: './src/server/app.js', 169 | target: 'node', 170 | output: { 171 | filename: 'server.js', 172 | libraryTarget: 'commonjs2' 173 | }, 174 | externals: [nodeExternals()] 175 | }); 176 | ``` 177 | 178 | ```jsx 179 | // scripts/webpack/config/webpack.config.client.js 180 | module.exports = merge(baseConfig, { 181 | entry: { 182 | main: './src/client/index.js' 183 | }, 184 | target: 'web', 185 | output: { 186 | filename: '[name].[chunkhash:8].js' // 设置hash用于缓存更新 187 | }, 188 | plugins: [ 189 | new manifestPlugin() // server端用于获取生成的前端文件名 190 | ] 191 | }); 192 | ``` 193 | 194 | build 后再 output 里生成信息如下: 195 | 196 | ```sh 197 | output 198 | ├── main.f695bcf8.js # client编译文件 199 | ├── manifest.json # manifest 文件 200 | ├── server.js # server编译文件 201 | └── server.js.map # server编译文件的sourcemap 202 | ``` 203 | 204 | 对于生成环境的前端代码,需要包含版本信息,以便用于版本更新,我们用 chunkhash 作为其版本号,每次代码更新后都会生成新的 hash 值,因此 205 | server 端需要获取每次编译后生成的版本信息,以用于下发正确的版本。这里有两种处理方式: 206 | 207 | 1. 使用 html-webpack-plugin 生成带有 js 版本的 html 文件,server 端直接渲染生成的 html 208 | 2. server 端通过 webpack-manifest-plugin 生成编译后的 manifest 信息,server 在自己使用的模板里插入对应的 js 代码。 209 | 第一种方式比较简单,且对于各种资源注入有很好的支持,但是这样 html-webpack-plugin 接管了 server 端的渲染逻辑,且只能生成 html 文件,server 端比较难以扩展,第二种方式需要用户自己处理各种资源注入逻辑,但是有良好的扩展性,可以轻松支持多种模板。 210 | 我们此处使用第一种方式。 211 | 212 | ### 变量注入 213 | 214 | 有些场景我们需要在代码中注入一些变量,例如 215 | 216 | - 一份代码需要运行在不同的环境,如 development,staging,production 环境,需要在代码中根据不同的环境处理不同的逻辑 217 | - 很多 node_moudles 会根据不同的 process.env.NODE_ENV 读取不同的代码,如 react 在 process.node.NODE_ENV === 'production'下会读取的是压缩后的代码,这样能保证线上的代码体积打包更小。 218 | - 不同的用户会下发不同的 参数,如在 AB 测情况下,server 会给不同用户下发 不同的参数,代码中根据不同的 参数,呈现不同的结果。 219 | 变量注入可以分为运行时注入和编译时注入 220 | 221 | #### 运行时注入 222 | 223 | 前端的运行是可以通过读取 server 端下发的 window.xxx 变量实现,比较简单, 224 | 服务端变量注入通常有两种方式配置文件 和配置环境变量。 225 | 226 | ##### 配置文件 227 | 228 | 我们可以为不同环境配置不同的 配置文件,如 eggjs 的多环境配置就是通过不同的 配置文件实现的根据 EGG_SERVER_ENV 读取不同的配置文件,其 config 如下所示, 229 | 230 | ```sh 231 | config 232 | |- config.default.js 233 | |- config.prod.js 234 | |- config.unittest.js 235 | `- config.local.js 236 | ``` 237 | 238 | 配置文件有其局限性,因为配置文件通常是和代码一起提交到代码仓库里的,不能在配置文件里存放一些机密信息,如数据库账号和密码等, 239 | 240 | ##### 环境变量 241 | 242 | 配置文件难以在运行时进行热更新,如我们需要对某些服务进行降级,需要在运行时替换掉某个变量的值。这些情况可以考虑使用环境变量进行变量注入。环境变量注入通常有如下如下用途: 243 | 244 | 1. 结合配置文件使用,根据环境变量读取不同的配置文件 245 | 2. 运行时控制:环境变量通过配置中心配置,代码运行时定时读取更新的配置变量,可以用于手动的降级控制。 246 | 247 | 有多个地方可以注入环境变量: 248 | 249 | 1. 代码注入 250 | ```js 251 | process.env.NODE_ENV = 'production' 252 | .... 253 | ``` 254 | 255 | ```` 256 | 2. 启动命令时注入 257 | ```js 258 | // package.json 259 | .... 260 | "scripts": { 261 | "build": "NODE_ENV=production webpack" 262 | } 263 | .... 264 | ```` 265 | 266 | 3. 运行环境注入,大多数的 ci 平台都支持配置环境 267 | 268 | #### 编译时注入 269 | 270 | 借助于 webpack 和 babel 强大的功能我们可以实现编译时注入变量,相比于运行时注入,编译时注入可以实现运行时注入无法实现的功能 271 | 272 | 1. 配合 webpack 的 Tree Shaking 功能,我们可以在编译时把无关代码删除 273 | 2. 可以在代码中实现 DSL,编译时替换为实际的 js 代码。 274 | 275 | 有两种方法可以实现编译时注入 276 | 277 | 1. [DefinePlugin](https://webpack.docschina.org/plugins/define-plugin/),DefinePlugin 允许创建一个在编译时可以配置的全局变量。这可能会不同的环境变量编译出不同版本的代码。一个最简单的场景就是通过 process.env.NODE_ENV 控制加载的版本,babel-plugin-transform-define 也可以实现相同功能。 278 | 2. babel-plugin-marco 可以实现更加复杂的编译时替换功能,例如我们可以通过 babel-plugin-macro 扩充支持 import 的语法,使得其可以支持`import files * from 'dir/*'`之类的批量导入,这在很多场景下都非常有作用。 279 | 280 | 在本例子中我们通过 process.env 和 definePlugin 向项目中注入`appBuild`和`appManifest`两个变量,其默认值在`path.js`里定义 281 | 282 | ```js 283 | // scripts/webpack/config/paths.js 284 | const path = require('path'); 285 | const fs = require('fs'); 286 | const appDirectory = fs.realpathSync(process.cwd()); 287 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 288 | 289 | module.exports = { 290 | appManifest: resolveApp('output/manifest.json'), 291 | appBuild: resolveApp('output') 292 | }; 293 | ``` 294 | 295 | #### dotenv 296 | 297 | [12factory](https://12factor.net/zh_cn/config) 提倡在环境中存储配置,我们使用 dotenv 来实现在环境中存储配置。这样方便我们在不同的环境下 298 | 对覆盖进行覆盖操作。根据[rails_dotenv](https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use)的规范,我们会一次加载`${paths.dotenv}.${NODE_ENV}.local`,`${paths.dotenv}.${NODE_ENV}`,`${paths.dotenv}.local`,`paths.dotenv`配置文件,前者会覆盖后者的配置。如在本例子中我们可以在.env.production 里覆盖设置`PORT=4000`覆盖默认端口。 299 | 300 | #### 收敛配置 301 | 302 | 为了方便项目的扩展,我们将原来在项目中硬编码的一些常量配置进行统一处理,大部分和路径相关的配置收敛到`scripts/webpack/config/paths`目录下。 303 | 304 | ```js 305 | const path = require('path'); 306 | const fs = require('fs'); 307 | const appDirectory = fs.realpathSync(process.cwd()); 308 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 309 | 310 | module.exports = { 311 | appManifest: resolveApp('output/manifest.json'), // client编译manifest 312 | appBuild: resolveApp('output'), //client && server编译生成目录 313 | appSrc: resolveApp('src'), // cliet && server source dir 314 | appPath: resolveApp('.'), // 项目根目录 315 | dotenv: resolveApp('.env'), // .env文件 316 | appClientEntry: resolveApp('src/client/entry'), // client 的webpack入口 317 | appServerEntry: resolveApp('src/server/app') // server 的webpack入口 318 | }; 319 | ``` 320 | 321 | #### 配置插件化 322 | 323 | 随着项目越来越复杂,我们的 webpack 配置也会变的越来越复杂,且难以阅读和扩展,除了将 webpack 的配置拆分为 client 和 server 我们可以考虑将 wepback 的配置进行插件化,将每个扩展功能通过插件的形式集成到原有的 webpack 配置中。如本例子中可以将 js 编译的部分抽取出来 324 | 325 | ```js 326 | // webpack.config.parts.js 327 | exports.loadJS = ({ include, exclude }) => ({ 328 | module: { 329 | rules: [ 330 | { 331 | test: /\.(js|jsx|mjs)$/, 332 | use: 'babel-loader', 333 | include, 334 | exclude 335 | } 336 | ] 337 | } 338 | }); 339 | // webpack.config.js 340 | const commonConfig = merge([...parts.loadJS({ include: paths.appSrc })]); 341 | ``` 342 | 343 | ### css 支持 344 | 345 | [在线示例-css]() 346 | 我们下面增加对 css 的支持,上步中我们已将对 js 编译提取到了`webpack.config.parts`里,同理我们也把对 css 的处理外置到`webpack.config.parts`里,css 的处理比 js 的处理复杂得多。不同于 js,node 环境对 js 有原生的支持,然而对于 css,node 并不支持导入 css 模块。 347 | 对 css 的处理分为三种情形 348 | 349 | 1. server 对 CSS 的处理:最简单的处理方式是忽略掉 css 文件,因此我们可以考虑只使用`css-loader`去处理 css 模块。 350 | 2. client 在 dev 模式下对 css 的处理:client 的 dev 模式下需要支持 css 的热更新,因此需要对使用`style-loader`去处理 css 模块。 351 | 3. client 在 prod 模式下对 css 的处理:client 的 prod 模式下,需要将 css 文件抽取为独立的 css 文件,并对 css 文件进行压缩,因此需要`mini-css-extract-plugin`进行处理。 352 | 353 | ```js 354 | // webpack.config.parts.js 355 | const postCssOptions = { 356 | ident: 'postcss', 357 | plugins: () => [ 358 | require('postcss-flexbugs-fixes'), 359 | autoprefixer({ 360 | browsers: ['>1%', 'last 4 versions', 'Firefox ESR', 'not ie < 9'], 361 | flexbox: 'no-2009' 362 | }) 363 | ] 364 | }; 365 | const loadCSS = ({ include, exclude }) => { 366 | let css_loader_config = {}; 367 | const postcss_loader = { 368 | loader: 'postcss-loader', 369 | options: postCssOptions 370 | }; 371 | if (IS_NODE) { 372 | // servre编译只需要能够解析css,并不需要实际的生成css文件 373 | css_loader_config = [ 374 | { 375 | loader: 'css-loader', 376 | options: { 377 | importLoaders: 1 378 | } 379 | }, 380 | postcss_loader 381 | ]; 382 | } else if (IS_DEV) { 383 | // client 编译且为development下,使用style-loader以便支持热更新 384 | css_loader_config = [ 385 | 'style-loader', 386 | { 387 | loader: 'css-loader', 388 | options: { 389 | importLoaders: 1 390 | } 391 | }, 392 | postcss_loader 393 | ]; 394 | } else { 395 | // client编译且为production下,需要将css抽取出单独的css文件,并且需要对css进行压缩 396 | css_loader_config = [ 397 | MiniCssExtractPlugin.loader, 398 | { 399 | loader: require.resolve('css-loader'), 400 | options: { 401 | importLoaders: 1, 402 | modules: false, // 不支持css module 403 | minimize: true // 压缩编译后生成的css文件 404 | } 405 | }, 406 | { 407 | loader: require.resolve('postcss-loader'), 408 | options: postCssOptions 409 | } 410 | ]; 411 | } 412 | return { 413 | // client && prod 下开启extractCss插件 414 | plugins: [ 415 | !IS_NODE && 416 | IS_PROD && 417 | new MiniCssExtractPlugin({ 418 | filename: 'static/css/[name].[contenthash:8].css', 419 | chunkFilename: 'static/css/[name].[contenthash:8].chunk.css', 420 | allChunks: true // 不对css进行code spliting,包含所有的css, 对css的code splitting 暂时还有些问题 421 | }) 422 | ].filter(x => x), 423 | module: { 424 | rules: [ 425 | { 426 | test: /\.css$/, 427 | use: css_loader_config 428 | } 429 | ] 430 | } 431 | }; 432 | }; 433 | ``` 434 | 435 | css module 的支持和上面类似,prod 模式下,我们还需要在 html 里引入 css,使用 manifest 即可轻松实现。 436 | 437 | ```js 438 | ctx.body = ` 439 | 440 | 441 | SSR with RR 442 | # 添加对css的支持 443 | 444 | 445 | 446 |
    ${markup}
    447 | 448 | 449 | 450 | `; 451 | ``` 452 | 453 | ### Code Fence 454 | 455 | 有时我们需要控制代码只在客户端或者服务端执行,如果在 server 里直接使用了`window`或者`document`这种仅在浏览器可访问的对象,则会在 server 端报错,反之在 client 里直接发使用了`fs`这样的对象也会报错。 456 | 457 | 对于共享于服务器和客户端,但用于不同平台 API 的任务,建议将平台特定实现包含在通用 API 中,或者使用为你执行此操作的 library。例如,axios 是一个 HTTP 客户端,可以向服务器和客户端都暴露相同的 API。 458 | 459 | 对于仅浏览器可用的 API,通常方式是,1.在「纯客户端」的生命周期钩子函数中惰性访问它们(如`react`的`componentDidMount`)。 460 | 461 | 请注意,考虑到如果第三方 library 不是以上面的通用用法编写,则将其集成到服务器渲染的应用程序中,可能会很棘手。你可能要通过模拟(mock)一些全局变量来使其正常运行(如可以通过 jsdom 来 mock 浏览器的 dom 对象,进行 html 解析),但这只是 hack 的做法,并且可能会干扰到其他 library 的环境检测代码(很多的第三方库判断执行环境的代码很粗暴,通常只是判断`typeof document === 'undefined'`,这是如果你 mock 了`document`对象,会导致第三方库的判断代码出错)。 462 | 463 | 因此相比于运行时判断执行环境,我们更倾向于在编译时判断执行环境。我们使用一种称为[Code Fence](https://fusionjs.com/docs/getting-started/core-concepts/#code-fence)的技术来实现在编译时区分执行环境。 464 | 其实现方式很简单,通过 webpack 的[definePlugin](https://webpack.docschina.org/plugins/define-plugin/)为 client 和 server 定义两个不同的全局常量。 465 | 466 | ```js 467 | // webpack.config.client.js 468 | { 469 | ... 470 | plugins: [ 471 | new webpack.DefinePlugin({ 472 | __BROWSER__: JSON.stringify(true), 473 | __NODE__: JSON.stringify(false) 474 | }) 475 | ] 476 | ... 477 | } 478 | // webpack.config.server.js 479 | 480 | { 481 | ... 482 | plugins: [ 483 | new webpack.DefinePlugin({ 484 | __BROWSER__: JSON.stringify(false), 485 | __NODE__: JSON.stringify(true) 486 | }) 487 | ] 488 | ... 489 | } 490 | ``` 491 | 492 | 本例中我们就可以使用`Code Fence`来统一 client 和 server 引入 app 的入口了。由于 client 和 server 渲染执行的逻辑不一致, 493 | client 执行 hydrate 操作,而 server 端执行 renderToString 操作,导致两者导入 app 的入口无法保持一致。我们可以通过`Code Fence`在 494 | 同一个文件里为 client 和 server 导出不同的内容。 495 | 496 | ```js 497 | // src/client/entry/index.js 498 | import App from './app'; 499 | import ReactDOM from 'react-dom'; 500 | import React from 'react'; 501 | 502 | const clientRender = () => { 503 | return ReactDOM.hydrate(, document.getElementById('root')); 504 | }; 505 | 506 | const serverRender = props => { 507 | return ; 508 | }; 509 | 510 | export default (__BROWSER__ ? clientRender() : serverRender); 511 | ``` 512 | 513 | ### 页面模板支持 514 | 515 | 对于复杂的页面,直接写在模板字符串里不太现实,通常使用模板引擎来渲染页面,常见的模板引擎包括`pug`,`ejs`,`nunjuck`等。 516 | 我们这里使用`nunjuck`作为模板引擎。 517 | 518 | ```html 519 | 520 | 521 | 522 | 523 | SSR with RR 524 | 525 | 526 | 527 | 528 |
    {{markup|safe}}
    529 | 530 | 531 | 532 | 533 | ``` 534 | 535 | ```js 536 | // src/server/server.js 537 | import koaNunjucks from 'koa-nunjucks-2'; 538 | ... 539 | app.use( 540 | koaNunjucks({ 541 | ext: 'njk', 542 | path: path.join(__dirname, 'views') 543 | }) 544 | ); 545 | ``` 546 | 547 | 由于 koa 里使用模板并不是直接`require` `views`里的模板,所以最后打包的文件并不包含`views`模板里的内容,因此我们需要将`views`里的内容拷贝过去。 548 | 另外 webpack 默认处理`__dirname`的行为是将其 mock 为`/`,因此服务端渲染的情况下,我们需要阻止其 mock 行为[\_\_dirname](https://webpack.js.org/configuration/node/#node-__dirname),同理也需要阻止`__console`和`__filename`的 mock 行为。 549 | 550 | ```js 551 | // webpack.config.server.js 552 | merge(baseConfig(target, env), { 553 | node: { 554 | __console: false, 555 | __dirname: false, // 阻止其mock行为 556 | __filename: false 557 | }); 558 | ``` 559 | 560 | ### SPA 支持 561 | 562 | 我们使用`react-router@4`来实现 SPA,`react-router`对服务端渲染有着良好的支持。 563 | `react-router`的核心 API 包括`Router`,`Route`,`Link` 564 | 565 | - Router: 渲染环境相关,为 Route 组件提供 history 对象,`react-router`为不同的环境提供了不同的 Router 实现,浏览器环境下提供了`BrowserRouter`,服务器环境提供`StaticRouter`,测试环境提供`MemoryRouter` 566 | - Route: 渲染环境无关,根据 Router 提供的 history 对象与 path 属性匹配,渲染对应组件。 567 | - Link: 实现单页内跳转,更新 history,不刷新页面。 568 | 因此对于服务端渲染,其差异主要在于 Router 的处理,Route 和 Link 的逻辑可以复用。 569 | 570 | #### 创建 routes 571 | 572 | ```js 573 | // src/client/entry/routes.js 574 | import Detail from '../../container/home/detail'; 575 | import User from '../../container/home/user'; 576 | import Feed from '../../container/home/feed'; 577 | import NotFound from '../../components/not-found'; 578 | export default [ 579 | { 580 | name: 'detail', 581 | path: '/news/item/:item_id', 582 | component: Detail 583 | }, 584 | { 585 | name: 'user', 586 | path: '/news/user/:user_id', 587 | component: User 588 | }, 589 | { 590 | name: 'feed', 591 | path: '/news/feed/:page', 592 | component: Feed 593 | }, 594 | { 595 | name: '404', 596 | component: NotFound // 404兜底 597 | } 598 | ]; 599 | ``` 600 | 601 | #### 更新 app.js 602 | 603 | ```js 604 | import React from 'react'; 605 | import { Switch, Route, Link } from 'react-router-dom'; 606 | import RedirectWithStatus from '../../components/redirct-with-status'; 607 | import Routers from './routers'; 608 | import './index.scss'; 609 | export default class Home extends React.Component { 610 | render() { 611 | return ( 612 |
    613 |
    614 | Home 615 | Feed 616 | Detail 617 | User 618 | Not Found 619 |
    620 |
    621 | 622 | 628 | {Routers.map(({ name, path, component }) => { 629 | return ; 630 | })} 631 | 632 |
    633 |
    634 | ); 635 | } 636 | } 637 | ``` 638 | 639 | #### 创建 Router 640 | 641 | 我们在服务端使用`StaticRouter`而在客户端使用`BrowserRouter`。StaticRouter 接受两个参数,根据 location 选择匹配组件进行渲染, 642 | 传入 context 信息用户服务端渲染是向服务端传递额外的信息,由于路由逻辑被客户端端接管,但有些路由相关业务仍然需要服务端处理,如服务端重定向,服务端日志、埋点统计等,因此我们通过 context 向服务端下发路由相关信息。 643 | 644 | ```js 645 | // src/client/entry/index.js 646 | import App from './app'; 647 | import { BrowserRouter, StaticRouter } from 'react-router-dom'; 648 | import routes from './routers'; 649 | import ReactDOM from 'react-dom'; 650 | import React from 'react'; 651 | 652 | const clientRender = () => { 653 | return ReactDOM.hydrate( 654 | 655 | 656 | , 657 | document.getElementById('root') 658 | ); 659 | }; 660 | 661 | const serverRender = props => { 662 | return ( 663 | 664 | 665 | 666 | ); 667 | }; 668 | 669 | export default (__BROWSER__ ? clientRender() : serverRender); 670 | ``` 671 | 672 | #### 配置 server 673 | 674 | 服务端需向 App 传入当前 url 和 context,然后根据 context 获取的信息可以执行服务端自定义的业务逻辑。 675 | 服务端对于路由请求一般有三种正常处理情况: 676 | 677 | 1. 正常返回页面 678 | 2. 重定向 679 | 3. 404 680 | 对于正常返回页面不需要任何特殊处理,而对于重定向和 404 服务端通常可能有自己的处理逻辑(日志,埋点监控,后端重定向处理等),因此服务端需要对这两种情况有所感知,不能交由前端完全处理。 681 | 682 | ```js 683 | app.use(async ctx => { 684 | const context = {}; 685 | const markup = renderToString(); 686 | if (context.url) { 687 | ctx.status = context.status; 688 | ctx.redirect(context.url); // 服务端重定向 689 | return; 690 | } 691 | if (context.status) { 692 | if (context.status === '404') { 693 | console.warn('page not found'); //服务端自定义404处理逻辑 694 | // ctx.redirect('/404'); 客户端已经做了404的容错,如果服务端想渲染服务端生成的的404页面, 695 | 可以在此执行,否则可以直接复用客户端的404容错。 696 | } 697 | } 698 | await ctx.render('home', { 699 | markup, 700 | manifest 701 | }); 702 | }); 703 | ``` 704 | 705 | 服务端的`context.status`和`context.url`这些信息的下发逻辑都在组件内实现,以`RedirectWithStatus`组件为例 706 | 707 | ```js 708 | // src/client/components/redirect-with-status 709 | const RedirectWithStatus = ({ from, to, status, exact }) => ( 710 | { 712 | // 客户端没有staticContext,所以需要判断, 713 | if (staticContext) { 714 | staticContext.status = status; // 下发信息给服务端 715 | } 716 | return ; 717 | }} 718 | /> 719 | ); 720 | ``` 721 | 722 | ### 数据预取和状态 723 | 724 | 服务端渲染的时候,如果应用程序依赖于一些异步数据,我们需要在服务端预先获取这些数据,并将预取的数据同步到客户端,如果服务端和客户端 725 | 获取的状态不一致,就会导致注水失败。 726 | 因此我们不能将状态直接存放到视图组件内部,需要将数据存放在视图组件之外,需要单独的状态容器存放我们的状态。这样服务端渲染实际分为如下几步: 727 | 728 | 1. 服务端根据路由获取对应页面的异步数据。 729 | 2. 服务端使用异步数据初始化服务端状态容器。 730 | 3. 服务端根据服务端状态容器进行服务端渲染,生成 html。 731 | 4. 服务端将初始化的状态容器里的状态通过页面模板下发到客户端。 732 | 5. 客户端从页面模板中获取服务端下发的初始状态。 733 | 6. 客户端根据初始状态初始化客户端状态容器。 734 | 7. 视图组件根据状态容器的状态,进行注水操作。 735 | 736 | 我们的应用包含三个页面 737 | 738 | - 列表页 739 | - 详情页 740 | - 用户页 741 | 每个页面都需要根据 url 里的参数去异步的获取数据。因此我们需要使用 redux 来支持服务端渲染, 742 | 直接使用 redux 来编写代码,代码十分冗余,我们使用`rematch` 简化 redux 的使用。 743 | 744 | #### 创建 store 745 | 746 | 首先创建一个 models 文件夹,这里存放所有与状态相关的文件。 747 | 748 | ```js 749 | // src/client/entry/models/index.js 750 | import { init } from '@rematch/core'; 751 | import immerPlugin from '@rematch/immer'; 752 | import { news } from './news'; // 与dva的model概念类似。包含state, reducer, effects等。 753 | const initPlugin = initialState => { 754 | return { 755 | config: { 756 | redux: { 757 | initialState 758 | } 759 | } 760 | }; 761 | }; 762 | 763 | export function createStore(initialState) { 764 | const store = init({ 765 | models: { news }, 766 | plugins: [ 767 | immerPlugin(), // 使用immer来实现immutable 768 | initPlugin(initialState) // 使用initialState初始化状态容器 769 | ] 770 | }); 771 | return store; 772 | } 773 | /// src/client/entry/models/news.js 774 | 775 | // 假定我们有一个可以返回 Promise 的 通用 API(请忽略此 API 具体实现细节) 776 | import { getItem, getTopStories, getUser } from 'shared/service/news'; 777 | 778 | export const news = { 779 | state: { 780 | detail: { 781 | item: {}, 782 | comments: [] 783 | }, 784 | user: {}, 785 | list: [] 786 | }, 787 | reducers: { 788 | updateUser(state, payload) { 789 | state.user = payload; 790 | }, 791 | updateList(state, payload) { 792 | state.list = payload; 793 | }, 794 | updateDetail(state, payload) { 795 | state.detail = payload; 796 | } 797 | }, 798 | effects: dispatch => ({ 799 | async loadUser(user_id) { 800 | const userInfo = await getUser(user_id); 801 | dispatch.news.updateUser(userInfo); 802 | }, 803 | async loadList(page = 1) { 804 | const list = await getTopStories(page); 805 | dispatch.news.updateList(list); 806 | }, 807 | async loadDetail(item_id) { 808 | const newsInfo = await getItem(item_id); 809 | const commentList = await Promise.all( 810 | newsInfo.kids.map(_id => getItem(_id)) 811 | ); 812 | dispatch.news.updateDetail({ 813 | item: newsInfo, 814 | comments: commentList 815 | }); 816 | } 817 | }) 818 | }; 819 | ``` 820 | 821 | #### 注入 store 822 | 823 | 创建完 store 后,我们就可以在应用中使用 store 了。 824 | 825 | ```js 826 | // src/client/entry/index.js 827 | 828 | import { createStore } from './models'; 829 | 830 | const clientRender = () => { 831 | const store = createStore(window.__INITIAL_STATE__); // 将 832 | return ReactDOM.hydrate( 833 | 834 | 835 | 836 | 837 | , 838 | document.getElementById('root') 839 | ); 840 | }; 841 | 842 | const serverRender = props => { 843 | return ( 844 | 845 | 846 | 847 | 848 | 849 | ); 850 | }; 851 | ``` 852 | 853 | ### 数据预取 854 | 855 | 对于服务端数据预取,问题关键是如何根据当前的 url 获取到匹配的页面组件,进而获取该页面所需的首屏数据。 856 | 因为首屏数据和页面存在一一对应的关系,因此我们可以考虑将首屏数据挂载到页面组件上。这是`next.js`等框架的做法,如下所示 857 | 858 | ```jsx 859 | class Page extends React.Component { 860 | static async getInitialProps(url) { 861 | const result = await fetchData(url); 862 | return result; 863 | } 864 | } 865 | ``` 866 | 867 | 这个做法的缺陷是如果我们想对页面组件使用 HOC 进行封装,需要将静态方法透传到包裹组件上,这有时在一定程度上难以实现,典型的如`react-loadable`,无法将组件透传到`Loadable`组件上。 868 | 869 | ```jsx 870 | { 871 | name: "detail", 872 | path: "/news/item/:item_id", 873 | component: Loadable({ // 因为是异步加载故这里难以将detail的静态方法透传到Loadable上。 874 | loader: () => import(/* webpackPrefetch: true */ "container/news/detail"), 875 | delay: 500, 876 | loading: Loading 877 | }), 878 | async asyncData({ dispatch }: Store, { params }: any) { 879 | await dispatch.news.loadDetail(params.item_id); 880 | } 881 | }, 882 | ``` 883 | 884 | 因此我们考虑将数据预取的逻辑存放在`routes`里,添加了数据预取后的`routes`如下所示。 885 | 886 | ```jsx 887 | import Detail from 'containers/home/detail'; 888 | import User from 'containers/home/user'; 889 | import Feed from 'containers/home/feed'; 890 | import NotFound from 'components/not-found'; 891 | export default [ 892 | { 893 | name: 'detail', 894 | path: '/news/item/:item_id', 895 | component: Detail, 896 | async asyncData({ dispatch }, { params }) { 897 | await dispatch.news.loadDetail(params.item_id); 898 | } 899 | }, 900 | { 901 | name: 'user', 902 | path: '/news/user/:user_id', 903 | component: User, 904 | async asyncData(store, { params }) { 905 | await store.dispatch.news.loadUser(params.user_id); 906 | } 907 | }, 908 | { 909 | name: 'feed', 910 | path: '/news/feed/:page', 911 | component: Feed, 912 | async asyncData(store, { params }) { 913 | await store.dispatch.news.loadList(params.page); 914 | } 915 | }, 916 | { 917 | name: '404', 918 | component: NotFound 919 | } 920 | ]; 921 | ``` 922 | 923 | #### 服务端数据预取 924 | 925 | 我们这里将实际的获取数据的逻辑封装在 redux 的 effects 里,这样方便服务端和客户端统一调用。 926 | 在`routes`里定义了数据预取逻辑后,我们接下来就可以在服务端进行数据预取操作了。 927 | 我们使用`react-router`的`matchPath`来根据当前路由匹配对应页面组件,进而做数据预取操作。代码如下: 928 | 929 | ```js 930 | // src/server/server.js 931 | app.use(async ctx => { 932 | const store = createStore(); 933 | const context = {}; 934 | const promises = []; 935 | routes.some(route => { 936 | const match = matchPath(ctx.url, route); // 判断当前页面是否与路由匹配 937 | if (match) { 938 | route.asyncData && promises.push(route.asyncData(store, match)); 939 | } 940 | }); 941 | await Promise.all(promises); // 等待服务端获取异步数据,并effect派发完毕 942 | const markup = renderToString( 943 | 944 | ); 945 | if (context.url) { 946 | ctx.status = context.status; 947 | ctx.redirect(context.url); 948 | return; 949 | } 950 | await ctx.render('home', { 951 | markup, 952 | initial_state: store.getState(), // 将服务端预取数据后的状态同步到客户端作为客户端的初始状态 953 | manifest 954 | }); 955 | }); 956 | ``` 957 | 958 | ### 客户端注水 959 | 960 | 实现了服务端预取之后,我们需要将服务端获取的状态同步到客户端,以保证客户端渲染的结果和服务端保持一致。 961 | 客户端注水共分为三步 962 | 963 | #### 获取服务端完成数据预取后的 initial_state 964 | 965 | 在`newsController`中可以获取服务端的 initial_state 966 | 967 | ```tsx 968 | await ctx.render('home', { 969 | markup, 970 | initial_state: store.getState() // 将服务端预取数据后的状态同步到客户端作为客户端的初始状态 971 | }); 972 | ``` 973 | 974 | #### 将 initial_state 同步到模板上 975 | 976 | 我们可以使用`renderState`将服务端获取的 initial_state 同步到模板上。 977 | 978 | ```html 979 | 980 | 981 | 982 | SSR with RR 983 | 984 | 985 | 986 | 987 |
    {{markup|safe}}
    988 | 989 | 990 | 991 | 992 | ``` 993 | 994 | 将 intial_state 注入到模板时需要做 xss 防御,这里我们使用[serialize-javascript](https://github.com/yahoo/serialize-javascript)对注入的内容进行过滤。我们为 nunjuck 配置 serialize。 995 | 996 | ```js 997 | // src/server/server.js 998 | app.use( 999 | koaNunjucks({ 1000 | ext: 'njk', 1001 | path: path.join(__dirname, 'views'), 1002 | configureEnvironment: env => { 1003 | env.addGlobal('serialize', obj => serialize(obj, { isJSON: true })); // 配置serialize便于模板里使用 1004 | } 1005 | }) 1006 | ); 1007 | ``` 1008 | 1009 | #### 客户端根据模板上的 initial_state 初始化 store 1010 | 1011 | configure 支持传入 intial_state 来初始化 store 1012 | 1013 | ```tsx 1014 | const clientRender = () => { 1015 | const store = configureStore(window.__INITIAL_STATE__); // 根据window.__INITIAL_STATE__初始化store 1016 | Loadable.preloadReady().then(() => { 1017 | ReactDOM.hydrate( 1018 | 1019 | 1020 | 1021 | 1022 | , 1023 | document.getElementById('root') 1024 | ); 1025 | }); 1026 | }; 1027 | ``` 1028 | 1029 | ### 客户端数据预取 1030 | 1031 | 受限于`react-router`并没有像`vue-router`提供类似`beforeRouteUpdate`的 api,我们只有在其他地方进行客户端预取操作,考虑如下的 hooks 1032 | 1033 | 1. `componentDidMount`: 需要区分是首次渲染还是路由跳转 1034 | 2. `componentWillReceiveProps`: react-router 切换路由是会进行 mount/unmount 操作,路由组件切换时,页面组件不会触发`componentWillReceiveProps` 1035 | 3. `history.listen`: 路由切换时触发 1036 | 1037 | 综上我们考虑在应用入口处通过 history.listen 里进行客户端数据预取操作。 1038 | 1039 | ```jsx 1040 | import React from 'react'; 1041 | import { Switch, Route } from 'react-router-dom'; 1042 | import { withRouter, matchPath } from 'react-router'; 1043 | import { connect } from 'react-redux'; 1044 | import Routers from './routes'; 1045 | import './index.scss'; 1046 | class App extends React.Component { 1047 | componentDidMount() { 1048 | const { history } = this.props; // 客户端的数据预取操作 1049 | this.unlisten = history.listen(async (location: any) => { 1050 | for (const route of Routers) { 1051 | const match = matchPath(location.pathname, route); 1052 | if (match) { 1053 | await route.asyncData({ dispatch: this.props.dispatch }, match); 1054 | } 1055 | } 1056 | }); 1057 | } 1058 | componentWillUnmount() { 1059 | this.unlisten(); // 卸载时取消listen 1060 | } 1061 | render() { 1062 | return ( 1063 |
    1064 | 1065 | {Routers.map(({ name, path, component: Component }) => { 1066 | return ; 1067 | })} 1068 | 1069 |
    1070 | ); 1071 | } 1072 | } 1073 | const mapDispatch = dispatch => { 1074 | return { 1075 | dispatch 1076 | }; 1077 | }; 1078 | // 通过withRouter来获取history 1079 | export default withRouter < 1080 | any > 1081 | connect( 1082 | undefined, 1083 | mapDispatch 1084 | )(App); 1085 | ``` 1086 | 1087 | #### service 同构 1088 | 1089 | 上面我们统一了客户端和服务端获取异步数据的逻辑,实际的发送请求都是通过`service/news`提供。 1090 | 1091 | ```js 1092 | import { getItem, getTopStories, getUser } from 'service/news'; 1093 | ``` 1094 | 1095 | `shared/service/news`的实现如下 1096 | 1097 | ```js 1098 | import { serverUrl } from 'constants/url'; 1099 | import http from 'shared/lib/http'; 1100 | async function request(api, opts) { 1101 | const result = await http.get(`${serverUrl}/${api}`, opts); 1102 | return result; 1103 | } 1104 | async function getTopStories(page = 1, pageSize = 10) { 1105 | let idList = []; 1106 | try { 1107 | idList = await request('topstories.json', { 1108 | params: { 1109 | page, 1110 | pageSize 1111 | } 1112 | }); 1113 | } catch (err) { 1114 | idList = []; 1115 | } 1116 | // parallel GET detail 1117 | const newsList = await Promise.all( 1118 | idList.slice(0, 10).map(id => { 1119 | const url = `${serverUrl}/item/${id}.json`; 1120 | return http.get(url); 1121 | }) 1122 | ); 1123 | return newsList; 1124 | } 1125 | 1126 | async function getItem(id) { 1127 | return await request(`item/${id}.json`); 1128 | } 1129 | 1130 | async function getUser(id) { 1131 | return await request(`user/${id}.json`); 1132 | } 1133 | 1134 | export { getTopStories, getItem, getUser }; 1135 | ``` 1136 | 1137 | 客户端和服务端的差异被我们使用`lib/http`屏蔽了。处理`lib/http`同构需要考虑两个问题: 1138 | 1139 | 1. 上层 api 保持一致,因此我们考虑使用同时支持 node 和 browser 的请求库,这里使用 axios 1140 | 2. server 和 client 的请求库应该是相互独立的,不能互相干扰,我们这里使用 axios 作为请求库,因为其每个 instance 配置是全局的,会导致互相干扰,因此我们需要创立两个 instance。 1141 | 1142 | ```js 1143 | // src/shared/service/lib/http 1144 | import client from './client'; 1145 | import server from './server'; 1146 | 1147 | export default (__BROWSER__ ? client : server); 1148 | // src/shared/service/lib/http/client.js 1149 | import axios from 'axios'; 1150 | const instance = axios.create(); 1151 | instance.interceptors.response.use( 1152 | response => { 1153 | return response; 1154 | }, 1155 | err => { 1156 | return Promise.reject(err); 1157 | } 1158 | ); 1159 | export default instance; 1160 | 1161 | // src/shared/service/lib/http/server.js 1162 | import axios from 'axios'; 1163 | import * as AxiosLogger from 'axios-logger'; 1164 | const instance = axios.create(); 1165 | instance.interceptors.request.use(AxiosLogger.requestLogger); 1166 | instance.interceptors.response.use( 1167 | response => { 1168 | AxiosLogger.responseLogger(response); 1169 | return response; 1170 | }, 1171 | err => { 1172 | return Promise.reject(err); 1173 | } 1174 | ); 1175 | export default instance; 1176 | ``` 1177 | 1178 | ### 代码分割 && 动态加载 1179 | 1180 | 至此我们已经实现了一个 SPA + SSR 的页面,但是此时仍然存在的一个问题是,每次首屏加载需要把所有页面的包一起加载,导致首屏的 js 包太大,我们期望非首屏的 js 包都可以异步加载,这样就可以大大减小首屏的 js 包大小。基于 webpack 实现代码分割比较简单,只需要使用`dynamic import`,webpack 自动的会将动态导入的模块进行拆包处理,然而在 SSR 情况下,就显得复杂很多。 1181 | 1182 | #### 异步组件 1183 | 1184 | React 在 16.6 发布了对`React.lazy`和`React.Suspense`的支持,其可很好的用于实现代码分割 1185 | 1186 | ```js 1187 | import React, { lazy, Suspense } from 'react'; 1188 | const OtherComponent = lazy(() => import('./OtherComponent')); 1189 | 1190 | function MyComponent() { 1191 | return ( 1192 | Loading...
    }> 1193 | 1194 | 1195 | ); 1196 | } 1197 | ``` 1198 | 1199 | 很可惜,其暂不支持服务端渲染,因此我们使用`react-loadable`来配合 webpack 实现代码分割。 1200 | 首先我们将路由里的组件全部替换为 Loadable 组件. 1201 | 1202 | ```js 1203 | import NotFound from 'components/not-found'; 1204 | import Loading from 'components/loading'; 1205 | import Loadable from 'react-loadable'; 1206 | export default [ 1207 | { 1208 | name: 'detail', 1209 | path: '/news/item/:item_id', 1210 | component: Loadable({ 1211 | loader: () => import('../containers/home/detail'), 1212 | loading: Loading, 1213 | delay: 500 1214 | }), 1215 | async asyncData({ dispatch }, { params }) { 1216 | await dispatch.news.loadDetail(params.item_id); 1217 | } 1218 | }, 1219 | { 1220 | name: 'user', 1221 | path: '/news/user/:user_id', 1222 | component: Loadable({ 1223 | loader: () => import('../containers/home/user'), 1224 | loading: Loading, 1225 | delay: 500 1226 | }), 1227 | //component: routes['../containers/home/user'], 1228 | async asyncData(store, { params }) { 1229 | await store.dispatch.news.loadUser(params.user_id); 1230 | } 1231 | }, 1232 | { 1233 | name: 'feed', 1234 | path: '/news/feed/:page', 1235 | component: Loadable({ 1236 | loader: () => import('../containers/home/feed'), 1237 | loading: Loading, 1238 | delay: 500 1239 | }), 1240 | async asyncData(store, { params }) { 1241 | await store.dispatch.news.loadList(params.page); 1242 | } 1243 | }, 1244 | { 1245 | name: '404', 1246 | component: NotFound 1247 | } 1248 | ]; 1249 | ``` 1250 | 1251 | #### 编译配置 1252 | 1253 | 首先我们需要添加对`dynamic import`语法的支持,由于`dynamic import`暂时处于 stage 3 阶段,所有`@babe/preset-env`并未包含处理`dynamic import`的插件,我们需要自己安装`@babel/plugin-syntax-dynamic-import`进行处理,该插件并未对`dynamic import`做任何转换,对其转换的工作由`webpack`负责处理,其只负责语法的支持。对于没有 webpack 的环境可以使用`dynamic-import-node`将其转换为`require`得以支持。 1254 | 1255 | ```js 1256 | // src/.babelrc 1257 | module.exports = api => { 1258 | return { 1259 | presets: [ 1260 | [ 1261 | '@babel/env', 1262 | { 1263 | modules: 'commonjs', 1264 | useBuiltIns: 'usage' 1265 | } 1266 | ], 1267 | '@babel/react' 1268 | ], 1269 | plugins: [ 1270 | '@babel/plugin-proposal-class-properties', 1271 | '@babel/plugin-syntax-dynamic-import', // 支持dyanmic import 1272 | 'react-loadable/babel', 1273 | 'babel-plugin-macros' 1274 | ] 1275 | }; 1276 | }; 1277 | ``` 1278 | 1279 | 我们接着需要为每个 chunk 生成单独的文件,因此需要配置对应的 chunkName 1280 | 1281 | ```js 1282 | // scripts/webpack/config/webpack.config.client.js 1283 | ... 1284 | output: { 1285 | filename: '[name].[chunkhash:8].js', 1286 | chunkFilename: '[name].chunk.[chunkhash:8].js', // 配置chunkName 1287 | } 1288 | ... 1289 | ``` 1290 | 1291 | 对于服务端我们并不希望对 server 生成的 bundle 进行拆包处理,因此可以考虑禁止对 server 进行拆包。 1292 | 1293 | ```js 1294 | // scripts/webpack/config/webpack.config.server.js 1295 | plugins: [ 1296 | new webpack.optimize.LimitChunkCountPlugin({ 1297 | maxChunks: 1 1298 | })], // 禁止server的bundle进行拆包 1299 | ``` 1300 | 1301 | ### chunk 收集和加载 1302 | 1303 | 进行代码分割之后,我们接下来需要根据路由加载对应的 chunk。这里服务端和客户端的处理方式有很大的不同。 1304 | 1305 | 无论是在 server 还是 client,webpack 对 import('xxx')的处理方式比较类似。 1306 | **Input** 1307 | 1308 | ```js 1309 | import('xxx'); 1310 | ``` 1311 | 1312 | **Output** 1313 | 1314 | ```js 1315 | Promise.resolve().then(() => require('test-module')); 1316 | ``` 1317 | 1318 | 以`() => import('../containers/home/detail')`为例观察下 webpack 生成的代码。 1319 | 1320 | ```js 1321 | //output/server.js 1322 | return Promise.all( 1323 | /*! import() | detail */ [ 1324 | __webpack_require__.e('vendors~detail~feed~user'), 1325 | __webpack_require__.e('detail~feed'), 1326 | __webpack_require__.e('detail') 1327 | ] 1328 | ).then( 1329 | __webpack_require__.t.bind( 1330 | null, 1331 | /*! ../containers/home/detail */ './src/client/containers/home/detail/index.js', 1332 | 7 1333 | ) 1334 | ); 1335 | ``` 1336 | 1337 | ```js 1338 | // output/main.js 1339 | return Promise.all( 1340 | /*! import() | detail */ [ 1341 | __webpack_require__.e('vendors~detail~feed~user'), 1342 | __webpack_require__.e('detail~feed'), 1343 | __webpack_require__.e('detail') 1344 | ] 1345 | ).then( 1346 | __webpack_require__.t.bind( 1347 | null, 1348 | /*! ../containers/home/detail */ './src/client/containers/home/detail/index.js', 1349 | 7 1350 | ) 1351 | ); 1352 | ``` 1353 | 1354 | 可以看到 server 和 client 生成的代码是一样的,且实际的模块加载都是在 Promise.resolve()的回调。 1355 | 1356 | #### 服务端 chunk 预加载 1357 | 1358 | 服务端我们并不需要按需加载,只需要在启动前把所有的异步的 chunk 全部加载好了即可。虽然在服务端我们可以同步加载所有模块,但是因为 1359 | webpack 将`import('xxx)`转换为`Promise.resolve().then(() => require('test-module'))`,这使得我们无法同步的去加载 chunk, 1360 | `react-loadable`为我们提供了`preloadAll`用于在 server 启动前加载所有的 chunk。 1361 | 1362 | ```js 1363 | // src/server/server.js 1364 | export async function startServer() { 1365 | await Loadable.preloadAll(); // 确保所有dyamic module都加载完 1366 | app.listen(process.env.PORT || 3000, () => { 1367 | // eslint-disable-next-line no-console 1368 | console.log('start server at port:', process.env.PORT || 3000); 1369 | }); 1370 | } 1371 | ``` 1372 | 1373 | ##### 客户端收集与加载 1374 | 1375 | 客户端的 chunk 加载就显得复杂的多主要分为五个步骤: 1376 | 1377 | 1. 将 module 与 Loadable 组件进行关联。 1378 | 2. 将当前路由匹配到 module 进行关联。 1379 | 3. 根据 module 匹配对应 chunk 1380 | 4. 将 chunk 注入页面模板 1381 | 5. 主程序启动前激活 chunk,避免出现 loading 1382 | 1383 | ##### Loadable 组件关联 module 1384 | 1385 | 为了后续在运行时能够根据路由匹配到需要加载的 module,我们需要将 module 信息和 Loadable 组件进行关联。我们既可以通过手动关联 1386 | 1387 | ```js 1388 | { 1389 | name: 'detail', 1390 | path: '/news/item/:item_id', 1391 | component: Loadable({ 1392 | loader: () => 1393 | import(/* webpackChunkName: "detail" */ '../containers/home/detail'), 1394 | loading: Loading, 1395 | modules: ['../containers/home/detail'], // 关联module信息 1396 | webpack: ()=> [require.resolveWeak('../containers/home/detail')] // 这里只能使用resolveWeak,不能使用require.resolve否则会导致code split 失效 1397 | delay: 500 1398 | }), 1399 | async asyncData({ dispatch }, { params }) { 1400 | await dispatch.news.loadDetail(params.item_id); 1401 | } 1402 | }, 1403 | ``` 1404 | 1405 | 如果对每个 Loadable 组件都手动的注入关联关系十分麻烦,为此`react-loadable`提供了 babel 插件为我们自动注入管理关系。 1406 | 1407 | ```js 1408 | ... 1409 | plugins: [ 1410 | ..., 1411 | 'react-loadable/babel', 1412 | ... 1413 | ] 1414 | ... 1415 | ``` 1416 | 1417 | ##### 当前路由关联 module 1418 | 1419 | Loadable 组件关联完 module 信息后,我们就可以根据当前路由匹配到本次渲染所需的所有 bundle 信息了。`react-loadable`通过`Loadable.Capture`来收集这个依赖关系,`Loadable.Capture`会根据上面的管理 module 信息,匹配到所有 module。 1420 | 1421 | ```js 1422 | ... 1423 | const modules = []; 1424 | const markup = renderToString( 1425 | modules.push(moduleName)}> 1426 | 1427 | 1428 | ); 1429 | ... 1430 | ``` 1431 | 1432 | ##### 根据 module 匹配 chunk 1433 | 1434 | 收集完当前路由匹配的所有 module 后,根据 module 到 chunk 映射既可以获取到当前路由匹配的所有 chunk,我们使用`react-loadable`提供的 webpack 插件来获取 module 到 chunk 的映射。 1435 | 1436 | ```js 1437 | // scripts/webpack/config/webpack.config.client.js 1438 | const { ReactLoadablePlugin } = require('react-loadable/webpack'); 1439 | .... 1440 | plugins: [ 1441 | new ReactLoadablePlugin({ 1442 | filename: paths.appLoadableManifest // 1443 | }) 1444 | ]; 1445 | // scripts/webpack/config/paths.js 1446 | module.exports = { 1447 | ..., 1448 | appLoadableManifest: resolveApp('output/react-loadable.json'), // module到chunk的映射文件 1449 | } 1450 | ``` 1451 | 1452 | 这样既可生成`react-loadable.json`文件,其内容如下 1453 | 1454 | ```json 1455 | "../containers/home/detail": [ 1456 | { 1457 | "id": "./src/client/containers/home/detail/index.js", 1458 | "name": "./src/client/containers/home/detail/index.js", 1459 | "file": "detail.chunk.676c84f3.js", 1460 | "publicPath": "/detail.chunk.676c84f3.js" 1461 | }, 1462 | { 1463 | "id": "./src/client/containers/home/detail/index.js", 1464 | "name": "./src/client/containers/home/detail/index.js", 1465 | "file": "detail.chunk.676c84f3.js.map", 1466 | "publicPath": "/detail.chunk.676c84f3.js.map" 1467 | } 1468 | ], 1469 | ``` 1470 | 1471 | 这样通过`react-loadable`提供的`getBundles`即可获取匹配的 chunk。然后注入模板即可。和服务端类似,虽然chunk文件加载,仍然 1472 | 需要手动的加载chunk里包含的module。通过`react-loadable`的`preloadAll`注册module。 1473 | 1474 | ```js 1475 | // src/server/server.js 1476 | app.use(async (ctx, next) => { 1477 | ... 1478 | const modules = []; 1479 | const markup = renderToString( 1480 | modules.push(moduleName)}> 1481 | 1482 | 1483 | ); 1484 | const bundles = getBundles(stats, modules); // 获取chunk信息 1485 | const js_bundles = bundles.filter(({ file }) => file.endsWith('.js')); 1486 | const css_bundles = bundles.filter(({ file }) => file.endsWith('.css')); 1487 | await ctx.render('home', { 1488 | markup, 1489 | initial_state: store.getState(), 1490 | manifest, 1491 | css_bundles, // 注入css chunk 1492 | js_bundles // 注入js chunk 1493 | }); 1494 | }); 1495 | ``` 1496 | chunk注入模板 1497 | ```html 1498 | 1499 | 1500 | 1501 | SSR with RR 1502 | 1503 | {% for item in css_bundles %} 1504 | 注入css chunk 1505 | {% endfor %} 1506 | 1507 | 1508 | 1509 |
    {{markup|safe}}
    1510 | 1511 | {% for item in js_bundles %} 1512 | 注入js chunk 1513 | {% endfor %} 1514 | 1515 | 1516 | 1517 | ``` 1518 | --------------------------------------------------------------------------------