1. package.json
{
"name": "react-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:build:client": "webpack --config config/webpack.client.js --watch",
"dev:build:server": "webpack --config config/webpack.server.js --watch",
"dev:start": "nodemon --watch dist --exec node \"./dist/bundle.js\""
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-env": "^7.22.20",
"@babel/preset-react": "^7.22.15",
"@babel/preset-stage-0": "^7.8.3",
"@babel/preset-typescript": "^7.23.0",
"@reduxjs/toolkit": "^1.9.7",
"@types/react": "^18.2.27",
"@types/react-dom": "^18.2.12",
"antd": "^5.10.0",
"autoprefixer": "^9.7.3",
"axios": "^1.5.1",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "7",
"clean-webpack-plugin": "^3.0.0",
"cross-env": "^7.0.3",
"css-loader": "5.0.0",
"eslint-loader": "^4.0.2",
"express-http-proxy": "^2.0.0",
"file-loader": "^5.0.2",
"happypack": "^5.0.1",
"html-webpack-plugin": "^3.2.0",
"less": "^3.10.3",
"less-loader": "5.0.0",
"lodash": "^4.17.15",
"mini-css-extract-plugin": "^0.8.0",
"moment": "^2.24.0",
"node-sass": "^9.0.0",
"nodemon": "^3.0.1",
"npm-run-all": "^4.1.5",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"postcss": "^8.4.31",
"postcss-loader": "^3.0.0",
"postcss-pxtorem": "5.0.0",
"react-activation": "^0.12.4",
"react-redux": "^8.1.3",
"recoil": "^0.7.7",
"redux": "^4.2.1",
"redux-persist": "^6.0.0",
"sass": "^1.69.3",
"sass-loader": "5.0.0",
"style-loader": "^1.0.1",
"terser-webpack-plugin": "^2.2.2",
"thread-loader": "^4.0.2",
"typescript": "^5.2.2",
"url-loader": "^3.0.0",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.9.0",
"webpack-merge": "^4.2.2",
"webpack-node-externals": "^3.0.0",
"webpack-parallel-uglify-plugin": "^1.1.2",
"yarn": "^1.22.19"
},
"dependencies": {
"express": "^4.18.2",
"path": "^0.12.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-config": "1.0.0-beta.4",
"react-router-dom": "4.3.1"
}
}
2. 新建.babelrc文件
{
"presets": [
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": []
}
3. 新建tsconfig.json文件
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noImplicitAny": false
},
"include": [
"src"
]
}
4. config目录
paths.js
const path = require('path');
const srcPath = path.resolve(__dirname, '..', 'src');
const distPath = path.resolve(__dirname, '..', 'dist');
module.exports = {
srcPath,
distPath
}
webpack配置文件
(1) webpack.base.js提取公共配置代码
module.exports = {
// 打包的规则
module: {
rules: [
{
test: /\.[jt]sx?$/, // 检测文件类型
loader: 'babel-loader', // 注意下载babel-loader babel-core
exclude: /node_modules/, // node_modules目录文件不编译
}
]
}
}
(2) webpack.server.js服务器配置
使用webpack-merge合并公共配置代码
const nodeExternals = require('webpack-node-externals');
const { distPath, srcPath } = require('./paths');
const path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');
const serverConfig = {
mode: 'production', // 也可以写development
target: 'node', // 告诉webpack打包的代码是服务器端文件
entry: './src/server/index.js',
output: { // 打包生成的文件应该放到哪儿去
filename: 'bundle.js',
path: distPath,
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js'],
// 针对npm中的第三方模块优先采用jsnext中指向的es6模块语法的文件
mainFields: ['jsnext:main', 'brower', 'main'],
alias: {
"components": path.resolve(srcPath, "containers/components"), //配置样式简单的路径
"@": srcPath // 把 src 这个常用目录修改为 @
},
},
externals: [
/**
* 1、如何让webpackexternals不影响测试环境?
由于webpackexternals将部分库文件排除在打包范围之外,这样在某些情况下可能会影响单元测试的运行,可以使用webpack-node-externals来排除node_modules目录下的所有依赖项。
*/
nodeExternals(),
],
}
module.exports = merge(config, serverConfig);
(3) webpack.client.js客户端配置
使用webpack-merge合并公共配置代码
const { distPath, srcPath, publicPath } = require('./paths');
const path = require('path');
const merge = require('webpack-merge');
const config = require('./webpack.base.js');
const clientConfig = {
mode: 'production', // 也可以写development
entry: './src/client/index.js',
output: { // 打包生成的文件应该放到哪儿去
filename: 'index.js',
path: publicPath,
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js'],
// 针对npm中的第三方模块优先采用jsnext中指向的es6模块语法的文件
mainFields: ['jsnext:main', 'brower', 'main'],
alias: {
"components": path.resolve(srcPath, "containers/components"), //配置样式简单的路径
"@": srcPath // 把 src 这个常用目录修改为 @
},
},
}
module.exports = merge(config, clientConfig);
5. src/App.tsx
import React from "react";
import Header from "components/header";
import { renderRoutes } from 'react-router-config'
import _ from 'lodash'
const App = (props) => {
return (
<section>
<Header />
{/* 显示页面对应的内容 */}
{renderRoutes(props.route.routes)}
</section>
);
};
export default App;
6. src/routes.tsx
嵌套路由, 让head组件一直存在, head之下的页面作为子路由
import React from 'react'
import Home from './containers/home'
import Login from './containers/login'
import App from './App';
const routes = [
{
path: '/',
component: Home,
loadData: Home.loadData,
exact: true,
key: 'home',
},
{
path: '/login',
component: Login,
loadData: Login.loadData,
exact: true,
key: 'login',
}
]
export default [{
path: '/',
component: App,
routes,
}]
7. src/store/index.ts
redux-thunk和redux的使用
1) 引入需要的reducer1, reducer2...
2) 使用combineReducers组合引入的reducer1, reducer2..., 构成新的reducer
3) 使用createStore创建store实例, 传入reducer和中间件!!!
createstore语法:
const store = createStore(reducer, [preloadedState], enhancer);
/**
*createStore接受3个参数:
第一个是reducer,
第二个是初始的state,
第三个enhancer是store的增强器。
执行本方法返回一个对象,
包含dispatch,subsribe,getState,replaceReducer,observable五个方法。
*/
a. 服务端获取store实例
const store = createStore(
reducer,
applyMiddleware(thunk.withExtraArgument(serverAxios)); // 注意传服务端的axios实例
);
b. 客户端获取store实例
// 拿服务端的数据作为客户端的数据
const defaultState = window.context.state
const store = createStore(
reducer,
defaultState,
// 改变客户端store的内容, 那么一定要使用clientAxios
applyMiddleware(thunk.withExtraArgument(clientAxios)) // 注意传客户端的axios实例
);
import { legacy_createStore as createStore, applyMiddleware, combineReducers } from "redux";
import thunk from 'redux-thunk';
import { reducer as homeReducer } from '../containers/home/store'
import clientAxios from '@/client/request'
import serverAxios from '@/server/request'
// 将各个模块的reducer组合一下
const reducer = combineReducers({
home: homeReducer,
});
// 导出getStore是因为想要每个用户的store都是独享的!!!
export const getStore = () => {
const store = createStore(
reducer,
// 改变服务器store的内容, 那么一定要使用serverAxios
applyMiddleware(thunk.withExtraArgument(serverAxios))
);
return store
}
export const getClientStore = () => {
// 拿服务端的数据作为客户端的数据
const defaultState = window.context.state
const store = createStore(
reducer,
defaultState,
// 改变客户端store的内容, 那么一定要使用clientAxios
applyMiddleware(thunk.withExtraArgument(clientAxios))
);
return store
}
8. src/containers(组件页面目录)/
home目录
home/index.tsx
import React, {useEffect} from "react";
import { connect } from "react-redux";
import { getHomeList } from './store/actions'
import _ from 'lodash'
// 同构--同一套react代码, 在服务器执行一次, 在客户端再执行一次
const Home = (props) => {
// 如果是class组件使用componentDidMount生命周期
useEffect(()=> {
// 避免首屏时服务端和客户端都各自调用了一次!!!
if(!props.list.length) props.getHomeList()
}, [])
const getList = () => {
return <ul>
{_.map(props.list, (item) => {
return <li key={item.uid}><span>{item.code}: </span>{item.type}</li>
})}
</ul>
}
return (
<section>
<div>this is {props.name}</div>
{getList()}
<button onClick={() => alert(11)}>click</button>
</section>
);
};
Home.loadData = (store) => {
// 这个函数, 负责在服务器渲染之前, 把这个路由需要的数据提前加载好
return store.dispatch(getHomeList())
}
const mapStateToProps = (state) => {
return ({
name: state.home.name,
list: state.home.newsList,
});
}
const mapDispatchToProps = (dispatch) => ({
getHomeList() {
console.log('test')
dispatch(getHomeList())
}
});
export default connect(mapStateToProps, mapDispatchToProps)(Home);
home/store目录
home/store/index.ts
import reducer from './reducer'
export { reducer }
home/store/actions.ts
import { CHANGE_LIST } from './constants'
const changeList = (list) => ({
type: CHANGE_LIST,
list,
})
export const getHomeList = (server) => {
let url1 = `/app/mock/22915/api/code`
// const url1 = 'http://rap2api.taobao.org/app/mock/22915/api/code'
return (dispatch, getState, axiosInstance) => {
return axiosInstance.post(url1, {
rows: 10,
page: 1
})
.then(res => {
const list = res.data.rows;
dispatch(changeList(list.slice(0, 10)))
})
}
}
home/store/constants.ts
// 定义常量
export const CHANGE_LIST = 'HOME/CHANGE_LIST'
home/store/reducer.ts
import { CHANGE_LIST } from './constants'
const defaultState = {
newsList: [],
name: 'Tom Li-1'
}
const reducer = (state:any = defaultState, action) => {
switch (action.type) {
case CHANGE_LIST:
return {
...state,
newsList: action.list
};
default:
return state;
}
};
export default reducer
login/index.tsx
import React from 'react'
import Header from '../components/header';
const Login = () => {
return <div>
<Header />
<div>login</div>
</div>
}
export default Login;
components/公共组件目录
header/index.tsx
import React from 'react'
import {Link} from 'react-router-dom'
// 同构--同一套react代码, 在服务器执行一次, 在客户端再执行一次
const Header = () => {
return <div>
<Link to="/">HOME</Link>
<Link to="/login">LOGIN</Link>
</div>
}
export default Header
9. src/server(服务端代码目录)/
index.tsx
// 注意store要使用服务端定义的store
import express from 'express';
import React from 'react';
import { render } from './utils'
import routes from '../routes'
import { getStore } from '../store'
import { matchPath } from 'react-router-dom'
import _ from 'lodash'
import proxy from 'express-http-proxy'
var app = express();
/**
* 客户端渲染
* react代码在浏览器上执行, 消耗的是用户浏览器的性能
*
* 服务器渲染
* react代码在服务器上执行, 消耗的是服务器端的性能(或者资源)
* 报错信息查询网站: stackoverflow.com
*/
// 服务器请求/api的时候, 做代理
app.use('/api', proxy('http://rap2api.taobao.org', {
proxyReqPathResolver(req) {
console.log(req.url,'req.url=======')
// const parts = req.url.split('?')
// const queryString = parts[1]
// const updatedPath = parts[0].replace(/test/, 'tent');
return req.url
}
}));
// 只要是静态文件, 都到public目录找
app.use(express.static('public'));
// * => 任意路径都能走到下列的方法
app.get('*', function (req, res) {
const store = getStore()
// 如果在这里, 能够拿到异步数据并填充到store之中
// store里面到底填充什么, 我们不知道, 我们需要结合当前用户请求地址, 和路由, 做判断
// 如果用户访问根/路径, 我们就拿home组件的异步数据
// 如果用户访问/login路径, 我们就拿login组件的异步数据
// 根据路由的路径, 来往store里面加数据
const matchedRoutes = [];
function getRoutes(routes) {
routes.some(route => {
const match = matchPath(req.path, route);
if (match) {
matchedRoutes.push(route)
}
if (Array.isArray(_.get(route, 'routes')) && _.get(route, 'routes')) {
getRoutes(_.get(route, 'routes'))
}
return match
})
}
getRoutes(routes)
// 让matchRoutes里面所有的组件, 对应的loadData执行一次
const promises = []
matchedRoutes.forEach(route => {
try {
promises.push(route.loadData(store))
} catch (error) {
}
})
console.log(matchedRoutes, 'matchedRoutes==')
Promise.all(promises)
.then(() => {
res.send(render({store, routes, req}))
})
})
var server = app.listen(2000);
utils.tsx
添加script标签是因为模板字符串渲染成dom, onClick等事件没有反应, 所以script标签再同构一下
import React from 'react';
import { renderToString } from "react-dom/server"
import { StaticRouter, Route } from 'react-router-dom'
import { Provider } from 'react-redux'
import _ from 'lodash'
import { renderRoutes } from 'react-router-config'
export const render = ({store, routes, req}) => {
// 虚拟dom是真实dom的一个JavaScript对象的映射
const content = renderToString((
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<div>
{renderRoutes(routes)}
</div>
</StaticRouter>
</Provider>
))
// 当store数据更新完成再渲染dom
return (
`<html>
<head>
<title>ssr</title>
<link rel="icon" href="/flower.jpg" />
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = ${JSON.stringify({state:store.getState()})}
</script>
<script src="/index.js"></script>
</body>
</html>`
)
}
request/index.ts
导出服务端axios实例
import axios from "axios";
const instance = axios.create({
baseURL: 'http://rap2api.taobao.org'
})
export default instance
10. src/client(客户端代码目录)/
index.tsx
// 注意store要使用客户端定义的store
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter, Route } from "react-router-dom";
import routes from "../routes";
import { Provider } from "react-redux";
import { getClientStore } from '../store';
import { renderRoutes } from 'react-router-config'
const App = () => {
return (
<Provider store={getClientStore()}>
<BrowserRouter>
<div>
{renderRoutes(routes)}
</div>
</BrowserRouter>
</Provider>
);
};
ReactDOM.hydrate(<App />, document.getElementById("root"));
request/index.ts
导出客户端axios实例
import axios from "axios";
const instance = axios.create({
baseURL: '/api'
})
export default instance
11. 因为npm-run-all, 所以执行yarn dev就能运行代码并监听组件是否修改了
修改home组件, 刷新浏览器就行了