Constructing-SPA-application-based-on-React-isomorphism

本文将讲解如何构建基于React同构的SPA应用,涉及到的技术或知识点有如下若干(按字母排序,部分可选):

babel fetch http2 intl isomorphic koa material-ui
postcss pwa react redux rxjs socket.io webpack …

项目地址

https://github.com/devlee/react-isomorphic

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被触发

Step 9 附录

PWA模式下Lighthouse满分截图

PWA模式下模拟器截图1

PWA模式下模拟器截图2

PWA模式下模拟器截图3

PWA模式下模拟器截图4

坚持原创技术分享,您的支持将鼓励我继续创作!