乐趣区

关于前端:React-SSR-原理梳理

背景

本文从 React + Redux + React-Router + Express 搭建的 SSR 框架具体讲一下 Next.js 的同构和 getServerSideProps 是如何实现的

什么是 SSR

CSR 是 Client Side Render 简称;页面上的内容是咱们加载的 js 文件渲染进去的,js 文件运行在浏览器下面,服务端只返回一个 html 模板。

服务端渲染(Server-Side Rendering),页面上的内容是通过服务端渲染生成的,服务端间接返回拼接好的 html,浏览器间接显示服务端返回的 html 就能够了。

Next.js

Next.js 是一个最罕用的 React SSR 框架:包含动态及服务器端交融渲染、反对 TypeScript、智能化打包、路由预取等性能无需任何配置。

SSG:在 build 时生成动态 html,实用于内容不会产生扭转且对所有用户展现内容都一样的页面。

function Content({detail}) {
    return <div>
        {detail}
    </div>
  }

  // This function gets called at build time
  export async function getStaticProps() {
    // Call an external API endpoint to get posts
    const res = await fetch('.../content')
    const result = await res.json();
    // console.log(result);

    // By returning {props: { posts} }, the Content component
    // will receive `posts` as a prop at build time
    return {
      props: {detail: result.result,},
    }
  }

  export default Content

SSR:在运行时生成 html,实用于动态数据,会比拟耗费服务器资源。

// This gets called on every request
  export async function getServerSideProps() {
    // Call an external API endpoint to get posts
    const res = await fetch('.../get-list')
    const result = await res.json();
    // console.log(result);

    // By returning {props: { posts} }, the List component
    // will receive `posts` as a prop at build time
    return {
      props: {posts: result.result},
    }
  }

  function List({posts}) {
    return <div>
      {posts.map(post => <div key={post.id}>{post.name}</div>)}
    </div>
  }

  export default List

理论业务场景中应用较多的还是 SSR,也就是通过 getServerSideProps 来实时获取服务端数据。上面就从 React + Redux + React-Router + Express 搭建的 SSR 框架具体讲一下 SSR 的同构和 getServerSideProps 是如何实现的。

版本号 react:16.4.1 express:4.16.3

同构及其实现原理

所谓同构,艰深的讲,就是一套 React 代码在服务器上运行一遍,达到浏览器又运行一遍。服务端渲染实现页面构造,客户端渲染绑定事件。

同构的代码实现

代码参考:https://github.com/Lie8466/re…

// src/index.js
import express from 'express';
import React from 'react';
// IMP: 须要应用 react-dom/server. 虚构 DOM 为 react 实现客户端和服务端渲染提供了很大的便利性
import {renderToString} from 'react-dom/server';
import Home from './features/Home';

const app = express();
const content = renderToString(<Home />);
const port = 3000

console.log(content);

app.use(express.static('public'));

app.get('/', function (req, res) {
    res.end(`
        <html>
            <head>
                <title>ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
            </body>
        </html>
  `);
});

// Home.js
const Home = () => {
    return (<div>
        This is Home
        <button onClick={() => alert('clicked')}>click</button>
        </div>);
}

export default Home;

页面 html 返回

此时 button 并没有点击事件。如何让 button 有点击事件?借助 hydrate 办法。

https://zh-hans.reactjs.org/d…
与 render() 雷同,但它用于在 ReactDOMServer 渲染的容器中对 HTML 的内容进行 hydrate 操作。React 会尝试在已有标记上绑定事件监听器

// src/client.js
import React from 'react';
import ReactDom from 'react-dom';

import Home from '../features/Home';

// https://reactjs.org/docs/react-dom.html#render 认为元素曾经在服务端渲染过,会做一些减少事件的操作
ReactDom.hydrate(<Home />, document.getElementById('root'))

// src/index.js

app.get('/', function (req, res) {
    res.end(`
        <html>
            <head>
                <title>ssr</title>
            </head>
            <body>
                <div id="root">${content}</div>
                // 新增上面这行
                <script src="./index.js"></script>
            </body>
        </html>
  `);
});

hydrate 是如何实现的

一、监听全局事件

在客户端运行 hydrate 时,首先会对立减少对所有反对事件的监听(与 render 相似)。

值得注意的是,与 render 不同,这里监听的是 div#root 元素的所有事件,而 render 办法监听的是 document 元素。

React 合成事件:
如何监听?监听的什么元素?(应用到的依据 registrationNameDependencies 对应关系才会去监听,且应用一个 set 防止反复监听。监听了 document 元素)
如何模仿捕捉和冒泡?(找到元素的 path 链,按不同程序顺次取出对应的事件)

二、为元素增加事件

其次后面的工作与 render 是统一的,客户端会将 React Element 组装为 Fiber Node 的树。相似下图,其中

  • child — 指向第一个 child
  • sibling — 指向下一个兄弟节点
  • return — 指向父节点

在对这个树进行遍历的时候,有几个特地重要的 function

  • performUnitOfWork
  • beginWork
  • completeUnitOfWork
  • completeWork

hydrate 与 render 对这个树进行遍历的逻辑是一样的,区别是 render 不须要思考旧节点,将新节点渲染到页面上即可,而运行 hydrate 时页面上曾经有渲染的元素,须要思考页面元素是否须要保留、批改或者删除。hydrate 采纳的形式是:从 #root 节点开始,在遍历 fiber 树过程中按 fiberNode 节点的遍历程序顺次获取到 newFiberNode,并且别离找到页面曾经渲染出元素的 firstChild 节点或 nextSibling 节点作为 oldFiberNode 节点。

最初在 completeWork 中对 domElement 和 FiberNode 进行比对,进行属性和节点的更新(个别状况下就是属性的更新,非凡状况例如客户端渲染后果与服务端不统一的状况下须要更新节点)。



如图所示,会给对应的 FiberNode 减少上 onClick 属性,元素在被点击时会触发对应 onClick 的执行。

React 事件是通过事件代理实现的。以点击事件为例,在页面有点击事件产生时,会依据 event.target 对应的 FiberNode 顺次往上遍历(取父节点即 return),取出对应 FiberNode 的 onClick 点击事件放到数组中后,顺次执行。

小结

  1. 什么是同构?所谓同构,就是一套 React 代码在服务器上运行一遍,达到浏览器又运行一遍。服务端渲染实现页面构造,客户端渲染绑定事件。
  2. 服务端执行流程:在服务端应用 react-dom/server 下的 renderToString 将 React 组件转化为 string,拼接在 html 中进行返回。此时 html 中不蕴含元素对应的事件。打包时把 react-dom 下的 hydrate 的逻辑打包到 js 中,拼接在 html 中作为 script 标签返回,提供给客户端运行应用
  3. 浏览器执行流程:申请 html,渲染 html 返回的页面内容并下载 js 文件,此时页面显示元素但不可交互,运行 js 中的 ReactDom.hydrate 给页面元素绑定事件,页面可交互。

数据的注水与脱水及其实现原理

参考代码 https://github.com/Lie8466/re…

SSR 模式下,服务端只执行 3 个生命周期函数:

  • constructor
  • getDerivedStateFromProps
  • render
    其余任何生命周期在服务端都不执行,因而上面代码中的 componentDidMount 在服务端并不会执行。如下代码所示的 componentDidMount 在服务端并不会执行
import React, {Component} from 'react';
import Header from '../../components/Header';
import {connect} from 'react-redux';
import {getHomeList} from './store/actions';

class Home extends Component {getList() {const { list} = this.props;
        return list.map(item => <div key={item.id}>{item.name}</div>)
    }

    render() {
        return (
            <div>
                <Header />
                {this.getList()}
                <button onClick={()=>{alert('click1')}}>
                    click
                </button>
            </div>
        )
    }

    componentDidMount() {this.props.getHomeList();
    }
}

const mapStateToProps = state => ({list: state.home.newsList});

const mapDispatchToProps = dispatch => ({getHomeList() {dispatch(getHomeList());
    }
})

export default connect(mapStateToProps, mapDispatchToProps)(Home);

如果想要在服务端返回数据后返回应该怎么做呢?

实现原理

首先给页面挂载 loadData 办法(类比 getServerSideProps),loadData 会在申请实现后更新 store 数据,从而使 Home 渲染进去有数据的内容。

// Home.js
Home.loadData = (store) => {
    // 这个函数,负责在服务器端渲染之前,把这个路由须要的数据提前加载好
    return store.dispatch(getHomeList())
}

// Routes.js
import Home from './containers/Home';
import Login from './containers/Login';

export default [
 { 
    path: '/',
    component: Home,
    exact: true,
    // 新增上面一行
    loadData: Home.loadData,
    key: 'home'
  }, { 
    path: '/login',
    component: Login,
    exact: true,
    key: 'login'
  }
];

服务端接管到申请后,依据 request 申请的页面地址,获取匹配到的页面,将其 loadData 放在一个数组中。

import {matchRoutes} from 'react-router-config'
// ...

// server/index.js
app.get('*', function (req, res) {const store = getStore();
    // 依据路由的门路,来往 store 外面加数据
    const matchedRoutes = matchRoutes(routes, req.path);
    // 让 matchRoutes 外面所有的组件,对应的 loadData 办法执行一次
    const promises = [];
    matchedRoutes.forEach(item => {if (item.route.loadData) {promises.push(item.route.loadData(store))
        }
    })
    Promise.all(promises).then(() => {res.send(render(store, routes, req));
    })
});

待所有的 loadData 执行结束后再返回 html,并且将数据注入到 html 中

import {renderToString} from 'react-dom/server';
//...

export const render = (store, routes, req) => {
   const content = renderToString((<Provider store={store}>
                <StaticRouter location={req.path} context={{}}>
                    <div>
                        {routes.map(route => (<Route {...route}/>
                    ))}
                </div>
                </StaticRouter>
            </Provider>
        ));
    
        return `
            <html>
                <head>
                    <title>ssr</title>
                </head>
                <body>
                    <div id="root">${content}</div>
          <script>
                        window.context = {state: ${JSON.stringify(store.getState())}
                        }
                    </script>
                    <script src='/index.js'></script>
                </body>
            </html>
      `;

}

客户端依据 window.context.state 初始化 store 数据

// store/index.js
export const getClientStore = () => {
    const defaultState = window.context.state;
    return createStore(reducer, defaultState, applyMiddleware(thunk));
}

小结

  • 数据的注水与脱水:注水指的是服务端申请数据后,将数据传递给客户端,脱水就是客户端应用数据的过程。
  • 服务端执行流程:服务端依据 request 申请中的页面 path,获取匹配到的路由对象,将路由对象上挂载的静态方法 loadData 放在 promise 中对立执行后,并将申请数据注入到 html 的 <script> 标签中,返回给客户端。
  • 客户端执行流程:申请 html,收到带有数据的 html,渲染带有服务端数据的页面。运行 <script>window.context…</script>,下载并运行 index.js 文件,js 代码中会间接取用 window.context 初始化 initialState,从而保障客户端首次计算出的页面与服务端返回的 html 完全一致。

总结

本篇文章以 React + Redux + React-Router + Express 搭建的 SSR 框架具体解说了 SSR 的同构和 getServerSideProps 是如何实现的。其实 Next.js 的实现原理与这个是相似的,本篇文章是一个简化的实现能够帮忙了解。小的区别是 Next.js 在注水和脱水的过程中,不是应用的 redux 的 state 来初始化数据,而是应用的 <Page {…pageProps} /> 来初始化的页面。




服务端应用 react-dom/server 的 renderToString,客户端应用 ReactDom.hydrate 实现代码同构;服务端通过 matchRoutes 找到匹配路由的 loadData,申请后再返回 html,且会往 html 中注入数据,客户端渲染 html 执行 js 拿到数据后初始化页面。

参考文章

  • https://indepth.dev/posts/100…
  • React server components 介绍 https://zhuanlan.zhihu.com/p/…
退出移动版