React server rendering —— 网易美学主站同构实录

此文已由作者张硕授权网易云社区发布。

欢迎访问网易云社区,了解更多网易技术产品运营经验。


网易美学主站在最初开发时,因为各种历史原因,引入了例如JQuery,Bootstrop,Angular, React等框架,代码结构比较混乱,给后续的开发和维护带来了很大的不便。所以对它进行了重构。下面,我会从以下三个方面对主站的重构方案进行介绍:

  • 我们为什么进行重构?

  • 如何使用React进行同构

  • 同构过程中遇到的问题以及解决方案

我们为什么要进行重构?

Before

对于同一个组件,需要分别使用模板和React组件实现两次

早期的主站使用Express作为Node层路由的同时,使用了类似于Jinja的Nunjucks作为javascript 模板引擎,进行HTML文件的渲染,也就是说,我们的网站是一个多页应用,Nunjucks渲染满足了SEO的需求。之后出于封装和组件的管理引入了Reactjs,对于一个页面的开发,往往需要两步:

  1. 使用Nujucks书写template以及对应的css样式;

  2. 页面加载后,对某些需要组件化的DOM 节点进行React组件的替换

images

对于每个页面,在引入的js文件中,对DOM节点进行替换,以CommentBox组件为例:

((window, document) => {
  ReactDOM.render(
    <CommentBox limit={20} type={3} id={id} initalLogin={initalLogin}/>,      document.querySelector("#comments")
  )
})(window, document)

对于页面的开发,造成了额外的工作量。

一些React组件初始化的数据,获取不易

React组件初始化时,需要把一些数据作为props传递进去。例如isLogin属性,对于一个有登录功能的网站,是否处于登录状态,影响了组件的展示。但是isLogin这个状态如何拿到呢,我们只能在Nunjucks模板中进行书写:

// repo.njkvar initalData = (function(){  var data = {
    id: "{{id}}",
    initalLogin: {{"true" if currentUser.userId else "false"}}
  }  return function(){    return data
  }
})()

通过initialData这个全局变量获取React组件初始化所需要的props。

不同组件之间的状态互相影响

我们的应用中,有一些状态需要在不同组件间共享。比如登陆状态isLogin,一些应用的做法是弹窗登陆后,强制刷新页面,使各个组件刷新状态。但是强制刷新页面会影响用户体验,这里,产品的需求是这样的:

images

点击点赞按钮,弹出登录框,进行登陆后,进行主动点赞,其他与登录状态有关的组件,检测到登录状态改变后,进行数据获取和显示刷新。

由于我们的组件,是根据id直接挂在在DOM节点上的,这些组件之间没有嵌套关系,不能通过props去传递状态。只能通过基于发布-订阅者模式的全局事件处理。在每个组件进行登录状态的trigger和监听。组件间需要共享的状态不仅仅只有isLogin,这样可以预见,我们需要在React组件的事件上,绑定大量的全局监听和触发事件。这样增加了组件之间的耦合,不利于代码的维护。

出于上述的考虑,我们选择了使用React进行前后端同构。

什么是同构

同构(Isomorphic)并不是一个新鲜的概念。一些团队已经基于他们的业务实现了同构直出(参考[1])。

这里再简单介绍一下,根据自己理解,同构可以看成,只需要维护一份代码,client side(Browser端)和server side(Nodejs端)都可以共用。

这样,在获取数据后,server side可以返回已经渲染好的html文件,满足SEO需要的同时,相比纯client rendering,也减少了响应时间,对于用户来说,就是减少了白屏这样不好的体验。

之后,前端拿到后端返回的HTML和数据,使用同一份代码,再次进行渲染。

image (图片来自网络)

同构方案的选择

有Next.js这样的服务端渲染框架,提供了脚手架,生成同构网站。我们没有直接采用Next.js,主要是出于以下几方面的考虑:

  • 对于已有项目来说,使用Next.js重写成本过高;

  • 自己书写重构方案,更容易定制;

  • Next.js的replaceState不支持IE9;

如何进行同构

这里,先列出我们使用的工具以及版本:

  • node层框架 —— express(当然也可以用koa)

  • react 15

  • react-router v3 —— react路由的不二选择

  • react-redux —— 思前想后最后引入的Redux

  • axios —— nodejs和browser通用的http框架,基于Promise

之后,会在后续的《React server rendering —— 网易美学主站同构实录(二)》中,讨论如何引入react16和react-router v4版本,进行同构。

Server Render和Client Render

React提供了在server side进行渲染的方法: renderToString 方法可以将React 元素渲染成HTML字符串,并且返回这个字符串。

这样,以Express为例,对于一个请求,server side可以这样返回:

// app.jsvar handleRender= require('./serverEntry')
app.get('*', handleRender)
// serverEntry.jsimport ReactDOMServer from 'react-dom/server'import App from './App'const handleRender = (req, res) => {  const reactString = ReactDOMServer.renderToString(<App />)
  res.send('<html><div id="app">'+ reactString + '</div></html>')
})module.exports = handleRender

对于client rendering,可以仍然使用ReactDOM提供的render方法。(React 16提供了hydrate方法,用来合并渲染server side渲染过的HTML)

// client.jsximport ReactDOM from 'react-dom'import App from './App'ReactDOM.render(<App />, docoment.getElementById('app'))

在React 16之前,由renderToString生成的HTML的各个DOM会带有额外属性:data-react-id,此外,第一个DOM会有data-checksum属性。在client side 进行渲染时,会检查HTML DOM是否存在相同的data-react-checksum,如果一致,则client side可以直接使用server side生成的DOM树。如果不一致,则client side会重新渲染整个HTML,DevTools也会出如下图的不一致警告:

image

React 16中,去掉了data-react-id和data-checksum属性,它采用了不同的算法来检测client side和server side是否一致。如果不一致的话,会修正这些不一致,而不是在client side 重新生成整个HTML。

不得不说的路由

抛弃了Nunjucks后,重构后的主站是一个单页应用,从index.html渲染所需要的页面。路由的引入是不可缺少的,这里使用了react-router, 对于4.x以前的版本,通过配置嵌套的, 很容易实现一个单页应用的路由

// routes.jsconst routes = {  path: '/',  component: require('./App').default,  childRoutes: [
    { path: 'about', component: About },
    { path: 'login', component: Login }
  ]
}

有了路由配置之后,client side可以写成以下:

// client.jsximport routes from './routes'import { browserHistory } from 'react-router' // 在生产环境中使用browserHistory而不是hashHistoryReactDOM.render(<Router routes={{ ...routes }} history={browserHistory} />, docoment.getElementById('app'))

而server side,在获取请求后,react-router提供了:

  • match方法,可以对req.url进行匹配;

  • RouterContext 用来同步渲染route 组件。

    // serverEntry.jsimport routes from './routes'const handleRender = (req, res) => {
    match({ routes, location: req.url), (err, reactLocation, renderProps) => {  if(error) {
        res.status(500).send(error.message)
      } else if (renderProps) {
        res.status(200).send(ReactDOMServer.renderToString(<RouterContext {...renderProps} />))
      } else {
        res.status(404).send('Not found')
      }
    })
    })

数据获取

前后端通用的数据获取

对于一个不需要同构的React 应用来说,我们通常选择把获取数据这一步放在componentDidMount方法中,在获取数据后,使用getState触发render。但是对于server rendering,并不会执行到componentDidMount这个方法。所以,我们需要在调用renderToString前,进行数据的获取,并将获取后的数据放置在组件可以访问到的store中,供组件渲染。

server side进行数据获取的方法很多,比如说通过代理转发请求。此外,已经有各种第三方库,提供了在server side和client side 发送请求的通用方法。isomorphic-fetch和axios都可以满足我们的需求。通过封装第三方库,我们抹平了在前后端发送请求书写上的不同。对于某一个页面来说,不管是server side还是client side,可以通过同一个fetchData方法获取初始数据。

fetchData放在哪里?

下面的问题,就是这个fetchData方法放在哪儿。可以选择一个文件,集中管理所有页面的fetchData方法。一些参考资料中,会选择把fetchData放置在页面组件的静态方法上:ES6中,提供了class中static方法,我们都知道class只是ES6提供的以一个语法糖,并没有改变JS基于原型的本质。class中定义的static方法,并没有放置在原型链上,可以直接通过类名进行调用。

我们的项目也选择把fetchData放置在页面static 方法中,主要是考虑到fetchData和业务逻辑放置在一起,维护起来更加方便和直观。 如此,About,用伪代码可以这样书写:

// About.jsximport Fetch from './fetch' // 将axios进行封装后的获取数据方法import Store from './store' //一个全局的Storeconst URL = '/api/about'class About extends React.Component {
  constructor(props) {    super(props)
  }  static fetchData() {    return Fetch(URL).then(data => {
      Store.set('about', data)
    })
  }  render() {    const data = Store.get('about')    // 后续的数据处理
    ...
  }
}

static方法fetchData并不是在组件About实例的生命周期里面,所以对于fetchData中获取的方法,我们需要先构建一个全局的Store单例,用来set获取的数据。在About组件的初始化render中,则可以使用Store.get方法获取这些数据进行渲染。

server side调用fetchData

之前提到了,server side 需要在renderToString之前,就进行数据的获取。对于页面组件上的静态方法fetchData,如何进行调用呢?

// serverEntry.js
match({ routes, location: req.url), (err, reactLocation, renderProps) => {  const { params, components, location } = renderProps  const taskList = []
  components.forEach((component) => {
    component && component.fetchData && taskList.push(component.fetchData())
  })
  Promise.all(taskList).then((data) => {    // 调用renderToString
  })
})

react-router 提供的match方法的回调中,renderProps.components即为对应页面的组件。可以直接调用这些组件的fetchData方法。client side 在获取到server side响应后,要进行渲染,也需要两部分:使用React框架的App代码;从后台服务器获取的请求数据。代码部分,可以打包成js文件引入到返回的html中,而请求数据,可以转化为字符串写入全局对象window上:

// serverEntry.js
Promise.all(taskList).then(() => {  const filepath = path.resolve(process.cwd(), 'dist/pages/index.html')
  fs.readfile(filepath, 'utf8', (err, file) => {    const data = Store.get()    const footString = `<script>(function(){window.__INITIAL_STATE__=${JSON.stringify(data)}})()</script>`    const reactString = ReactDOMServer.renderToString(<RouterContext {...renderProps} />)    const result = reactString.replace(/<div id="app"><\/div>/, `<div id="app">${reactString}</div>${footString}`)
    res.send(result)
  })
})

client side调用fetchData

对于单页应用来说,打开页面后,页面的跳转时在client side完成,并不需要访问服务器获取HTML。所以在进行页面跳转时,也需要进行fetchData,然后再挂载页面组件。react-router 3.x版本中,提供了一个onEnter的hook:onEnter(nextState, replace, callback?)。如果使用了第三个参数,则页面跳转会被block,直到调用callback。有了onEnter,我们可以这样进行client side数据获取:

// routes.jsconst onEnter = (nextState, replace, callback) => {  if (!__BROWSER__) return callback() // 服务端直接返回
  if (window.__INITIAL_STATE__ !== null) {
    window.__INITIAL_STATE__ = null
    return callback()
  }  const { routes } = nextState  const defaultDataHandler = () => Promise.resolve()  const matchedRoute = routes[routes.length - 1]  const fetchDataHandler = matchedRoute.component
    && matchedRoute.component.fetchData || defaultDataHandler  fetchDataHandler().then(data => {
    ... // 一些业务处理
    callback()
  }).catch(err => {
    ... // 错误处理
    callback()
  })
}

状态管理——Redux

之前提到,所以对于fetchData中获取的方法,我们需要先构建一个全局的Store单例,用来set获取的数据。在组件的初始化render中,则可以使用Store.get方法获取这些数据进行渲染。听起来很熟悉是不是,Redux中的Store可以完全满足我们的需求,而不用自己构建一个全局的Store单例。但是对于大部分工程来说,Redux并不是非用不可,Redux的引入在使数据流更加清晰的同时,也会使组件的结构更加复杂,增加开发的工作量,对于一个setState操作,需要

  • 定义一个actiontype

  • 定义一个action函数

  • 定义一个reducer函数

  • 触发action

"如果你不知道是否需要 Redux,那就是不需要它。"

但是出于以下的考虑,我们最后决定引入了Redux:

  • Redux提供了方便的通过初始state构建Store的方法,通过dispatch改变state,并可以通过getState获取状态;

  • React-Redux 提供Provider组件,将store放在上下文对象context中,子组件可以从context中拿到store,而不用经过层层props传递;

  • 我们的应用中,有一些组件的状态需要共享。比如isLogin状态,这个状态改变,会许多组件的状态

引入了Redux,在一次请求中,我们需要做

  • 创建一个Redux store实例;

  • 对于这个请求,fetchData,并在fetchData中dispatch一些action,获取到的数据存入store;

  • 从store中获取改变后的state;

  • 将state放在返回client的HTML字符串中,供client端初始化store;

image

在client side,可以对window.__INITIAL_STATE__进行解析,并将解析后的对象作为初始状态构建Store。

// client.jsximport configStore from './configStore'import { browserHistory } from 'react-router'const store = configStore(window.__INITIAL_STATE__)
ReactDOM.render(
  <Provider store={store}>
    <Router routes={{ ...routes }} history={browserHistory} />
  <Provider>,
  docoment.getElementById('app')
)

如果需要更详细的介绍,可以参考Reactjs github上对于使用Redux进行server rendering的内容(参考[2])。此外,可以使用第三方库react-router-redux,它提供了syncHistoryWithStore函数,可以将react-router的history与store互相同步。如果需要记录、重复用户行为,或者分析导航事件,则可以引入这个库。

工程化——webpack

建立开发环境和上线环境,实现模块的打包,前端常用的工具有很多: 例如webpack,gulp, grunt, browerify等。具体的打包方法就不在这里赘述。

与client rendering的单页应用不同的是, 也需要对server side进行打包。以webpack为例,就是需要进行两次打包,入口文件分别是client.jsx和serverEntry.js。对于serverEntry生成的文件bundle.server.js,需要在app.js中进行引入:

// app.jsvar handleRender= require('./dist/bundle.server')
app.get('*', handleRender)

遇到的问题以及解决方法

之前参考的资料中,已经有了比较完备的server rendering方案。但是具体的项目实践中,也遇到了一些问题,在解决这些问题的时候,积累了写经验,希望能给之后也有需要进行React 前后端同构的项目一些参考。

HTTP 请求头处理

通过封装第三方库,我们抹平了在前后端发送请求书写上的不同。对于某一个页面来说,不管是server side还是client side,可以通过同一个fetchData方法获取初始数据。fetchData是页面元素的一个static方法。 fetchData中,基于业务需求,可能不仅仅有一个获取数据的方法。比如/about请求,react-router路由匹配到了About组件, 在这个组件中,需要获取两部分数据:

  • /api/content : 获取改页面的展示内容;

  • /api/user: 获取当前用户登录信息;

    // About.jsximport Fetch from './fetch' // 将axios进行封装后的获取数据方法import Store from './store' //一个全局的Storeclass About extends React.Component {
    constructor(props) {  super(props)
    }static fetchData(store) {  const fetchContent = Fetch('/api/content')  const fetchUser = Fetch('/api/user')  return Promise.all([fetchContent, fetchUser]).then(datas => {
        ...
      })
    }
    render() {   // 后续的render操作
      ...
    }
    }

在 client side, 这样fetchData没有问题,因为浏览器发送的请求(/api/content, /api/user),有完备的请求头。在server side, 收到的/about请求,有完整的请求头,但是从Node层发出的/api/content, /api/user则缺少了对应的请求头信息,例如cookie, 这就导致了/api/user这个接口,是不能获取登陆信息的。此外,还缺少referer,origin, userAgent一些对服务端比较重要的请求头。

那怎么办呢?一个比较容易想到的办法是,在server side,将/about请求的请求头取出来,然后放到/api/content, /api/user这两个请求头上。

这里,我们是这样操作的,利用Redux,

  1. 在serverEntry.js中,将请求头信息从req.headers中读出, 然后放在Redux store中;

  2. 在每个组件的static方法fetchData(store)中,在使用store中读出,将其作为Fetch方法的一个参数;

  3. 对封装了axios库的Fetch方法进行改写,读取请求头信息,并且发送。

这样做的好处是,每个server side的请求,都有对应的请求头,并且与浏览器发送的请求头一致。但是,也带来了一些不便:每个页面的fetchData中,都要重复从store中获取请求头-->将请求头放在Fetch方法参数这个操作,处理上有一些冗余。这里,如果大家有什么更好的解决方法,欢迎联系我~

XSS风险

React 会将所有要显示到 DOM 的字符串转义,避免出现XSS的风险。

// serverEntry.js
const footString = `<script>(function(){window.__INITIAL_STATE__=${JSON.stringify(store.getState()}})()</script>`
// client.jsxconst initialState = window.__INITIAL_STATE__

上述的代码,大家应该已经察觉到问题了。对于store中的state,我们使用了JSON.stringify进行序列化, 它将一个Javascript value转化成一个JSON字符串,这样就出现了XSS的风险。试想,如果store.getState()是下列的结果:

{
  "user": {
    "id": "1",
    "comment": "<script>alert('XSS!')</script>",
    "avatar": "https://beauty.nosdn.127.net/beauty/img/1.png"
  }}

我们的页面上就会弹出 image

问了避免这样的问题,我们需要对state其中的特殊html标签进行转义。 Git上有许多第三方库可以帮助我们解决这个问题。例如serialize-javascript。它也是一个序列化的工具,提供了serialize API,可以自动地对HTML字符进行转义:

serialize({    haxorXSS: '</script>'});

执行结果为:

{"haxorXSS":"\\u003C\\u002Fscript\\u003E"}

在server side,我们将JSON.stringify替换为serialize即可:

// serverEntry.js
const footString = `<script>(function(){window.__INITIAL_STATE__=${serialize(store.getState()}})()</script>`

登录检测

在Redux store中,我们维护了一个isLogin状态,对于某些页面,只有在登录状态才可见,如果没有登录,直接在地址栏中输入对应的url,则会跳转至其他页面;如果在这些页面中点击退出登录,也会跳转至其他页面。

为了减少代码的复用,我们设了一个高阶组件CheckLoginEnhance, 它直接于Redux进行通信,监听isLogin的状态。在componentDidMount, componentWillReceiveProps这两个hook上,去检测isLogin状态,如果没有登录,则进行页面的跳转。

高阶组件的本质是生成组件的函数,使用起来也非常简单,只需要在需要登录检测的页面组件上,用@CheckLoginEnhance进行包裹即可。

我们这里的登陆检测,都是在client side进行的,如果能在server side进行检测,直接进行跳转。对于用户来说,体验更加友好。

为了实现这个需求,我们可以在serverEntry.js中获取isLogin,然后使用res.redirect进行跳转。此外react-router v4采用了动态路由,不需要额外的配置,很容易地能够实现这个功能,我们在后续的文章中会进行讲解。

页面哪些state要使用Redux进行管理?

对于一个较为复杂的应用,在使用Redux时,都需要进行Reducer的拆分,拆分后的每个Reducer函数独立负责该特定切片state的更新。Redux提供了combineReducer函数,将拆分后的Reducer函数合并成一个Reducer函数,最后使用这个Reducer进行store的创建。

我们项目中,对于每一个页面,拆分一个单独的Reducer,对应单独的state。对于一些公共的state,比如说用户信息,错误处理,导航信息,则从各个页面的state中抽离出来,统一处理。

与此同时,我们面临了一个问题,这个问题也是刚接触Redux进行项目开发时,经常会遇到的,在单个页面中,哪些组件要使用Redux进行管理state,哪些使用setState进行维护?

之前提到,引入Redux的原因,就是它提供了一个上下文都可以访问的store,存储的数据既可以用于server rendering也可以用于client rendering。所以对于server rendering所需要的初始化的数据,需要使用Redux进行管理。此外,那些与server rendering无关的状态呢?比如说,某个Button的显示和隐藏。如果由Redux进行管理,固然数据流向更加清晰,但是也可以预见我们需要维护巨大的reduce方法和复杂的state结构,但是如果不由Redux进行管理,则是否会出现React state和Redux共存,导致数据流混乱的问题。

对于Redux的store和React的state,Redux的作者是这样回答的:

Use React for ephemeral state that doesn't matter to the app globally and doesn't mutate in complex ways. For example, a toggle in some UI element, a form input state. Use Redux for state that matters globally or is mutated in complex ways. For example, cached users, or a post draft.

Sometimes you'll want to move from Redux state to React state (when storing something in Redux gets awkward) or the other way around (when more components need to have access to some state that used to be local).

The rule of thumb is: do whatever is less awkward.

对于应用中所使用的组件,可以简单分为三类:

  • 页面组件

  • 页面的子组件,处理展示逻辑

  • 一些公共组件(如LoginModal),这些组件的state和Redux维护的state紧密相关;

image

对于这三类组件。按照容器组件和展示组件相分离的思想,我们使用高阶函数connect将页面组件进行包裹,形成容器组件。容器组件监听Redux state,并且向Redux派发actions。对于从Redux中获取的state,通过props向子组件传递。而子组件,通过props获取数据外,自身可以维护与展示相关的state。

对于某些公共组件,当然也可以像普通的子组件一样,获取页面组件的props。但是这样一来,一则嵌套太深,二则与页面代码耦合性太高,不利于组件的复用,也违背了我们使用Redux管理状态的初衷。所以这里也允许这些组件通过connect生成容器组件,直接与Redux通信。

还没有解决的问题:

网易美学主站上线已经四个多月了。在这个过程中,我们一直在持续维护周边的构建,使整个网站架构更加完备和和合理。但是一直有一个问题没有得到解决,那就是Code-splitting,目前client side所有的代码都打成一个包,没有实现代码分隔和按需加载。在使用react-router同时进行代码分隔和server rendering时,遇到了一些问题。react-router是这样解释的:

We’ve tried and failed a couple of times. What we learned:

  1. You need synchronous module resolution on the server so you can get those bundles in the initial render.

  2. You need to load all the bundles in the client that were involved in the server render before rendering so that the client rendering is the same as the server render. (The trickiest part, I think its possible but this is where I gave up.)

  3. You need asynchronous resolution for the rest of the client app’s life. We determined that google was indexing our sites well enough for our needs without server rendering, so we dropped it in favor of code-splitting + service worker caching. Godspeed those who attempt the server-rendered, code-split apps.


延伸阅读:

[1] ReactJS 服务端同构实践【QQ音乐web团队】

[2] Server Rendering

[3] Question: How to choose between Redux's store and React's state?

[4] Redux-Server Rendering


相关文章:
【推荐】 分布式存储系统可靠性系列五:副本放置算法&CopySetReplication
【推荐】 git使用那些事儿
【推荐】 Android标题栏(2)

posted @ 2018-11-13 09:13  tianshidan1998  阅读(261)  评论(0编辑  收藏  举报