本文将讲解如何构建基于React同构的SPA应用,涉及到的技术或知识点有如下若干(按字母排序,部分可选):
babel fetch http2 intl isomorphic koa material-ui
postcss pwa react redux rxjs socket.io webpack …
项目地址
Step 1 创建项目并安装依赖包
新建文件夹myapp, 在此文件夹内建立如下项目结构:
|-- config // *项目配置*
| |-- asset // 静态资源配置
| |-- intl // 语言包配置
| |-- server // 服务端配置
| |-- webpack // 前端工程配置
|-- src // *项目源文件*
| |-- client // 前端代码
| | |-- action // redux.action
| | |-- component // 组件
| | |-- container // 页面
| | |-- css // 样式
| | |-- decorator // 装饰器
| | |-- epic // redux-observable.epic
| | |-- reducer // redux.reducer
| | |-- route // 页面路由
| | |-- selector // reselect
| | |-- service-worker // pwa.sw
| | |-- socket // socket功能
| | |-- store // redux.store
| |-- server // 服务端代码
| | |-- middleware // 中间件
| | |-- route // api路由
| | |-- socket // socket功能
| | |-- view // 视图模板
| |-- universal // 通用代码
|-- static // *静态资源*
由于npm的普适性,本文讲述中不采用yarn,本文所有命令均在git bash中执行
执行以下命令初始化项目相关信息:
npm init
执行以下命令安装项目依赖包:
npm install babel-cli babel-core babel-loader babel-plugin-transform-class-properties babel-plugin-transform-decorators-legacy babel-plugin-transform-runtime babel-preset-latest babel-preset-react babel-preset-stage-0 classnames co-views copy-webpack-plugin core-decorators css-loader extract-text-webpack-plugin file-loader ip isomorphic-fetch isomorphic-style-loader json-loader koa@next koa-favicon@next koa-locale koa-router@next koa-static@next material-ui normalize.css offline-plugin postcss postcss-cssnext postcss-import@8.1.2 postcss-loader postcss-nested react react-dom react-hot-loader react-intl react-intl-redux react-motion react-redux react-router react-tap-event-plugin redux redux-logger redux-observable reselect rxjs socket.io socket.io-client spdy style-loader swig universal-webpack url-loader webpack webpack-node-externals --save
查看myapp下package.json文件可见(版本号可能不同):
...
"dependencies": {
"babel-cli": "^6.18.0",
"babel-core": "^6.0.0",
"babel-loader": "^6.2.7",
...
"url-loader": "^0.5.7",
"webpack": "^1.13.3",
"webpack-node-externals": "^1.5.4"
},
...
依赖包详解
babel-cli babel脚手架可从命令行直接编译文件
babel-core babel核心包
babel-loader webpack插件,用于编译js
babel-plugin-transform-class-properties 用于格式化类的属性,比如react组件中写class,要写propTypes,该插件可以把这些多余的代码删除(确切是格式化,但效果就是删除)。
babel-plugin-transform-decorators-legacy 这是一个用于babel6的插件,用于复制babel5的旧装饰器行为,以便允许人们更容易地转换到babel6,避免在更新装饰器出问题,或者为babel6重新实现一个装饰器。
babel-plugin-transform-runtime 提供帮助类和内置类函数的外部引用,并自动修补代码且不污染全局环境。简而言之就是可以随意使用es6的各种特性,你不必关心用到了哪个,而从polyfill中去import相应的函数,该插件会自动处理好所有兼容问题。
babel-preset-latest 包含了es2015/es2016/es2017…所有特性
babel-preset-react 用于转换jsx语法
babel-preset-stage-0 特性插件大集合,包含了stage-1/2/3…
classnames 以js表达式的方式生成类名,用于jsx中写dom的class,当然某些class需要混合js逻辑的时候就很有用。
co-views koa模板引擎渲染平台,是对express通用模板引擎渲染平台 consolidate 的封装
copy-webpack-plugin webpack插件,用于拷贝文件或目录
core-decorators 基于stage0语法的未被js内部实现的装饰器,可用于react/angular。
css-loader webpack插件,用于打包css文件。
extract-text-webpack-plugin webpack插件,用于提取css代码到独立的文件中,而不是内嵌到html文件中。
file-loader webpack插件,用于将文件转换为路径。
ip 生成ip地址,主要用于开发环境时webpack的静态资源路径。
isomorphic-fetch 同时用于node和browserify的fetch API。
isomorphic-style-loader webpack插件,用于同构应用处理css样式部分。
json-loader webpack插件,用于处理json文件,转换为object。
koa 下一代node框架,由原express团队打造,本身不包含任何中间件。
koa-favicon koa中间件,用于设置favicon。
koa-locale koa中间件,用于从请求中获取locale信息以便设置intl语言代码。
koa-router koa中间件,服务端路由设置。
koa-socket koa中间件,封装了socket.io,实现了基于http服务的ws。
koa-sslify koa中间件,对于http请求强制跳转https。
koa-static koa中间件,提供静态文件服务。
material-ui 实现了Google Material Design的react组件库。
normalize.css css resets。
offline-plugin webpack插件,基于service-worker等技术实现离线功能。
postcss 使用js插件来处理css样式代码的工具。
postcss-cssnext postcss插件,可以使用最新的css语法。
postcss-import 用于处理@import,选用8.1.2的是因为更高版本新增了一个warning功能,这个待解决,但不影响功能。
postcss-loader webpack插件,用于使用postcss插件处理css。
postcss-nested postcss插件,使得css可以像sass一样进行嵌套书写。
react js框架。
react-dom react部分api被拆分至此库。
react-hot-loader webpack插件,react热更新。
react-intl react国际化库。
react-intl-redux 封装了react-intl/react-redux等库。
react-motion react动效解决方案。
react-redux 官方的封装了react/redux的库。
react-router react路由。
react-tap-event-plugin react插件,提供tap event支持。
redux react状态容器。
redux-logger redux日志插件。
redux-observable 用于redux里action的rxjs中间件。
reselect redux状态选择器,为不同的组件选择不同的状态。
rxjs js响应式扩展,适用场景:1.异步操作重,2.同时处理多个数据源。
socket.io 基于websocket的实时应用框架
socket.io-client socket.io的客户端
spdy Google 开发的基于传输控制协议 (TCP) 的应用层协议 ,开发组正在推动 SPDY 成为正式标准(现为互联网草案)。SPDY协议旨在通过压缩、多路复用和优先级来缩短网页的加载时间和提高安全性。(SPDY 是 Speedy 的昵音,意思是更快)
style-loader webpack插件,将css添加到dom中。
swig 模板引擎
universal-webpack webpack同构工具
url-loader webpack插件,对于需要处理的文件,会以url的方式来请求,可设置文件大小阀值,小于阀值则使用file-loader处理。
webpack 模块加载器
webpack-node-externals webpack插件,用以node端不打包node_modules里的库
Step 2 创建项目配置、通用代码、静态资源
项目配置 ./config/index.js
const asset = require('./asset');
const intl = require('./intl');
const server = require('./server');
const siteName = 'devlee.io';
module.exports = {
asset: asset,
intl: intl,
server: server,
siteName: siteName
};
输出各分类配置
客户端打包文件配置: ./config/asset/index.js
module.exports = {
development: {
port: 8066,
prefix: '/'
},
production: {
port: 8066
}
};
输出不同开发环境下所需变量,这里有端口和路径相关的变量。
服务端应用程序配置: ./config/server/index.js
module.exports = {
development: {
port: 80,
ports: 443
},
production: {
port: 80,
ports: 443
}
};
./config/server/ssl下的devlee.io.crt和devlee.io.key为证书
intl国际化配置: ./config/intl/index.js
const en = require('./en');
const zh = require('./zh');
module.exports = {
en: en,
zh: zh
};
这里直接输出具体的语言包
英语: ./config/intl/en.js
module.exports = {
locale: 'en',
messages: {
nav: {
home: 'Home',
about: 'About',
demo: 'Demo'
}
}
};
中文: ./config/intl/zh.js
module.exports = {
locale: 'zh',
messages: {
nav: {
home: '首页',
about: '关于'
}
}
};
中文代码最好区分zh-CN,zh-TW
webpack: ./config/webpack/index.js
...
const webpackConfig = {
context: rootFolder,
resolve: {
extensions: [
'',
'.js',
'.jsx',
'.json'
]
},
entry: {
common: [
'normalize.css',
'./src/client/css/font-icons/style.css',
'./src/client/css/common.pcss'
],
app: [
'./src/client/index.jsx'
]
},
output: {
publicPath: assetPath,
path: path.resolve(rootFolder, './build'),
filename: '[name].js',
chunkFilename: '[name].js'
},
module: {
loaders: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
loader: 'babel'
},
{
test: /\.json$/,
loader: 'json'
},
{
test: /\.css$/,
loader: extractTextWebpackPlugin.extract(
'style',
'css?-autoprefixer'
)
},
{
test: /\.pcss$/,
loader: extractTextWebpackPlugin.extract(
'isomorphic-style',
'css?modules&localIdentName=[hash:base64:5]&-autoprefixer&importLoaders=1!postcss'
)
},
{
test: /\.(jpg|jpeg)$/i,
loader: 'file'
},
{
test: /\.(ico|gif|png|woff|woff2|eot|ttf|svg)$/i,
loader: 'url'
}
]
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'common'
}),
/* eslint-disable new-cap */
new extractTextWebpackPlugin('[name].css'),
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(env),
PWA: JSON.stringify(pwa)
}
})
],
devServer: {
host: '0.0.0.0',
port: assetConfigPort
},
externals: {
react: 'React',
'react-dom': 'ReactDOM',
'react-router': 'ReactRouter'
},
regular_expressions: {
javascript: /\.(js|jsx)$/,
styles: /\.(css|pcss)$/
},
postcss: () => {
return [
postcssImport(),
postcssCssNext({ browsers: ['> 0%'] }),
postcssNested()
];
}
};
module.exports = webpackConfig;
webpackConfig属性详解:
context: 编译的文件相对本文件所在目录的相对路径,简单来说相对本配置文件将其设置为项目根目录就可了。本文件路径 ./config/webpack/index.js,根目录相对本文件的目录就是 path.resolve(__dirname, ‘../..’)
resolve: 需要解决的模块,就是需要编译处理的文件类型。
entry: 入口文件
output: 编译输出文件
module.loaders: 各种模块loader集合
plugins: 插件,这里依此使用了提取公共代码的插件、 提取style到css文件中的插件、 全局变量插件
devServer: 开发服务器配置
externals: 这部分库不进行编译打包
regular_expressions: UniversalWebpack配置需要
postcss: postcss插件配置
webpack.setting: ./config/webpack/setting.js
module.exports = {
server: {
input: './src/server/index.js',
output: './build/server/index.js'
}
};
该文件配置服务端代码编译入口及出口文件路径。
webpack.client: ./config/webpack/client.js
...
delete webpackConfig.externals;
webpackConfig.plugins = webpackConfig.plugins || [];
webpackConfig.plugins.push(
new webpack.DefinePlugin({
'process.env': {
CLIENT: JSON.stringify(true)
}
})
);
if (pwa) {
webpackConfig.plugins.unshift(
new CopyWebpackPlugin([{
from: path.resolve(rootFolder, './static'),
to: path.resolve(rootFolder, './build')
}])
);
webpackConfig.plugins.push(
new OfflinePlugin({
ServiceWorker: {
navigateFallbackURL: '/'
}
})
);
}
module.exports = webpackConfig;
客户端webpack配置在基础配置之上做一些处理:
(可选)这里删除externals是为了开发所需,实际上不删除也可以,只需要在html中加入相应库的cdn地址就可以了;
(必须)新增全局变量process.env.CLIENT为true
(可选)PWA模式下需要把static下的文件拷贝至build目录下,因为该模式下服务端会把build目录作为静态资源服务的目录;
(可选)PWA模式下需要设置OfflinePlugin,对于未命中sw的路径会跳转至根路径。
webpack.server: ./config/webpack/server.js
delete webpackConfig.externals;
webpackConfig = universalWebpack.serverConfiguration(webpackConfig, settings);
webpackConfig.node = {
__dirname: true,
__filename: true
};
webpackConfig.externals = [webpackNodeExternals()];
module.exports = webpackConfig;
服务端webpack配置在基础配置之上做一些处理:
(可选)这里删除externals其实没必要,下面会重写
(必须)利用UniversalWebpack生成新的服务端配置
(必须)UniversalWebpack默认的node属性里的属性值为false,我们这按需要必须要要设置为true,这样不会导致路径出问题。
(必须)利用webpackNodeExternals插件过滤掉node_modules里的库,避免打包
通用代码: ./src/universal
cookie处理: ./src/universal/cookie.js
...
export function setCookie(name, value, option, ctx) {
// server
if (ctx) {
return ctx.cookies.set(name, value, option);
}
// client
...
document.cookie = str;
}
export function getCookie(name, ctx) {
// server
if (ctx) {
if (!name) {
return ctx.cookies;
}
return ctx.cookies.get(name);
}
// client
if (!name) {
return parse(document.cookie);
}
return parse(document.cookie)[name];
}
export function clearCookie(name, option, ctx) {
// server
if (ctx) {
return ctx.cookies.set(name, null, option);
}
// client
setCookie(name, null, option);
}
输出三个函数分别为设置、获取、清除cookie,最后一个参数适用于服务端koa
域名设置: ./src/universal/domain.js
import { env } from './env';
let defDomain = 'localhost';
defDomain = (env === 'production' ? 'devlee.io' : defDomain);
const domain = defDomain;
export default domain;
根据环境输出当前域名
环境变量: ./src/universal/env.js
const pwa = process.env.PWA;
export const env = process.env.NODE_ENV || 'development';
export const isPwa = String(pwa) === 'true';
export const isDev = String(env) === 'development';
export const isClient = String(process.env.CLIENT) === 'true';
输出当前环境变量名、是否为PWA模式、是否为开发模式、是否是客户端环境
intl国际化相关变量: ./src/universal/intl.js
import en from 'react-intl/locale-data/en';
import zh from 'react-intl/locale-data/zh';
import config from '../../config';
const intlConfig = config.intl;
export const intlList = ['en', 'zh'];
export const intlPack = intlConfig;
export const intlData = {
en,
zh
};
输出国际化语言列表,语言包列表、react-intl提供的相关data数据
socket日志记录: ./src/universal/socket-log.js
import { isClient } from './env';
const env = isClient ? 'client' : 'server';
const prefix = `[socket.io-${env}] `;
const log = (ctx) => {
if (ctx) {
console.log('>>>');
console.log(`${prefix}EventType: ${ctx.type}`);
console.log(`${prefix}EventName: ${ctx.event}`);
console.log(`${prefix}Data Begin:`);
console.log(' ');
console.log(ctx.data);
console.log(' ');
console.log(`${prefix}Data End.`);
console.log('<<<');
}
};
export default log;
输出socket日志函数,用于on/emit时的记录
静态资源: ./static
服务端用favicon.ico
PWA用favicon.png
PWA用index.html
<!DOCTYPE html>
<html>
<head>
<title>React Isomorphic Seed</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#00bcd4">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<link rel="canonical" href="https://devlee.io" />
<link rel="stylesheet" href="/common.css">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div id="app"></div>
<noscript>
devlee.io
</noscript>
<script src="/common.js"></script>
<script src="/app.js"></script>
</body>
</html>
PWA用manifest.json
{
"name": "React Isomorphic PWA",
"short_name": "react pwa",
"icons": [{
"src": "favicon.png",
"type": "image/png",
"sizes": "192x192"
}],
"start_url": "/?utm_source=homescreen",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#00bcd4"
}
Step 3 创建前后端入口文件
client: ./src/client/index.jsx
...
window.onload = () => {
/* eslint-disable no-underscore-dangle */
if (isPwa) {
sw.init();
}
socket.init();
const store = configureStore(
reducer,
window.__INITIAL_STATE__
);
const state = store.getState();
injectTapEventPlugin();
render(
<Provider store={store}>
<Router history={browserHistory}>
{route(state)}
</Router>
</Provider>,
document.getElementById('app')
);
};
前端入口文件,我们需要对相关功能作初始化操作,包括service-worker(PWA mode),socket,redux store, material-ui tapEvent, react render。
(可选)如果当前是PWA模式,那么需要执行sw的init,来初始化service worker;
(可选)socket直接执行init方法即可;
(可选)如果使用redux,则需要store配置,需要reducer和初始状态集window.INITIAL_STATE,该值由服务端渲染到页面的script中;
(可选)如果使用redux,则需要获取state,利用store的getSate方法获得;
(可选)如果使用material-ui,则必须执行injectTapEventPlugin方法来注入tagEvent;
(可选)如果使用intl,Provider从react-intl-redux引入,否则从react-redux引入。
(可选)如果使用react-router,则需配置history和route
(必须)render方法必须,第一个参数是组件,第二个参数是挂载的dom对象
PWA模式:Progressive Web Apps
server: ./index.js
const UniversalWebpack = require('universal-webpack');
const config = require('./config/webpack');
const settings = require('./config/webpack/setting');
UniversalWebpack.server(config, settings);
该文件为服务端启动文件,即可通过node命令直接运行的。UniversalWebpack会通过webpack的配置config和服务端的设置settings来启动服务。
server: ./src/server/index.js
...
export default () => {
let httpsApp;
const httpApp = new Koa();
const serverConfig = config.server;
let app;
let options;
if (isPwa) {
httpsApp = new Koa();
app = httpsApp;
options = {
key: fs.readFileSync(path.resolve(rootFolder, './config/server/ssl/devlee.io.key')),
cert: fs.readFileSync(path.resolve(rootFolder, './config/server/ssl/devlee.io.crt'))
};
httpApp.use(ctx => {
ctx.status = 301;
ctx.redirect(`https://${ctx.hostname}:${serverConfig[env].ports}${ctx.path}${ctx.search}`);
ctx.body = 'Redirecting to https';
});
} else {
app = httpApp;
}
middleware.intl(app);
app.use(middleware.error)
.use(middleware.ssl)
.use(middleware.favicon)
.use(middleware.static)
.use(middleware.helper)
.use(middleware.navigator)
.use(middleware.view)
.use(router.routes())
.use(router.allowedMethods());
if (isPwa) {
http.createServer(httpApp.callback()).listen(serverConfig[env].port, () => {
console.log(`http app start at port ${serverConfig[env].port}`);
});
const httpsServer = spdy.createServer(
options,
httpsApp.callback()
);
middleware.io(httpsServer);
httpsServer.listen(serverConfig[env].ports, () => {
console.log(`https app start at port ${serverConfig[env].ports}`);
});
} else {
middleware.io(app);
app.listen(serverConfig[env].port, () => {
console.log(`http app start at port ${serverConfig[env].port}`);
});
}
};
该文件为主要入口文件,由于需要被UniversalWebpack所使用,所以必须export一个function,在function中写相关代码。
PWA模式下会启动http和spdy两种服务,spdy(强制https,支持http2),这里的http服务只做强制跳转https作用;
普通开发模式下只创建http服务;
两种模式下的koa实例都会加载中间件实现相应的功能,例如route,socket等。
Step 4 服务端中间件、路由、socket、视图模板搭建
中间件: ./src/server/middleware/index.js
export default {
error,
view,
favicon,
helper,
intl,
navigator,
io,
static: serverStatic,
ssl,
cookie
};
输出各分类中间件
error用于记录服务端错误;
view用于设置视图引擎;
favicon用于设置服务器favicon;
helper用于生成帮助类函数如css、script;
intl输出koa-locale用于获取locale值;
navigator用于material-ui配置;
io ./src/server/middleware/io/index.js
import IO from 'koa-socket';
import IO2 from 'socket.io';
import socket from '../../socket';
import onInit from './on';
import { isPwa } from '../../../universal/env';
let ioInstance;
const io = app => {
if (isPwa) {
ioInstance = IO2.listen(app);
ioInstance.sockets.on('connection', io2 => {
socket.init(io2);
onInit();
});
} else {
ioInstance = new IO();
ioInstance.attach(app);
socket.init(ioInstance);
onInit();
}
};
export default io;
PWA模式下将直接使用socket.io来初始化;
非PWA模式使用koa-socket库来初始化;
onInit方法会初始化socket监听事件;
static用于服务端静态资源配置;
ssl用于强制跳转https;
cookie用于koa路由;
路由: ./src/server/route/index.js
...
const router = new Router();
router.use(middleware.cookie);
data(router);
main(router);
export default router;
data为api路由,main为主页面路由
api路由: ./src/server/router/data/index.js
export default router => {
router.get('/test', ctx => {
ctx.body = 'test';
});
};
测试api
主页面路由: ./src/server/router/main/index.js
...
let injectTapEventPluginFlag = false;
function getTasks(renderProps, store) {
let tasks = [];
Object.keys(renderProps.components).map(component => {
if (component && component.WrappedComponent && component.WrappedComponent.fetchData) {
const tempTasks = component.WrappedComponent.fetchData(store.getState(),
store.dispatch, renderProps.params);
if (Array.isArray(tempTasks)) {
tasks = tasks.concat(tempTasks);
} else if (tempTasks.then) {
tasks.push(tempTasks);
}
}
return component;
});
return tasks;
}
export default router => {
router.get('/*', async ctx => {
let matchError;
let matchRedirect;
let matchProps;
let isomorphicHtml;
let locale = ctx.getLocaleFromHeader() || 'en';
if (!intlPack[locale]) {
locale = 'en';
}
const store = configureStore(reducer, {
intl: intlPack[locale]
});
const state = store.getState();
await match({
routes: route(state),
location: ctx.url
}, (err, redirectLocation, renderProps) => {
matchError = err;
matchRedirect = redirectLocation;
matchProps = renderProps;
});
if (matchProps) {
const tasks = getTasks(matchProps, store);
await Promise.all(tasks);
if (!injectTapEventPluginFlag) {
injectTapEventPlugin();
injectTapEventPluginFlag = true;
}
isomorphicHtml = renderToString(
<Provider store={store}>
<RouterContext {...matchProps} />
</Provider>
);
}
if (matchError) {
throw matchError;
} else if (matchRedirect) {
ctx.redirect(matchRedirect.pathname + matchRedirect.search);
} else if (isomorphicHtml) {
ctx.body = await ctx.render('app', {
isomorphicHtml,
isomorphicState: state,
locale
});
} else {
console.error('there was no route found matching the given location');
ctx.redirect('/');
ctx.status = 301;
ctx.body = 'Redirecting to home page';
}
});
};
仅仅一个get(/*)路由来处理
socket包装: ./src/server/socket/index.js
import log from '../../universal/socket-log';
import { isPwa } from '../../universal/env';
const socket = {};
let ioInstance = null;
const emit = (event, data) => {
log({
event,
data,
type: 'emit'
});
if (isPwa) {
ioInstance.emit(event, data);
} else {
ioInstance.broadcast(event, data);
}
};
const on = (event, cb) => {
ioInstance.on(event, cb);
};
socket.init = io => {
if (ioInstance === null || isPwa) {
ioInstance = io;
}
socket.io = ioInstance;
if (isPwa) {
io.use(async (ctx, next) => {
if (ctx && ctx.length > 1) {
log({
event: ctx[0],
data: ctx[1],
type: 'on'
});
}
await next();
});
} else {
io.use(async (ctx, next) => {
log({
event: ctx.event,
data: ctx.data,
type: 'on'
});
await next();
});
socket.init = () => {};
}
};
socket.emit = emit;
socket.on = on;
export default socket;
视图模板: ./src/server/view/index.twig
<!DOCTYPE html>
<html lang="{{ locale }}">
<head>
<title>{{ title || 'React Isomorphic Seed' }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#00bcd4">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/favicon.png">
<link rel="manifest" href="/manifest.json">
<link rel="canonical" href="https://devlee.io" />
{{ css('common') }}
{{ css('app') }}
</head>
<body>
<div id="app">{{ isomorphicHtml|raw }}</div>
<script>
window.__INITIAL_STATE__ = {{ isomorphicState|json|raw }};
</script>
<noscript>
devlee.io
</noscript>
{{ script('common') }}
{{ script('app') }}
</body>
</html>
Step 5 客户端根组件及页面级组件
根组件App: ./src/client/component/App/index.jsx
...
@connect()
@autobind
export default class App extends React.PureComponent {
static propTypes = {
children: React.PropTypes.object,
location: React.PropTypes.object
};
render() {
const context = {
userAgent: navigator.userAgent || 'all'
};
const { location, children } = this.props;
return (
<MuiThemeProvider muiTheme={getMuiTheme(context)}>
<div className="root app-component">
<Nav location={location} />
{ children }
</div>
</MuiThemeProvider>
);
}
}
使用装饰器connect来链接状态管理器redux
使用装饰器autobind来自动绑定类的方法
使用material-ui的MuiThemeProvider包装以使用其组件,通过navigator.userAgent来配置组件的样式,即该组件系统会根据用户访问的环境不同而表现出不同的样式(内置)
页面级组件放置目录位于./src/client/container
Step 6 客户端基础样式及组件样式
基础样式目录: ./src/client/css
common.pcss 中包含了共用样式
variable.pcss 中包含了样式变量
其余文件夹内的文件为细分样式类别,包括字体、hack、变量等等,以上两个文件按需从这些类别中引入文件即可
组件样式位置随组件位于同级目录下
Step 7 客户端路由、 socket、 service-worker
路由: ./src/client/route/index.js
...
const route = () => {
return (
<Route path="/" component={App}>
<IndexRoute component={Home} />
<Redirect from="home" to="/" />
<Route path="about" component={About} />
<Route path="demo" component={Demo}>
<Route path=":demoId" component={DemoItem} />
</Route>
</Route>
);
};
export default route;
定义了“/”为根路由映射到App组件
定义了默认首页路由映射到Home组件
重定向了“home”路径映射到根路由
定义了about、demo页面路由及demo详情页路由
socket包装: ./src/client/socket/index.js
...
let ioInstance = null;
const socket = {};
const emit = (event, data) => {
log({
event,
data,
type: 'emit'
});
ioInstance.emit(event, data);
};
const on = (event, cb) => {
ioInstance.on(event, data => {
log({
event,
data,
type: 'on'
});
cb(data);
});
};
socket.init = () => {
ioInstance = io();
socket.io = ioInstance;
onInit();
socket.init = () => {};
};
socket.emit = emit;
socket.on = on;
export default socket;
类似服务端,对原生socket实例包装以便log
service-worker: ./src/client/service-worker/index.js
...
const sw = {};
sw.init = () => {
if (isPwa) {
offline.install();
}
};
export default sw;
PWA模式下初始化一下offline-plugin/runtime库
Step 8 客户端redux、 rxjs、 reselect
redux/store: ./src/client/store/index.js
...
const composeEnhancers = isClient ?
(window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose) :
compose;
const epicMiddleware = createEpicMiddleware(epic);
const configureStore = isDev ?
composeEnhancers(applyMiddleware(
epicMiddleware,
ReduxLogger()
))(createStore) :
applyMiddleware(
epicMiddleware
)(createStore);
export default configureStore;
store配置文件,这里可以加入中间件epic,开发环境还可以加入logger, devtools等
redux/action: ./src/client/action/index.js
export const TEST_REQUEST = 'TEST_REQUEST';
export const TEST_RESPONSE = 'TEST_RESPONSE';
export const TEST_CANCEL = 'TEST_CANCEL';
export const TEST_ERROR = 'TEST_ERROR';
export function testRequest() {
return {
type: TEST_REQUEST
};
}
export function testResponse() {
return {
type: TEST_RESPONSE
};
}
export function testCancel() {
return {
type: TEST_CANCEL
};
}
export function testError() {
return {
type: TEST_ERROR
};
}
这些都是测试action
redux/reducer: ./src/client/reducer/index.js
...
intlList.map(item => {
addLocaleData(intlData[item]);
return item;
});
const reducer = combineReducers({
intl: intlReducer,
socket,
test
});
export default reducer;
这里包装一下所有的reducer
redux/reducer/test: ./src/client/reducer/test.js
import { TEST_REQUEST, TEST_RESPONSE, TEST_CANCEL, TEST_ERROR } from '../action';
export default function (state = {
count: 0,
fetching: false
}, action) {
switch (action.type) {
case TEST_REQUEST: {
return {
count: state.count,
fetching: true
};
}
case TEST_RESPONSE: {
return {
count: state.count + 1,
fetching: false
};
}
case TEST_CANCEL: {
return {
count: state.count,
fetching: false
};
}
case TEST_ERROR: {
return {
count: state.count,
fetching: false
};
}
default:
return state;
}
}
测试reducer响应相应action的逻辑
rxjs/epic: ./src/client/epic/index.js
import { combineEpics } from 'redux-observable';
import test from './test';
const epic = combineEpics(
test
);
export default epic;
这里包装一下所有的epic
rxjs/epic/test: ./src/client/epic/test.js
import { Observable } from 'rxjs/Observable';
import { TEST_REQUEST, TEST_CANCEL, testResponse } from '../action';
import socket from '../socket';
export default function (action$) {
return action$
.ofType(TEST_REQUEST)
.switchMap(() => {
socket.emit('data', {
test: 'io'
});
return Observable
.fromEvent(socket.io, 'data')
.filter(data => typeof data.test !== 'undefined')
.delay(800)
.mapTo(testResponse())
.takeUntil(action$.ofType(TEST_CANCEL));
});
}
测试epic响应及触发相应action的逻辑:
响应TEST_REQUEST
触发socket对象的emit方法
监听socket.io对象的data方法
过滤数据
延迟800ms
触发testResponse
除非在延迟期间有TEST_CANCEL被触发