├── config ├── dev.env.js ├── prod.env.js ├── webpack.config.base.js ├── webpack.config.server.js ├── util.js └── webpack.config.client.js ├── public └── favicon.ico ├── src ├── api │ ├── index.d.ts │ └── index.js ├── assets │ ├── top-list.styl │ └── app.css ├── redux │ ├── actionTypes.ts │ ├── store.ts │ ├── reducers.ts │ └── actions.ts ├── containers │ ├── TopList.ts │ └── TopDetail.ts ├── router │ ├── NestedRoute.tsx │ ├── StatusRoute.tsx │ └── index.ts ├── views │ ├── Bar.tsx │ ├── Baz.tsx │ ├── Foo.tsx │ ├── TopDetail.tsx │ └── TopList.tsx ├── components │ └── ListItem.tsx ├── entry-server.tsx ├── entry-client.tsx └── App.tsx ├── .eslintignore ├── .browserslistrc ├── .gitignore ├── tsconfig.json ├── index.html ├── .eslintrc.js ├── README.md ├── plugin └── webpack │ ├── util.js │ └── server-plugin.js ├── server ├── index.js ├── dev-server.js └── renderer.js └── package.json /config/dev.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"development"' 3 | } -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production"' 3 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dxx/react-ssr/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/api/index.d.ts: -------------------------------------------------------------------------------- 1 | export function getTopList(); 2 | 3 | export function getTopDetail(id: number); -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /config 4 | /public 5 | /plugin 6 | /server 7 | 8 | *.css -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Browsers 2 | 3 | >1% 4 | last 4 versions 5 | Firefox ESR 6 | not ie < 9 # Doesn"t support IE8 anyway 7 | -------------------------------------------------------------------------------- /src/assets/top-list.styl: -------------------------------------------------------------------------------- 1 | .list-wrapper 2 | margin: 0 3 | padding: 0 4 | list-style: none 5 | .list-item 6 | margin-bottom: 10px -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # dependencies 3 | /node_modules 4 | 5 | # production 6 | /dist 7 | 8 | 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* -------------------------------------------------------------------------------- /src/redux/actionTypes.ts: -------------------------------------------------------------------------------- 1 | export const SET_CLIENT_LOAD = "SET_CLIENT_LOAD"; 2 | 3 | export const SET_TOP_LIST = "SET_TOP_LIST"; 4 | 5 | export const SET_TOP_DETAIL = "SET_TOP_DETAIL"; 6 | -------------------------------------------------------------------------------- /src/containers/TopList.ts: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import TopList from "../views/TopList"; 3 | 4 | const mapStateToProps = (state) => ({ 5 | clientShouldLoad: state.clientShouldLoad, 6 | topList: state.topList 7 | }); 8 | 9 | export default connect(mapStateToProps)(TopList); 10 | -------------------------------------------------------------------------------- /src/containers/TopDetail.ts: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux" 2 | import TopDetail from "../views/TopDetail"; 3 | 4 | const mapStateToProps = (state) => ({ 5 | clientShouldLoad: state.clientShouldLoad, 6 | topDetail: state.topDetail 7 | }); 8 | 9 | export default connect(mapStateToProps)(TopDetail); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es6", "dom"], 5 | "allowJs": true, 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "noEmit": true, 11 | "jsx": "react", 12 | }, 13 | "include": [ 14 | "src" 15 | ] 16 | } -------------------------------------------------------------------------------- /src/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from "redux"; 2 | import thunkMiddleware from "redux-thunk"; 3 | import reducer from "./reducers"; 4 | 5 | // 导出函数,以便客户端和服务端根据初始state创建store 6 | export default (store) => { 7 | return createStore( 8 | reducer, 9 | store, 10 | applyMiddleware(thunkMiddleware) // 允许store能dispatch函数 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/router/NestedRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Route } from "react-router-dom"; 3 | 4 | const NestedRoute = (route) => ( 5 | } 8 | /> 9 | ); 10 | 11 | export default NestedRoute; 12 | -------------------------------------------------------------------------------- /src/views/Bar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Helmet } from "react-helmet"; 3 | 4 | class Bar extends React.Component { 5 | public render() { 6 | return ( 7 |
8 | 9 | Bar 10 | 11 |
Bar
12 |
13 | ); 14 | } 15 | } 16 | 17 | export default Bar; 18 | -------------------------------------------------------------------------------- /src/views/Baz.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Helmet } from "react-helmet"; 3 | 4 | class Baz extends React.Component { 5 | public render() { 6 | return ( 7 |
8 | 9 | Baz 10 | 11 |
Baz
12 |
13 | ); 14 | } 15 | } 16 | 17 | export default Baz; 18 | -------------------------------------------------------------------------------- /src/views/Foo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Helmet } from "react-helmet"; 3 | 4 | class Foo extends React.Component { 5 | public render() { 6 | return ( 7 |
8 | 9 | Foo 10 | 11 |
Foo
12 |
13 | ); 14 | } 15 | } 16 | 17 | export default Foo; 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React SSR 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/router/StatusRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Route } from "react-router-dom"; 3 | 4 | const StatusRoute = (props) => ( 5 | { 6 | // 客户端无staticContext对象 7 | if (staticContext) { 8 | // 设置状态码 9 | staticContext.statusCode = props.code; 10 | } 11 | return props.children; 12 | }} /> 13 | ); 14 | 15 | export default StatusRoute; 16 | -------------------------------------------------------------------------------- /src/components/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface ListItemProps { 4 | topTitle: string; 5 | picUrl: string; 6 | } 7 | 8 | class ListItem extends React.Component { 9 | public render() { 10 | const {topTitle, picUrl} = this.props; 11 | return ( 12 |
13 |
{topTitle}
14 |
15 | 16 |
17 |
18 | ); 19 | } 20 | } 21 | 22 | export default ListItem; 23 | -------------------------------------------------------------------------------- /src/assets/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | font-size: 14px; 8 | margin: 0; 9 | background-color: rgb(97, 218, 251); 10 | } 11 | .title { 12 | font-size: 24px; 13 | font-weight: bold; 14 | text-align: center; 15 | } 16 | .nav { 17 | padding: 0; 18 | list-style: none; 19 | display: flex; 20 | } 21 | .nav > li { 22 | flex: 1; 23 | text-align: center; 24 | } 25 | .view { 26 | padding: 0 15px; 27 | } 28 | 29 | .active { 30 | color: #FFFFFF; 31 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | env: { 5 | es6: true, 6 | browser: true, 7 | node: true 8 | }, 9 | extends: [ 10 | "eslint:recommended", 11 | "plugin:react/recommended" 12 | ], 13 | plugins: ["@typescript-eslint"], 14 | parserOptions: { 15 | sourceType: "module", 16 | ecmaFeatures: { 17 | jsx: true 18 | } 19 | }, 20 | rules: { 21 | "no-unused-vars": 0, 22 | "react/display-name": 0, 23 | "react/prop-types": 0 24 | }, 25 | settings: { 26 | react: { 27 | version: "16.4.2" 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/entry-server.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { StaticRouter } from "react-router-dom"; 3 | import { Provider } from "react-redux"; 4 | import createStore from "./redux/store"; 5 | import { router } from "./router"; 6 | import Root from "./App"; 7 | 8 | const createApp = (context, url, store) => { 9 | const App: any = () => { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | return ; 19 | } 20 | 21 | export { 22 | createApp, 23 | createStore, 24 | router 25 | }; 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## React SSR 2 | 3 | This is a react ssr demo written with typescript. It base on react16.x and webpack4.x 4 | 5 | Fetures: 6 | 7 | 1. Support HMR in development mode. 8 | 2. Code Splitting base on route. 9 | 3. Head management for SEO. 10 | 4. Fetch Data ahead of time. 11 | 12 | ## Available Scripts 13 | 14 | ### `npm install` 15 | 16 | First, run the `npm install` to install dependence. 17 | 18 | ### `npm run dev` 19 | 20 | Runs the app in the development mode.
21 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 22 | 23 | ### `npm run build` 24 | 25 | Builds the app for production. 26 | 27 | ### `npm run start` 28 | 29 | Starts the server for production. 30 | -------------------------------------------------------------------------------- /src/views/TopDetail.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Helmet } from "react-helmet"; 3 | import { setClientLoad, fetchTopDetail } from "../redux/actions"; 4 | 5 | interface TopDetailProps { 6 | match: { params: any }; 7 | dispatch: any; 8 | clientShouldLoad: boolean; 9 | topDetail: any; 10 | } 11 | 12 | class TopDetail extends React.Component { 13 | public componentDidMount() { 14 | const id = this.props.match.params.id; 15 | if (this.props.clientShouldLoad === true) { 16 | this.props.dispatch(fetchTopDetail(id)); 17 | } else { 18 | this.props.dispatch(setClientLoad(true)); 19 | } 20 | } 21 | public render() { 22 | const { topDetail } = this.props; 23 | return ( 24 |
25 | 26 | {topDetail.name} 27 | 28 |
29 | 30 | 31 |
32 |
33 | ); 34 | } 35 | } 36 | 37 | export default TopDetail; 38 | -------------------------------------------------------------------------------- /src/entry-client.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import * as React from "react"; 4 | import * as ReactDOM from "react-dom"; 5 | import { BrowserRouter as Router } from "react-router-dom"; 6 | import { Provider } from "react-redux"; 7 | import { loadableReady } from "@loadable/component"; 8 | import createStore from "./redux/store"; 9 | import Root from "./App"; 10 | 11 | const createApp = (Component) => { 12 | // 获取服务端初始化的state,创建store 13 | const initialState = (window as any).__INITIAL_STATE__; 14 | const store = createStore(initialState); 15 | const App = () => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | return ; 25 | } 26 | 27 | // 开始渲染之前加载所需的组件 28 | loadableReady().then(() => { 29 | ReactDOM.hydrate(createApp(Root), document.getElementById("app")); 30 | }); 31 | 32 | // 热更新 33 | if (module.hot) { 34 | module.hot.accept("./App", () => { 35 | const NewApp = require("./App").default; 36 | ReactDOM.hydrate(createApp(NewApp), document.getElementById("app")); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /plugin/webpack/util.js: -------------------------------------------------------------------------------- 1 | const { red } = require("chalk"); 2 | 3 | const isJS = (file) => /\.js(\?[^.]+)?$/.test(file); 4 | 5 | const onEmit = (compiler, name, hook) => { 6 | if (compiler.hooks) { 7 | // Webpack >= 4.0.0 8 | compiler.hooks.emit.tapAsync(name, hook) 9 | } else { 10 | // Webpack < 4.0.0 11 | compiler.plugin("emit", hook) 12 | } 13 | } 14 | 15 | const validateConfig = (compiler) => { 16 | const prefix = "ssr-server-plugin"; 17 | if (compiler.options.target !== "node") { 18 | let msg = 'webpack config `target` should be "node".'; 19 | console.error(red(`${prefix} ${msg}\n`)); 20 | } 21 | 22 | if (compiler.options.output && compiler.options.output.libraryTarget !== "commonjs2") { 23 | let msg = 'webpack config `output.libraryTarget` should be "commonjs2".'; 24 | console.error(red(`${prefix} ${msg}\n`)); 25 | } 26 | 27 | if (!compiler.options.externals) { 28 | let msg = 'You should use `output.externals` to externalize dependencies in the server build' 29 | console.error(red(`${prefix} ${msg}\n`)); 30 | } 31 | } 32 | 33 | module.exports = { 34 | isJS, 35 | onEmit, 36 | validateConfig 37 | } -------------------------------------------------------------------------------- /src/redux/reducers.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import * as ActionTypes from "./actionTypes"; 3 | 4 | const initialState = { 5 | clientShouldLoad: true, 6 | topList: [], 7 | topDetail: {} 8 | } 9 | 10 | function combineClientShouldLoad(clientShouldLoad = initialState.clientShouldLoad, action) { 11 | switch (action.type) { 12 | case ActionTypes.SET_CLIENT_LOAD: 13 | return action.clientShouldLoad; 14 | default: 15 | return clientShouldLoad; 16 | } 17 | } 18 | 19 | function combineTopList(topList = initialState.topList, action) { 20 | switch (action.type) { 21 | case ActionTypes.SET_TOP_LIST: 22 | return action.topList; 23 | default: 24 | return topList; 25 | } 26 | } 27 | 28 | function combineTopDetail(topDetail = initialState.topDetail, action) { 29 | switch (action.type) { 30 | case ActionTypes.SET_TOP_DETAIL: 31 | return action.topDetail; 32 | default: 33 | return topDetail; 34 | } 35 | } 36 | 37 | const reducer = combineReducers({ 38 | clientShouldLoad: combineClientShouldLoad, 39 | topList: combineTopList, 40 | topDetail: combineTopDetail 41 | }); 42 | 43 | export default reducer; 44 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import NestedRoute from "./NestedRoute"; 2 | import StatusRoute from "./StatusRoute"; 3 | import { fatchTopList, fetchTopDetail } from "../redux/actions"; 4 | 5 | import loadable from "@loadable/component"; 6 | 7 | // import Bar from "../views/Bar"; 8 | // import Baz from "../views/Baz"; 9 | // import Foo from "../views/Foo"; 10 | // import TopList from "../containers/TopList"; 11 | // import TopDetail from "../containers/TopDetail"; 12 | 13 | const router = [ 14 | { 15 | path: "/bar", 16 | component: loadable(() => import("../views/Bar")) 17 | }, 18 | { 19 | path: "/baz", 20 | component: loadable( () => import("../views/Baz")) 21 | }, 22 | { 23 | path: "/foo", 24 | component: loadable(() => import("../views/Foo")) 25 | }, 26 | { 27 | path: "/top-list", 28 | component: loadable(() => import("../containers/TopList")), 29 | exact: true, 30 | asyncData(store) { 31 | return store.dispatch(fatchTopList()); 32 | } 33 | }, 34 | { 35 | path: "/top-list/:id", 36 | component: loadable(() => import("../containers/TopDetail")), 37 | asyncData(store, params) { 38 | return store.dispatch(fetchTopDetail(params.id)); 39 | } 40 | } 41 | ]; 42 | 43 | export default router; 44 | 45 | export { 46 | router, 47 | NestedRoute, 48 | StatusRoute 49 | } 50 | -------------------------------------------------------------------------------- /src/views/TopList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Helmet } from "react-helmet"; 3 | import { NavLink } from "react-router-dom"; 4 | import { setClientLoad, fatchTopList } from "../redux/actions"; 5 | import ListItem from "../components/ListItem"; 6 | import "../assets/top-list.styl"; 7 | 8 | interface TopListProps { 9 | match: { params: any, url: string }; 10 | dispatch: any; 11 | clientShouldLoad: boolean; 12 | topList: any; 13 | } 14 | 15 | class TopList extends React.Component { 16 | public componentDidMount() { 17 | // 判断是否需要加载数据 18 | if (this.props.clientShouldLoad === true) { 19 | this.props.dispatch(fatchTopList()); 20 | } else { 21 | // 客户端执行后,将客户端是否加载数据设置为true 22 | this.props.dispatch(setClientLoad(true)); 23 | } 24 | } 25 | public render() { 26 | const { match, topList } = this.props; 27 | return ( 28 |
29 | 30 | Top List 31 | 32 |
    33 | { 34 | topList.map((item) => { 35 | return
  • 36 | 37 |
  • ; 38 | }) 39 | } 40 |
41 |
42 | ) 43 | } 44 | } 45 | 46 | export default TopList; 47 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import jsonp from "jsonp"; 3 | 4 | const topListUrl = "https://c.y.qq.com/v8/fcg-bin/fcg_myqq_toplist.fcg"; 5 | const topDetailUrl = "https://c.y.qq.com/v8/fcg-bin/fcg_v8_toplist_cp.fcg?&type=top"; 6 | 7 | function getTopList() { 8 | if (process.env.REACT_ENV === "server") { 9 | return axios.get(topListUrl + "?format=json"); 10 | } else { 11 | // 客户端使用jsonp请求 12 | return new Promise((resolve, reject) => { 13 | jsonp(topListUrl + "?format=jsonp", { 14 | param: "jsonpCallback", 15 | prefix: "callback" 16 | }, (err, data) => { 17 | if (!err) { 18 | const response = {}; 19 | response.data = data; 20 | resolve(response); 21 | } else { 22 | reject(err); 23 | } 24 | }); 25 | }); 26 | } 27 | } 28 | 29 | function getTopDetail(id) { 30 | if (process.env.REACT_ENV === "server") { 31 | return axios.get(topDetailUrl + "&format=json&topid=" + id); 32 | } else { 33 | return new Promise((resolve, reject) => { 34 | jsonp(topDetailUrl + "&format=jsonp&topid=" + id, { 35 | param: "jsonpCallback", 36 | prefix: "callback" 37 | }, (err, data) => { 38 | if (!err) { 39 | const response = {}; 40 | response.data = data; 41 | resolve(response); 42 | } else { 43 | reject(err); 44 | } 45 | }); 46 | }); 47 | } 48 | } 49 | 50 | export { 51 | getTopList, 52 | getTopDetail 53 | } 54 | -------------------------------------------------------------------------------- /src/redux/actions.ts: -------------------------------------------------------------------------------- 1 | import { SET_CLIENT_LOAD, SET_TOP_LIST, SET_TOP_DETAIL } from "./actionTypes"; 2 | import { getTopList, getTopDetail } from "../api"; 3 | 4 | export function setClientLoad(clientShouldLoad) { 5 | return { type: SET_CLIENT_LOAD, clientShouldLoad }; 6 | } 7 | 8 | export function setTopList(topList) { 9 | return { type: SET_TOP_LIST, topList }; 10 | } 11 | 12 | export function setTopDetail(topDetail) { 13 | return { type: SET_TOP_DETAIL, topDetail }; 14 | } 15 | 16 | export function fatchTopList() { 17 | // dispatch由thunkMiddleware传入 18 | return (dispatch, getState) => { 19 | return getTopList().then((response) => { 20 | const data = response.data; 21 | if (data.code === 0) { 22 | // 获取数据后dispatch,存入store 23 | dispatch(setTopList(data.data.topList)); 24 | } 25 | if (process.env.REACT_ENV === "server") { 26 | dispatch(setClientLoad(false)); 27 | } 28 | }); 29 | } 30 | } 31 | 32 | export function fetchTopDetail(id) { 33 | return (dispatch, getState) => { 34 | return getTopDetail(id).then((response) => { 35 | const data = response.data; 36 | if (data.code === 0) { 37 | const topinfo = data.topinfo; 38 | const top = { 39 | id: topinfo.topID, 40 | name: topinfo.ListName, 41 | pic: topinfo.pic, 42 | info: topinfo.info 43 | }; 44 | dispatch(setTopDetail(top)); 45 | } 46 | if (process.env.REACT_ENV === "server") { 47 | dispatch(setClientLoad(false)); 48 | } 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | Switch, 4 | Redirect, 5 | NavLink 6 | } from "react-router-dom"; 7 | import { Helmet } from "react-helmet"; 8 | import { router, NestedRoute, StatusRoute} from "./router"; 9 | import "./assets/app.css"; 10 | 11 | class App extends React.Component { 12 | public render() { 13 | return ( 14 |
15 | 16 | This is App page 17 | 18 | 19 |
This is a react ssr demo
20 |
    21 |
  • Bar
  • 22 |
  • Baz
  • 23 |
  • Foo
  • 24 |
  • TopList
  • 25 |
26 |
27 | 28 | { 29 | router.map((route, i) => 30 | 31 | ) 32 | } 33 | 34 | 35 |
36 |

Not Found

37 |
38 |
39 | {/* 40 | 41 | 42 | 43 | 44 | */} 45 |
46 |
47 |
48 | ); 49 | } 50 | } 51 | 52 | export default App; 53 | -------------------------------------------------------------------------------- /config/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const path = require("path"); 3 | const UglifyJsPlugin = require("uglifyjs-webpack-plugin"); 4 | const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); 5 | 6 | let env = "dev"; 7 | let isProd = false; 8 | if (process.env.NODE_ENV === "production") { 9 | env = "prod"; 10 | isProd = true; 11 | } 12 | 13 | const baseWebpackConfig = { 14 | mode: isProd ? "production" : "development", 15 | devtool: isProd ? "#source-map" : "#cheap-module-source-map", 16 | output: { 17 | path: path.resolve(__dirname, "../dist"), 18 | publicPath: "/dist/" // 打包后输出路径以/dist/开头 19 | }, 20 | resolve: { 21 | extensions: [".ts", ".tsx", ".js", ".jsx", ".json"] 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.(png|jpe?g|gif|svg)$/, 27 | loader: "url-loader", 28 | options: { 29 | limit: 10000, 30 | name: "static/img/[name].[hash:7].[ext]" 31 | } 32 | }, 33 | { 34 | test: /\.(woff2?|eot|ttf|otf)$/, 35 | loader: "url-loader", 36 | options: { 37 | limit: 10000, 38 | name: "static/fonts/[name].[hash:7].[ext]" 39 | } 40 | } 41 | ] 42 | }, 43 | optimization: { 44 | // mode为production自动启用 45 | minimizer: [ 46 | new UglifyJsPlugin({ 47 | sourceMap: true 48 | }), 49 | new OptimizeCSSAssetsPlugin({ 50 | cssProcessorOptions: { 51 | map: { inline: false } 52 | } 53 | }) 54 | ] 55 | }, 56 | plugins: [ 57 | new webpack.DefinePlugin({ 58 | "process.env": require("./" + env + ".env") 59 | }) 60 | ] 61 | } 62 | 63 | module.exports = baseWebpackConfig; 64 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const ServerRenderer = require("./renderer"); 5 | const app = express(); 6 | 7 | const isProd = process.env.NODE_ENV === "production"; 8 | 9 | let renderer; 10 | let readyPromise; 11 | let template = fs.readFileSync("./index.html", "utf-8"); 12 | if (isProd) { 13 | // 静态资源映射到dist路径下 14 | app.use("/dist", express.static(path.join(__dirname, "../dist"))); 15 | 16 | let bundle = require("../dist/server-bundle.json"); 17 | let clientManifest = require("../dist/client-manifest.json"); 18 | renderer = new ServerRenderer(bundle, template, clientManifest); 19 | } else { 20 | readyPromise = require("./dev-server")(app, ( 21 | bundle, 22 | clientManifest) => { 23 | renderer = new ServerRenderer(bundle, template, clientManifest); 24 | }); 25 | } 26 | 27 | app.use("/public", express.static(path.join(__dirname, "../public"))); 28 | 29 | const render = (req, res) => { 30 | console.log("======enter server======"); 31 | console.log("visit url: " + req.url); 32 | 33 | // 此对象会合并然后传给服务端路由,不需要可不传 34 | const context = {}; 35 | 36 | renderer.renderToString(req, context).then(({error, html}) => { 37 | if (error) { 38 | if (error.url) { 39 | res.redirect(error.url); 40 | } else if (error.code) { 41 | res.status(error.code).send("error code:" + error.code); 42 | } 43 | } 44 | res.send(html); 45 | }).catch(error => { 46 | console.log(error); 47 | res.status(500).send("Internal server error"); 48 | }); 49 | } 50 | 51 | app.get("*", isProd ? render : (req, res) => { 52 | // 等待客户端和服务端打包完成后进行render 53 | readyPromise.then(() => render(req, res)); 54 | }); 55 | 56 | app.listen(3000, () => { 57 | console.log("Your app is running"); 58 | }); 59 | -------------------------------------------------------------------------------- /config/webpack.config.server.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const merge = require("webpack-merge"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const nodeExternals = require("webpack-node-externals"); 5 | const baseWebpackConfig = require("./webpack.config.base"); 6 | const SSRServerPlugin = require("../plugin/webpack/server-plugin"); 7 | const util = require("./util"); 8 | 9 | const webpackConfig = merge(baseWebpackConfig, { 10 | entry: { 11 | app: "./src/entry-server.tsx" 12 | }, 13 | output: { 14 | filename: "entry-server.js", 15 | libraryTarget: "commonjs2" // 打包成commonjs2规范 16 | }, 17 | target: "node", // 指定node运行环境 18 | externals: [ 19 | nodeExternals({ 20 | whitelist: [ /\.css$/ ] // 忽略css,让webpack处理 21 | }) 22 | ], // 不绑定node模块,保留为 require() 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.(ts|tsx)$/, 27 | use: [ 28 | { 29 | loader: "babel-loader", 30 | options: { 31 | babelrc: false, 32 | plugins: [ 33 | "dynamic-import-node", 34 | "@loadable/babel-plugin" 35 | ] 36 | } 37 | }, 38 | { 39 | loader: "ts-loader", 40 | options: { 41 | transpileOnly: true // 只进行编译 42 | } 43 | }, 44 | { 45 | loader: "eslint-loader" 46 | } 47 | ], 48 | exclude: /node_modules/ 49 | }, 50 | ...util.styleLoaders({ 51 | sourceMap: true, 52 | usePostCSS: true, 53 | extract: true 54 | }) 55 | ] 56 | }, 57 | plugins: [ 58 | new webpack.DefinePlugin({ 59 | "process.env.REACT_ENV": JSON.stringify("server") // 指定React环境为服务端 60 | }), 61 | // 服务端不支持window document等对象,需将css外链 62 | new MiniCssExtractPlugin({ 63 | filename: "static/css/[name].[contenthash].css" 64 | }), 65 | new SSRServerPlugin({ 66 | filename: "server-bundle.json" 67 | }) 68 | ] 69 | }); 70 | 71 | module.exports = webpackConfig; 72 | -------------------------------------------------------------------------------- /plugin/webpack/server-plugin.js: -------------------------------------------------------------------------------- 1 | const { isJS, onEmit, validateConfig } = require("./util"); 2 | 3 | module.exports = class SSRServerPlugin { 4 | constructor(opts = {}) { 5 | this.options = Object.assign({ 6 | filename: "server-bundle.json", 7 | }, opts); 8 | } 9 | apply(compiler) { 10 | validateConfig(compiler); 11 | 12 | onEmit(compiler, "ssr-server-plugin" , (compilation, callback) => { 13 | const stats = compilation.getStats().toJson(); 14 | 15 | if(Object.keys(stats.entrypoints).length > 1) { 16 | throw new Error( 17 | "Server-side bundle should have one single entry file. " 18 | ); 19 | } 20 | 21 | const entryName = Object.keys(stats.entrypoints)[0]; 22 | const entryInfo = stats.entrypoints[entryName]; 23 | 24 | if (!entryInfo) { 25 | return callback(); 26 | } 27 | 28 | const entryAssets = entryInfo.assets.filter(isJS); 29 | const entry = entryAssets[0]; 30 | 31 | const bundle = { 32 | entry, 33 | files: {}, 34 | maps: {} 35 | } 36 | 37 | stats.assets.forEach(asset => { 38 | if (asset.name.match(/\.js$/)) { 39 | bundle.files[asset.name] = compilation.assets[asset.name].source(); 40 | } else if (asset.name.match(/\.js\.map$/)) { 41 | bundle.maps[asset.name.replace(/\.map$/, "")] = JSON.parse(compilation.assets[asset.name].source()); 42 | } 43 | // do not emit anything else for server 44 | delete compilation.assets[asset.name]; 45 | }) 46 | 47 | if (Object.keys(bundle.files).length > 1) { 48 | throw new Error( 49 | "Server-side bundle should output one single file. " + 50 | "If you are using code splitting, you should use `dynamic-import-node` babel plugin. " 51 | ); 52 | } 53 | 54 | const json = JSON.stringify(bundle, null, 2) 55 | const filename = this.options.filename 56 | 57 | compilation.assets[filename] = { 58 | source: () => json, 59 | size: () => json.length 60 | } 61 | 62 | callback(); 63 | }); 64 | } 65 | } -------------------------------------------------------------------------------- /server/dev-server.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const MFS = require("memory-fs"); 4 | const clientConfig = require("../config/webpack.config.client"); 5 | const serverConfig = require("../config/webpack.config.server"); 6 | 7 | module.exports = function setupDevServer(app, callback) { 8 | let bundle; 9 | let clientManifest; 10 | let resolve; 11 | const readyPromise = new Promise(r => { resolve = r }); 12 | const update = () => { 13 | if (bundle && clientManifest) { 14 | callback(bundle, clientManifest); 15 | resolve(); // resolve Promise让服务端进行render 16 | } 17 | } 18 | 19 | const readFile = (fs, fileName) => { 20 | return fs.readFileSync(path.join(clientConfig.output.path, fileName), "utf-8"); 21 | } 22 | 23 | // 修改入口文件,增加热更新文件 24 | clientConfig.entry.app = ["webpack-hot-middleware/client", clientConfig.entry.app]; 25 | clientConfig.output.filename = "static/js/[name].[hash].js"; 26 | clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin()); 27 | 28 | // 客户端打包 29 | const clientCompiler = webpack(clientConfig); 30 | 31 | const devMiddleware = require("webpack-dev-middleware")(clientCompiler, { 32 | publicPath: clientConfig.output.publicPath, 33 | logLevel: "warn" 34 | }); 35 | // 使用webpack-dev-middleware中间件服务webpack打包后的资源文件 36 | app.use(devMiddleware); 37 | 38 | clientCompiler.hooks.done.tap("done", stats => { 39 | const info = stats.toJson(); 40 | if (stats.hasWarnings()) { 41 | console.warn(info.warnings); 42 | } 43 | 44 | if (stats.hasErrors()) { 45 | console.error(info.errors); 46 | return; 47 | } 48 | // 从webpack-dev-middleware中间件存储的内存中读取打包后的文件 49 | clientManifest = JSON.parse(readFile(devMiddleware.fileSystem, "client-manifest.json")); 50 | update(); 51 | }); 52 | 53 | // 热更新中间件 54 | app.use(require("webpack-hot-middleware")(clientCompiler)); 55 | 56 | // 监视服务端打包入口文件,有更改就更新 57 | const serverCompiler = webpack(serverConfig); 58 | // 使用内存文件系统 59 | const mfs = new MFS(); 60 | serverCompiler.outputFileSystem = mfs; 61 | serverCompiler.watch({}, (err, stats) => { 62 | const info = stats.toJson(); 63 | if (stats.hasWarnings()) { 64 | console.warn(info.warnings); 65 | } 66 | 67 | if (stats.hasErrors()) { 68 | console.error(info.errors); 69 | return; 70 | } 71 | 72 | bundle = JSON.parse(readFile(mfs, "server-bundle.json")); 73 | update(); 74 | }); 75 | 76 | return readyPromise; 77 | } 78 | -------------------------------------------------------------------------------- /config/util.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require("autoprefixer"); 2 | const postcssFlexbugsFixes = require("postcss-flexbugs-fixes"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | 5 | const cssLoaders = function(options) { 6 | options = options || {}; 7 | 8 | const cssLoader = { 9 | loader: "css-loader", 10 | options: !options.modules ? { 11 | sourceMap: options.sourceMap 12 | } : { 13 | sourceMap: options.sourceMap, 14 | camelCase: true, 15 | modules: true, 16 | localIdentName: "[name]_[local]-[hash:base64:5]" 17 | } 18 | } 19 | 20 | const postcssLoader = { 21 | loader: "postcss-loader", 22 | options: { 23 | sourceMap: options.sourceMap, 24 | ident: "postcss", 25 | plugins: () => [ 26 | autoprefixer({ 27 | flexbox: "no-2009" 28 | }), 29 | postcssFlexbugsFixes 30 | ] 31 | } 32 | } 33 | 34 | function generateLoaders (loader, loaderOptions) { 35 | const loaders = options.usePostCSS ? [cssLoader, postcssLoader] : [cssLoader]; 36 | 37 | if (loader) { 38 | loaders.push({ 39 | loader: loader + "-loader", 40 | options: Object.assign({}, loaderOptions, { 41 | sourceMap: options.sourceMap 42 | }) 43 | }) 44 | } 45 | 46 | // Extract CSS when that option is specified 47 | // (which is the case during production build) 48 | if (options.extract) { 49 | return [MiniCssExtractPlugin.loader].concat(loaders); 50 | } else { 51 | return ["style-loader"].concat(loaders); 52 | } 53 | } 54 | 55 | return { 56 | css: generateLoaders(), 57 | postcss: generateLoaders(), 58 | less: generateLoaders("less"), 59 | sass: generateLoaders("sass", { indentedSyntax: true }), 60 | scss: generateLoaders("sass"), 61 | stylus: generateLoaders("stylus"), 62 | styl: generateLoaders("stylus") 63 | } 64 | } 65 | 66 | module.exports.styleLoaders = function (options) { 67 | const output = []; 68 | const loaders = cssLoaders(options); 69 | 70 | options.modules = true; 71 | const cssModuleLoaders = cssLoaders(options); 72 | 73 | for (const extension in loaders) { 74 | const loader = loaders[extension]; 75 | const cssModuleLoader = cssModuleLoaders[extension]; 76 | output.push({ 77 | test: new RegExp("\\." + extension + "$"), 78 | oneOf: [ 79 | { 80 | resourceQuery: /css-modules/, 81 | use: cssModuleLoader, 82 | }, 83 | { 84 | use: loader 85 | } 86 | ] 87 | }); 88 | } 89 | 90 | return output; 91 | } 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ssr", 3 | "version": "1.0.0", 4 | "description": "A react ssr demo", 5 | "author": "mcx", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "node server/index.js", 9 | "start": "cross-env NODE_ENV=production node server/index.js", 10 | "build": "rimraf dist && npm run build:client && npm run build:server", 11 | "build:client": "cross-env NODE_ENV=production webpack --config config/webpack.config.client.js", 12 | "build:server": "cross-env NODE_ENV=production webpack --config config/webpack.config.server.js" 13 | }, 14 | "dependencies": { 15 | "@loadable/component": "^5.1.2", 16 | "@loadable/server": "^5.1.3", 17 | "axios": "^0.18.0", 18 | "express": "^4.16.3", 19 | "jsonp": "^0.2.1", 20 | "react": "^16.4.2", 21 | "react-dom": "^16.4.2", 22 | "react-helmet": "^5.2.0", 23 | "react-redux": "^5.0.7", 24 | "react-router-config": "^1.0.0-beta.4", 25 | "react-router-dom": "^4.3.1", 26 | "redux": "^4.0.0", 27 | "redux-thunk": "^2.3.0" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.1.6", 31 | "@babel/preset-env": "^7.4.1", 32 | "@loadable/babel-plugin": "^5.0.1", 33 | "@loadable/webpack-plugin": "^5.0.2", 34 | "@types/react": "^16.4.18", 35 | "@types/react-dom": "^16.0.9", 36 | "@types/react-helmet": "^5.0.7", 37 | "@types/react-redux": "^6.0.9", 38 | "@types/react-router-dom": "^4.3.1", 39 | "@types/webpack-env": "^1.13.6", 40 | "@typescript-eslint/eslint-plugin": "^1.6.0", 41 | "@typescript-eslint/parser": "^1.6.0", 42 | "autoprefixer": "^9.3.1", 43 | "babel-loader": "^8.0.4", 44 | "babel-plugin-dynamic-import-node": "^2.2.0", 45 | "cross-env": "^5.2.0", 46 | "css-loader": "^1.0.0", 47 | "eslint": "^5.16.0", 48 | "eslint-loader": "^2.1.2", 49 | "eslint-plugin-react": "^7.12.4", 50 | "file-loader": "^2.0.0", 51 | "fork-ts-checker-webpack-plugin": "^0.4.10", 52 | "memory-fs": "^0.4.1", 53 | "mini-css-extract-plugin": "^0.4.4", 54 | "optimize-css-assets-webpack-plugin": "^5.0.1", 55 | "postcss-flexbugs-fixes": "^4.1.0", 56 | "postcss-loader": "^3.0.0", 57 | "rimraf": "^2.6.2", 58 | "style-loader": "^0.23.0", 59 | "stylus": "^0.54.5", 60 | "stylus-loader": "^3.0.2", 61 | "ts-loader": "^5.2.2", 62 | "typescript": "^3.2.1", 63 | "uglifyjs-webpack-plugin": "^1.3.0", 64 | "url-loader": "^1.1.1", 65 | "webpack": "^4.22.0", 66 | "webpack-cli": "^3.1.2", 67 | "webpack-dev-middleware": "^3.4.0", 68 | "webpack-hot-middleware": "^2.23.1", 69 | "webpack-merge": "^4.1.4", 70 | "webpack-node-externals": "^1.7.2" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /config/webpack.config.client.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const merge = require("webpack-merge"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); 5 | const LoadablePlugin = require("@loadable/webpack-plugin"); 6 | const baseWebpackConfig = require("./webpack.config.base"); 7 | const util = require("./util"); 8 | 9 | const isProd = process.env.NODE_ENV === "production"; 10 | 11 | const resolve = relativePath => path.resolve(__dirname, relativePath); 12 | 13 | const webpackConfig = merge(baseWebpackConfig, { 14 | entry: { 15 | app: "./src/entry-client.tsx" 16 | }, 17 | output: { 18 | filename: "static/js/[name].[chunkhash].js" 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.(ts|tsx)$/, 24 | use: [ 25 | { 26 | loader: "babel-loader", 27 | options: { 28 | babelrc: false, 29 | presets: [ 30 | [ 31 | "@babel/preset-env", // @loadable/babel-plugin处理后存在es6的语法 32 | { 33 | "modules": false 34 | } 35 | ] 36 | ], 37 | plugins: [ 38 | "@loadable/babel-plugin" 39 | ] 40 | } 41 | }, 42 | { 43 | loader: "ts-loader", 44 | options: { 45 | // 支持HMR和禁用类型检查,类型检查将使用ForkTsCheckerWebpackPlugin 46 | transpileOnly: true 47 | } 48 | }, 49 | { 50 | loader: "eslint-loader" 51 | } 52 | ], 53 | include: [ resolve("../src") ] 54 | }, 55 | ...util.styleLoaders({ 56 | sourceMap: isProd ? true : false, 57 | usePostCSS: true, 58 | extract: isProd ? true : false 59 | }) 60 | ] 61 | }, 62 | optimization: { 63 | splitChunks: { 64 | chunks: "all", // chunk选择范围 65 | cacheGroups: { 66 | vendor: { 67 | test: function(module) { 68 | // 阻止.css文件资源打包到vendor chunk中 69 | if(module.resource && /\.css$/.test(module.resource)) { 70 | return false; 71 | } 72 | // node_modules目录下的模块打包到vendor chunk中 73 | return module.context && module.context.includes("node_modules"); 74 | } 75 | } 76 | } 77 | }, 78 | // webpack引导模块 79 | runtimeChunk: { 80 | name: "manifest" 81 | } 82 | }, 83 | plugins: [ 84 | // 在单独的进程中执行类型检查加快编译速度 85 | new ForkTsCheckerWebpackPlugin({ 86 | async: false, 87 | tsconfig: resolve("../tsconfig.json") 88 | }), 89 | new LoadablePlugin({ 90 | filename: "client-manifest.json", 91 | }) 92 | ] 93 | }); 94 | 95 | if (isProd) { 96 | webpackConfig.plugins.push( 97 | new MiniCssExtractPlugin({ 98 | filename: "static/css/[name].[contenthash].css" 99 | }) 100 | ); 101 | } 102 | 103 | module.exports = webpackConfig; 104 | -------------------------------------------------------------------------------- /server/renderer.js: -------------------------------------------------------------------------------- 1 | const React = require("react"); 2 | const ReactDOMServer = require("react-dom/server"); 3 | const { matchRoutes } = require("react-router-config"); 4 | const { Helmet } = require("react-helmet"); 5 | const { ChunkExtractor, ChunkExtractorManager } = require("@loadable/server"); 6 | 7 | class ServerRenderer { 8 | constructor(bundle, template, manifest) { 9 | this.template = template; 10 | this.manifest = manifest; 11 | this.serverEntry = this._createEntry(bundle); 12 | } 13 | renderToString(request, staticContext) { 14 | return new Promise((resolve, reject) => { 15 | const serverEntry = this.serverEntry; 16 | 17 | const createApp = serverEntry.createApp; 18 | const createStore = serverEntry.createStore; 19 | const router = serverEntry.router; 20 | 21 | const store = createStore({}); 22 | 23 | const render = () => { 24 | // 存放组件内部路由相关属性,包括状态码,地址信息,重定向的url 25 | let context = {}; 26 | 27 | if (staticContext && staticContext.constructor === Object) { 28 | Object.assign(context, staticContext); 29 | } 30 | 31 | let component = createApp(context, request.url, store); 32 | let extractor = new ChunkExtractor({ 33 | stats: this.manifest, 34 | entrypoints: ["app"] // 入口entry 35 | }); 36 | let root = ReactDOMServer.renderToString( 37 | React.createElement( 38 | ChunkExtractorManager, 39 | { extractor }, 40 | component) 41 | ); 42 | 43 | if (context.url) { // 当发生重定向时,静态路由会设置url 44 | resolve({ 45 | error: {url: context.url} 46 | }); 47 | return; 48 | } 49 | 50 | if (context.statusCode) { // 有statusCode字段表示路由匹配失败 51 | resolve({ 52 | error: {code: context.statusCode} 53 | }); 54 | } else { 55 | // store.getState() 获取预加载的state,供客户端初始化 56 | resolve({ 57 | error: undefined, 58 | html: this._generateHTML(root, extractor, store.getState()) 59 | }); 60 | } 61 | } 62 | 63 | let promises; 64 | // 匹配路由 65 | let matchs = matchRoutes(router, request.path); 66 | promises = matchs.map(({ route, match }) => { 67 | const asyncData = route.asyncData; 68 | // match.params获取匹配的路由参数 69 | return asyncData ? asyncData(store, Object.assign(match.params, request.query)) : Promise.resolve(null); 70 | }); 71 | 72 | // resolve所有asyncData 73 | Promise.all(promises).then(() => { 74 | // 异步数据请求完成后进行render 75 | render(); 76 | }).catch(error => { 77 | reject(error); 78 | }); 79 | }); 80 | } 81 | _createEntry(bundle) { 82 | const file = bundle.files[bundle.entry]; 83 | 84 | // 读取内容并编译模块 85 | const vm = require("vm"); 86 | const sandbox = { 87 | console, 88 | module, 89 | require 90 | }; 91 | vm.runInNewContext(file, sandbox); 92 | 93 | return sandbox.module.exports; 94 | } 95 | _generateHTML(root, extractor, initalState) { 96 | // 必须在组件renderToString后获取 97 | let head = Helmet.renderStatic(); 98 | // 替换注释节点为渲染后的html字符串 99 | return this.template 100 | .replace(/.*<\/title>/, `${head.title.toString()}`) 101 | .replace("<!--react-ssr-head-->", 102 | `${head.meta.toString()}\n${head.link.toString()}\n${extractor.getLinkTags()}\n${extractor.getStyleTags()} 103 | <script type="text/javascript"> 104 | window.__INITIAL_STATE__ = ${JSON.stringify(initalState)} 105 | </script> 106 | `) 107 | .replace("<!--react-ssr-outlet-->", `<div id="app">${root}</div>\n${extractor.getScriptTags()}`); 108 | } 109 | } 110 | 111 | module.exports = ServerRenderer; 112 | --------------------------------------------------------------------------------