当前位置: 首页 > 工具软件 > React Static > 使用案例 >

使用React的static方法实现同构以及同构的常见问题

仲高超
2023-12-01

代码地址请在github查看,假设有新内容。我会定时更新。也欢迎您star,issue,共同进步

1.我们服务端渲染数据从何而来

1.1 怎样写出同构的组件

服务端生成HTML结构有时候并不完好。有时候不借助js是不行的。比方当我们的组件须要轮询服务器的数据接口,实现数据与服务器同步的时候就显得非常重要。事实上这个获取数据的过程能够是数据库获取,也能够是从其它的反向代理服务器来获取。

对于client来说,我们能够通过ajax请求来完毕,仅仅要将ajax请求放到componentDidMount方法中来完毕就能够。

而之所以放在该方法中有两个原因,第一个是为了保证此时DOM已经挂载到页面中;还有一个原因是在该方法中调用setState会导致组件又一次渲染(详细你能够查看这个文章)。而对于服务端来说,
一方面它要做的事情便是:去数据库或者反向代理服务器拉取数据 -> 依据数据生成HTML -> 吐给client。这是一个固定的过程,拉取数据和生成HTML过程是不可打乱顺序的。不存在先把内容吐给client,再拉取数据这种异步过程。所以,componentDidMount在服务器渲染组件的时候,就不适用了(由于render方法已经调用,可是componentDidMount还没有运行,所以渲染得到的是没有数据的组件。原因在于生命周期方法componentDidMount在render之后才会调用)。

还有一方面,componentDidMount这种方法,在服务端确实永远都不会运行!因此我们要採用和client渲染全然不一致的方法来解决渲染之前数据不存在问题。

关于服务端渲染和client渲染的差别你能够查看Node直出理论与实践总结

var React = require('react');
var DOM = React.DOM;
var table = DOM.table, tr = DOM.tr, td = DOM.td;
var Data = require('./data');
module.exports = React.createClass({
    statics: {
        //获取数据在实际生产环境中是个异步过程,所以我们的代码也须要是异步的
        fetchData: function (callback) {
            Data.fetch().then(function (datas) {
                callback.call(null, datas);
            });
        }
    },
    render: function () {
        return table({
                children: this.props.datas.map(function (data) {
                    return tr(null,
                        td(null, data.name),
                        td(null, data.age),
                        td(null, data.gender)
                    );
                })
            });
    },
    componentDidMount: function () {
        setInterval(function () {
            // 组件内部调用statics方法时,使用this.constructor.xxx
            // client在componentDidMount中获取数据,并调用setState改动状态要求
            // 组件又一次渲染
            this.constructor.fetchData(function (datas) {
                this.setProps({
                    datas: datas
                });
            });
        }, 3000);
    }
});

当中服务器端的处理逻辑render-server.js例如以下:

var React = require('react');
var ReactDOMServer = require('react-dom/server');
// table类
var Table = require('./Table');
// table实例
var table = React.createFactory(Table);
module.exports = function (callback) {
    //在client调用Data.fetch时,是发起ajax请求。而在服务端调用Data.fetch时,
    //有可能是通过UDP协议从其它数据服务器获取数据、查询数据库等实现
    Table.fetchData(function (datas) {
        var html = ReactDOMServer.renderToString(table({datas: datas}));
        callback.call(null, html);
    });
};

以下是服务器的逻辑server.js:

var makeTable = require('./render-server');
var http = require('http');
//注冊中间件
http.createServer(function (req, res) {
    if (req.url === '/') {
        res.writeHead(200, {'Content-Type': 'text/html'});
        //先訪问数据库或者反代理服务器来获取到数据,并注冊回调,将含有数据的html结构返回给client,此处仅仅是渲染一个组件。否则须要renderProps.components.forEach来遍历全部的组件获取数据
        //http://www.toutiao.com/i6284121573897011714/
        makeTable(function (table) {
            var html = '<!doctype html>\n\
                      <html>\
                        <head>\
                            <title>react server render</title>\
                        </head>\
                        <body>' +
                            table +
                            //这里是client的代码。实现每隔一定事件更新数据,至于怎样加入以下的script标签内容,能够參考这里https://github.com/liangklfangl/react-universal-bucket
                            '<script src="pack.js"></script>\
                        </body>\
                      </html>';
            res.end(html);
        });
    } else {
        res.statusCode = 404;
        res.end();
    }
}).listen(1337, "127.0.0.1");
console.log('Server running at http://127.0.0.1:1337/');

注意:由于我们的react服务端渲染仅仅是一次性的。不会随着调用setState而又一次reRender,所以我们须要在返回给client的html中加入client的代码,真正的每隔一定时间更新组件的逻辑是client通过ajax来完毕的。

1.2 怎样避免服务端渲染后client再次渲染

服务端生成的data-react-checksum是干嘛使的?我们想一想。就算服务端没有初始化HTML数据,仅仅依靠client的React也全然能够实现渲染我们的组件,那服务端生成了HTML数据。会不会在clientReact运行的时候被又一次渲染呢?我们服务端辛辛苦苦生成的东西,被client无情地覆盖了?当然不会!

React在服务端渲染的时候,会为组件生成相应的校验和(在redux的情况下事实上应该是一个组件树,为整个组件树生成校验和,由于这整个组件树就是我们首页要显示的内容)(checksum)。这样clientReact在处理同一个组件的时候,会复用服务端已生成的初始DOM,增量更新(也就是说当client和服务端的checksum不一致的情况下才会进行dom diff,进行增量更新),这就是data-react-checksum的作用。能够通过以下的几句话来总结下:

假设data-react-checksum同样则不又一次render。省略创建DOM和挂载DOM的过程,接着触发 componentDidMount 等事件来处理服务端上的未尽事宜(事件绑定等),从而加快了交互时间;不同一时候,组件在client上被又一次挂载 render。

ReactDOMServer.renderToString 和 ReactDOMServer.renderToStaticMarkup 的差别在这个时候就非常好解释了。前者会为组件生成checksum,而后者不会。后者仅仅生成HTML结构数据。所以,仅仅有你不想在client-服务端同一时候操作同一个组件的时候,方可使用renderToStaticMarkup。注意:上面使用了statics块,该写法仅仅在createClass中可用。你能够使用以下的写法:

//组件内的写法
class Component extends React.Component {
    static propTypes = {
    ...
    }
    static someMethod(){
    }
}

在组件外面你能够依照例如以下写法:

class Component extends React.Component {
   ....
}
Component.propTypes = {...}
Component.someMethod = function(){....}

详细你能够查看这里

关于服务端渲染经常会出现以下的warning,大多数情况下是由于在返回 HTML 的时候没有将服务端上的数据一同返回,或者是返回的数据格式不正确导致

Warning: React attempted to reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generatted on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Insted, figure out why the markup being generated is different on the client and server

2.怎样区分client与服务端代码

2.1 加入client代码到服务端渲染的html字符串

通过这个样例我们知道,将webpack-isomorphic-tools这个插件加入到webpack的plugin中:

module.exports = {
    entry:{
        'main': [
          'webpack-hot-middleware/client?path=http://' + host + ':' + port + '/__webpack_hmr',
        // "bootstrap-webpack!./src/theme/bootstrap.config.js",
        "bootstrap-loader",
        //确保安装bootstrap3,bootstrap4不支持less
          './src/client.js'
        ]
    },
   output: {
      path: assetsPath,
      filename: '[name]-[hash].js',
      chunkFilename: '[name]-[chunkhash].js',
      publicPath: 'http://' + host + ':' + port + '/dist/'
      //表示要訪问我们client打包好的资源必须在前面加上的前缀。也就是虚拟路径
    },
    plugins:[
        new webpack.DefinePlugin({
          __CLIENT__: true,
          __SERVER__: false,
          __DEVELOPMENT__: true,
          __DEVTOOLS__: true //,
        }),
     webpackIsomorphicToolsPlugin.development()
     //在webpack的development模式下一定更要调用它支持asset hold reloading!
     //https://github.com/liangklfang/webpack-isomorphic-tools
    ]
}

此时我们client.js会被打包到相应的文件路径下。然后在我们的模版中,仅仅要将这个打包好的script文件加入到html返回给client就能够了。以下是遍历我们的webpack-assets.json来获取到我们全部的产生的资源,然后加入到html模板中返回的逻辑:

export default class Html extends Component {
  static propTypes = {
    assets: PropTypes.object,
    component: PropTypes.node,
    store: PropTypes.object
  };
  render() {
    const {assets, component, store} = this.props;
    const content = component ? renderToString(component) : '';
    //假设有组件component传递过来,那么我们直接调用renderToString
    const head = Helmet.rewind();
    return (
      <html lang="en-us">
        <head>
          {head.base.toComponent()}
          {head.title.toComponent()}
          {head.meta.toComponent()}
          {head.link.toComponent()}
          {head.script.toComponent()}
          <link rel="shortcut icon" href="/favicon.ico" />
         <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css"/>
        <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Work+Sans:400,500"/>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/violet/0.0.1/violet.min.css"/>
          <meta name="viewport" content="width=device-width, initial-scale=1" />
          {/* styles (will be present only in production with webpack extract text plugin)
             styles属性仅仅有在生产模式下才会存在,此时通过link来加入。

便于缓存 */} {Object.keys(assets.styles).map((style, key) => <link href={assets.styles[style]} key={key} media="screen, projection" rel="stylesheet" type="text/css" charSet="UTF-8"/> )} {/* assets.styles假设开发模式下,那么肯定是空,那么我们直接採用内联的方式来插入就可以。

此时我们的css没有单独抽取出来,也就是没有ExtractTextWebpackPlugin。打包到js中从而内联进来 */} {/* (will be present only in development mode) */} {/* outputs a <style/> tag with all bootstrap styles + App.scss + it could be CurrentPage.scss. */} {/* can smoothen the initial style flash (flicker) on page load in development mode. */} {/* ideally one could also include here the style for the current page (Home.scss, About.scss, etc) */} </head> <body> <div id="content" dangerouslySetInnerHTML={{__html: content}}/> {/*将组件renderToString后放在id为content的div内部*/} <script dangerouslySetInnerHTML={{__html: `window.__data=${serialize(store.getState())};`}} charSet="UTF-8"/> {/*将store.getState序列化后放在window.__data上,让client代码能够拿到*/} <script src={assets.javascript.main} charSet="UTF-8"/> {/*将我们的main.js,来自于client打包并放在特定文件夹下的资源放在页面中, 这就成了client自己的js资源了 */} </body> </html> ); } }

所以说以下的div#content中是服务端渲染后得到的html字符串,并被原样返回给client。

这种话。对于服务端的任务就完毕了

 <div id="content" dangerouslySetInnerHTML={{__html: content}}/>

而我们的以下的script标签的内容就是我们的client代码打包后的结果:

   <script src={assets.javascript.main} charSet="UTF-8"/>

此时client和服务端的逻辑都已经完毕了,client能够继续接收用户操作而发送ajax请求更新组件状态。

2.2 怎样使得服务端和client发起请求的逻辑通用

一个好的使用方法在于使用isomorphic-fetch

2.3 immutable数据在同构中的注意事项

首先在服务端返回的时候必须将store.getState得到的结果序列化,并且此时假设store返回的某一个部分state是immutbale的,那么client要又一次通过这部分state数据来创建新的immutable对象(如以下的样例中我们的recipeGrid和connect是immutable的):

  <script dangerouslySetInnerHTML={{__html: `window.__data=${serialize(store.getState())};`}} charSet="UTF-8"/>

对于client来说,我们必须将从服务端注入到HTML上的state数据转成 immutable对象,并将该对象作为initialState来创建store:

  const data = window.__data;
  //当中data是服务端返回的store.getState的值。也就是store的当前状态
  if (data) {
     data.recipeGrid = Immutable.fromJS(data.recipeGrid);
     //这里必须设置,否则报错说:paginator.equals is not a function
      data.connect = Immutable.fromJS(data.connect);
     //能够使用https://github.com/liangklfang/redux-immutablejs
  }
  const store = finalCreateStore(reducer, data);
2.4 服务端server不支持ES6的兼容

假设你想在服务端使用import等ES6的语法的话。你能够採用以下的方式。首先在项目的根文件夹下配置.babelrc文件,内容例如以下:

{
  "presets": ["react", "es2015", "stage-0"],
  "plugins": [
    "transform-runtime",
    "add-module-exports",
    "transform-decorators-legacy",
    "transform-react-display-name"
  ]
}

然后配置一个单独的文件server.babel.js:

const fs = require("fs");
const babelrc = fs.readFileSync("./.babelrc");
let config ;
try{
    config = JSON.parse(babelrc);
}catch(err){
    console.error("你的.babelrc文件有误,请细致检查");
    console.error(err);
}
//你能够指定ignore配置来忽略某些文件。
//https://github.com/babel/babel/tree/master/packages/babel-register
require("babel-register")(config);
//require("babel-register")会导致以后全部的.es6,.es,.js,.jsx的文件都会被babel处理

最后我们加入我们的server.js,内容例如以下(直接node server.js,而真正的逻辑放在../src/server中):

#!/usr/bin/env node
require('../server.babel'); // babel registration (runtime transpilation for node)
var path = require('path');
var rootDir = path.resolve(__dirname, '..');
global.__CLIENT__ = false;
global.__SERVER__ = true;
global.__DISABLE_SSR__ = false;  
// <----- DISABLES SERVER SIDE RENDERING FOR ERROR DEBUGGING
global.__DEVELOPMENT__ = process.env.NODE_ENV !== 'production';
if (__DEVELOPMENT__) {
//服务端代码热载入
  if (!require('piping')({
      hook: true,
      ignore: /(\/\.|~$|\.json|\.scss$)/i
    })) {
    return;
  }
}
// https://github.com/halt-hammerzeit/webpack-isomorphic-tools
var WebpackIsomorphicTools = require('webpack-isomorphic-tools');
global.webpackIsomorphicTools = new WebpackIsomorphicTools(require('../webpack/webpack-isomorphic-tools-config'))
  .development(__DEVELOPMENT__)
  .server(rootDir, function() {
  //rootDir必须和webpack的context一致。调用这种方法服务器就能够直接require不论什么资源了
  //这个路径用于获取webpack-assets.json文件,这个是webpack输出的
  // webpack-isomorphic-tools is all set now.
  // here goes all your web application code:
  // (it must reside in a separate *.js file 
  //  in order for the whole thing to work)
  //  此时webpack-isomorphic-tools已经注冊好了,这里能够写你的web应用的代码。并且这些代码必须在一个独立的文件里
    require('../src/server');
  });

经过上面的babel-register的处理,此时你的../src/server.js中能够使用随意ES6的代码了。

2.5 服务端代码单独使用webpack打包

假设对于服务端的代码要单独打包,那么必须进行以下的设置:

target: "node"

你能够參考这里

2.6 服务端渲染之忽略css/less/scss文件

在2.4中我们使用了babel-register帮助服务端识别特殊的js语法,但对less/css文件无能为力,庆幸的是。在普通情况下,服务端渲染不须要样式文件的參与,css文件仅仅要引入到HTML文件里就可以,因此。能够通过配置项。忽略全部 css/less 文件:

require("babel-register")({
  //默认情况ignore是node_modules表示node_modules下的全部文件的require不会进行处理
  //这里明白指定css/less不经过babel处理
  ignore: /(.css|.less)$/, });

详细内容你能够查看babel-register文档

你能够传递其指定的全部的其它选项。包含plugins和presets。

可是有一点要注意。就是距离我们源文件的近期一个.babelrc始终会起作用,同一时候其优先级也要比你在此配置的选项优先级高。

此时我们忽略了样式文件的解析并不会导致client对组件再次渲染,由于我们的checksum和详细的css/less/scss文件无关,仅仅是和组件render的结果有关。

2.7 使用webpack-isomorphic-tools识别css/less/scss文件

通过 babel-register 能够使用babel解决jsx语法问题,对 css/less 仅仅能进行忽略,但在使用了CSS Modules 的情况下。服务端必须能够解析 less文件,才干得到转换后的类名,否者服务端渲染出的HTML结构和打包生成的client css 文件里,类名无法相应。

其原因在于:我们在服务端使用了CSS Module的情况下必须採用例如以下的方式来完毕类名设置:

const React = require("react");
const styles = require("./index.less");
class Test extends React.Component{
 render(){
     return (
        //假设不是css module。那么可能是这种情况:className="banner"
           <div className={styles.banner}>This is banner<\/div>
        )
   }
}

假设服务端无法解析css/less肯定无法得到终于的class的名称(经过css module处理后的className)。从而导致client和服务端渲染得到的组件的checksum不一致(由于class的值不一致)。而对于2.6提到的忽略less/css文件的情况,尽管服务端没有解析该类名,可是我们的组件上已经通过class属性值指定了同样的字符串,因此checksum是全然一致的。

为了解决问题,须要一个额外的工具,即webpack-isomorphic-tools,帮助识别less文件。通过这个工具,我们会将服务器端组件引入的less/css/scss文件进行特别的处理,如以下是Widget组件引入的scss文件被打包成的内容并写入到webpack-assets.json中:

 "./src/containers/Widgets/Widgets.scss": {
      "widgets": "widgets___3TrPB",
      "refreshBtn": "refreshBtn___18-3v",
      "idCol": "idCol___3gf_9",
      "colorCol": "colorCol___2bs_U",
      "sprocketsCol": "sprocketsCol___3nkz0",
      "ownerCol": "ownerCol___fwn86",
      "buttonCol": "buttonCol___1feoO",
      "saving": "saving___7FVQZ",
      "_style": ".widgets___3TrPB .refreshBtn___18-3v {\n  margin-left: 20px;\n}\n\n.widgets___3TrPB .idCol___3gf_9 {\n  width: 5%;\n}\n\n.widgets___3TrPB .colorCol___2bs_U {\n  width: 20%;\n}\n\n.widgets___3TrPB .sprocketsCol___3nkz0 {\n  width: 20%;\n  text-align: right;\n}\n\n.widgets___3TrPB .sprocketsCol___3nkz0 input {\n  text-align: right;\n}\n\n.widgets___3TrPB .ownerCol___fwn86 {\n  width: 30%;\n}\n\n.widgets___3TrPB .buttonCol___1feoO {\n  width: 25%;\n}\n\n.widgets___3TrPB .buttonCol___1feoO .btn {\n  margin: 0 5px;\n}\n\n.widgets___3TrPB tr.saving___7FVQZ {\n  opacity: 0.8;\n}\n\n.widgets___3TrPB tr.saving___7FVQZ .btn[disabled] {\n  opacity: 1;\n}\n"
    }

此时,在服务端你能够使用上面说的styles.banner这种方式来设置className,而不用操心使用babel-register仅仅能忽略css/less/scss文件而无法使用css module特性,从而导致checksum不一致!

详细你能够查看这里

2.8 前后端路由不同的处理

单页应用一个常见的问题在于:全部的代码都会在页面初始化的时候一起载入,即使这部分的代码是不须要的,这经常会产生长时间的白屏。webpack支持将你的代码进行切分,从而切割成为不同的chunk而按需载入。

当我们在特定路由的时候载入该路由须要的代码逻辑,哪些当前页面不须要的逻辑按需载入

对于server-rendering来说,我们服务端不会採用按需载入的方式,而我们的client经常会使用System.import或者require.ensure来实现按需载入

比方以下的样例:

module.exports = {
    path: 'complex',
    getChildRoutes(partialNextState, cb) {
       //假设是服务端渲染。我们将Page1,Page2和其它全部的组件打包到一起,假设是client,那么我们会将Page1,Page2的逻辑单独打包到一个chunk中从而按需载入
        if (ONSERVER) {
            cb(null, [
                require('./routes/Page1'),
                require('./routes/Page2')
            ])
        } else {
            require.ensure([], (require) => {
                cb(null, [
                    require('./routes/Page1'),
                    require('./routes/Page2')
                ])
            })
        }
    },
    //IndexRoute表示默认载入的子组件,
    getIndexRoute(partialNextState, cb) {
        if (ONSERVER) {
            const { path, getComponent } = require('./routes/Page1');
            cb(null, { getComponent });
        } else {
            require.ensure([], (require) => {
                // separate out the path part, otherwise warning raised
                // 获取下一个模块的path和getComponent,由于他是採用module.export直接导出的
                // 我们直接将getComponent传递给callback函数
                const { path, getComponent } = require('./routes/Page1');
                cb(null, { getComponent });
            })
        }
    },
    getComponent(nextState, cb) {
        if (ONSERVER) {
            cb(null, require('./components/Complex.jsx'));
        } else {
            require.ensure([], (require) => {
                cb(null, require('./components/Complex.jsx'))
            })
        }
    }
}

这个样例的路由相应于/complex,假设是服务端渲染,那么我们会将Page1,Page2代码和其它的组件代码打包到一起。

假设是client渲染,那么我们会将Page1,Page2单独打包成为一个chunk。当用户訪问”/complex”的时候才会载入这个chunk。那么为什么服务端渲染要将Page1,Page2一起渲染呢?事实上你要弄清楚,对于服务端渲染来说。将Page1,Page2一起渲染事实上是获取到了该两个子页面的DOM返回给client(形成当前页面的子页面的两个Tab页面)。而client单独载入chunk事实上仅仅是为了让这部分DOM能够响应用户的点击,滚动等事件而已。注意:服务端渲染和我们的req.url有关,如以下的样例:

 match({ history, routes: getRoutes(store), location: req.originalUrl }, (error, redirectLocation, renderProps) => {
    if (redirectLocation) {
      res.redirect(redirectLocation.pathname + redirectLocation.search);
      //重定向要加入pathname+search
    } else if (error) {
      console.error('ROUTER ERROR:', pretty.render(error));
      res.status(500);
      hydrateOnClient();
      //发送500告诉client请求失败。同一时候不让缓存了
    } else if (renderProps) {
      loadOnServer({...renderProps, store, helpers: {client}}).then(() => {
        const component = (
          <Provider store={store} key="provider">
            <ReduxAsyncConnect {...renderProps} />
          <\/Provider>
        );
        res.status(200);
        global.navigator = {userAgent: req.headers['user-agent']};
        res.send('<!doctype html>\n' +
          renderToString(<Html assets={webpackIsomorphicTools.assets()} component={component} store={store}\/>));
      });
    } else {
      res.status(404).send('Not found');
    }
  });
});

我们的服务端依据req.url获取到renderProps,从而将一个组件树渲染成为html字符串返回给client。所以我们服务端不会按需渲染。终于导致的结果仅仅是多渲染了该path下的一部分DOM而已,并且这样有一个优点就是高速响应用户操作(还是要client进行注冊事件等)而不用client又一次render该部分DOM。

而从client来说,我此时仅仅须要载入该path下相应的chunk就能够了,而不是将整个应用的chunk一起载入,从而按需载入,速度更快,更加合理。

服务端match路由须要注意的问题:尽量前置重定向(写到路由的 onEnter 里)。

除非须要拉取数据进行推断,不要在路由确定之后再重定向。

由于在拿到路由配置之后就要依据相应的页面去拉数据了。这之后再重定向就比較浪费。

如以下的样例:

  const requireLogin = (nextState, replace, cb) => {
    function checkAuth() {
      const { auth: { user }} = store.getState();
      if (!user) {
        // oops, not logged in, so can't be here!
        replace('/');
      }
      cb();
    }
    if (!isAuthLoaded(store.getState())) {
      store.dispatch(loadAuth()).then(checkAuth);
    } else {
      checkAuth();
    }
  };

以下使用onEnter钩子函数的路由配置:

    <Route onEnter={requireLogin}>
       //假设没有登录,那么以下的路由组件根本不会实例化,更不用说拉取数据了
        <Route path="chat" component={Chat}/>
        <Route path="loginSuccess" component={LoginSuccess}/>
  <\/Route>

參考资料:

React同构思想

React数据获取为什么一定要在componentDidMount里面调用?

ReactJS 生命周期、数据流与事件

React statics with ES6 classes

React同构直出优化总结

腾讯新闻React同构直出优化实践

Node直出理论与实践总结

React+Redux 同构应用开发

ReactJS 服务端同构实践「QQ音乐web团队」

代码拆分 - 使用 require.ensure

性能优化三部曲之三——Node直出让你的网页秒开 #6

 类似资料: