此文已由做者张硕受权网易云社区发布。javascript
欢迎访问网易云社区,了解更多网易技术产品运营经验。css
网易美学主站在最初开发时,由于各类历史缘由,引入了例如JQuery,Bootstrop,Angular, React等框架,代码结构比较混乱,给后续的开发和维护带来了很大的不便。因此对它进行了重构。下面,我会从如下三个方面对主站的重构方案进行介绍:html
咱们为何进行重构?前端
如何使用React进行同构java
同构过程当中遇到的问题以及解决方案node
早期的主站使用Express做为Node层路由的同时,使用了相似于Jinja的Nunjucks做为javascript 模板引擎,进行HTML文件的渲染,也就是说,咱们的网站是一个多页应用,Nunjucks渲染知足了SEO的需求。以后出于封装和组件的管理引入了Reactjs,对于一个页面的开发,每每须要两步:react
使用Nujucks书写template以及对应的css样式;webpack
页面加载后,对某些须要组件化的DOM 节点进行React组件的替换ios
对于每一个页面,在引入的js文件中,对DOM节点进行替换,以CommentBox组件为例:git
((window, document) => { ReactDOM.render( <CommentBox limit={20} type={3} id={id} initalLogin={initalLogin}/>, document.querySelector("#comments") ) })(window, document)
对于页面的开发,形成了额外的工做量。
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,一些应用的作法是弹窗登录后,强制刷新页面,使各个组件刷新状态。可是强制刷新页面会影响用户体验,这里,产品的需求是这样的:
点击点赞按钮,弹出登陆框,进行登录后,进行主动点赞,其余与登陆状态有关的组件,检测到登陆状态改变后,进行数据获取和显示刷新。
因为咱们的组件,是根据id直接挂在在DOM节点上的,这些组件之间没有嵌套关系,不能经过props去传递状态。只能经过基于发布-订阅者模式的全局事件处理。在每一个组件进行登陆状态的trigger和监听。组件间须要共享的状态不只仅只有isLogin,这样能够预见,咱们须要在React组件的事件上,绑定大量的全局监听和触发事件。这样增长了组件之间的耦合,不利于代码的维护。
出于上述的考虑,咱们选择了使用React进行先后端同构。
同构(Isomorphic)并非一个新鲜的概念。一些团队已经基于他们的业务实现了同构直出(参考[1])。
这里再简单介绍一下,根据本身理解,同构能够当作,只须要维护一份代码,client side(Browser端)和server side(Nodejs端)均可以共用。
这样,在获取数据后,server side能够返回已经渲染好的html文件,知足SEO须要的同时,相比纯client rendering,也减小了响应时间,对于用户来讲,就是减小了白屏这样很差的体验。
以后,前端拿到后端返回的HTML和数据,使用同一份代码,再次进行渲染。
(图片来自网络)
有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版本,进行同构。
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也会出以下图的不一致警告:
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放置在页面组件的静态方法上: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 须要在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完成,并不须要访问服务器获取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() }) }
以前提到,因此对于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;
在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,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 先后端同构的项目一些参考。
经过封装第三方库,咱们抹平了在先后端发送请求书写上的不一样。对于某一个页面来讲,无论是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,
在serverEntry.js中,将请求头信息从req.headers中读出, 而后放在Redux store中;
在每一个组件的static方法fetchData(store)中,在使用store中读出,将其做为Fetch方法的一个参数;
对封装了axios库的Fetch方法进行改写,读取请求头信息,而且发送。
这样作的好处是,每一个server side的请求,都有对应的请求头,而且与浏览器发送的请求头一致。可是,也带来了一些不便:每一个页面的fetchData中,都要重复从store中获取请求头-->将请求头放在Fetch方法参数这个操做,处理上有一些冗余。这里,若是你们有什么更好的解决方法,欢迎联系我~
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" }}
咱们的页面上就会弹出
问了避免这样的问题,咱们须要对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采用了动态路由,不须要额外的配置,很容易地可以实现这个功能,咱们在后续的文章中会进行讲解。
对于一个较为复杂的应用,在使用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紧密相关;
对于这三类组件。按照容器组件和展现组件相分离的思想,咱们使用高阶函数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:
You need synchronous module resolution on the server so you can get those bundles in the initial render.
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.)
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?
相关文章:
【推荐】 如何避免程序员和产品经理打架?“微服务”或将成终极解决方案