SSR

1,760 阅读9分钟

渲染方式及其特点

服务端渲染ssr

方式

  1. 前端请求一个地址 url

  2. 后端接收到这个请求,然后根据请求信息,从数据库或者其他地方获取相应的数据

  3. 使用模板引擎(如 java > jspphp > smarty)将这些数据渲染成 html

  4. html 文本返回给前端

优势

  1. 利于seo
  2. 首屏等待时间短

缺点

  1. 增加服务端压力
  2. 前后端耦合性高,不方便本地开发、本地模拟调试,也不方便自动化测试
  3. 项目难以管理和维护

客户端渲染

方式

  1. 前端请求一个地址 url
  2. 后端接收到这个请求,然后把相应的 html 文件直接返回给前端
  3. 前端解析 js 后,然后通过 ajax 向后台获取相应的数据
  4. 然后由 js 将这些数据渲染成页面

优势

  1. 减少服务端压力
  2. 前后端解耦,能更好的扩展、重构
  3. 更好的管理代码,更有效率的开发、调试、测试

缺点

  1. 首屏加载缓慢,因为要等 js 加载完毕后,才能进行渲染
  2. SEO 不友好,因为 html 中几乎没有可用的信息

预渲染

适用场景

预渲染适用于简单的静态页面,其目的是用来改善少数营销页面的SEO对于那些大量的动态路由匹配(比如大量的文章详情,大量的活动详情等)就不适合了。服务端渲染适用于复杂、较大型、与服务端交互频繁的功能型网站,比如电商网站。

方式

预渲染不像SSR那样即时编译 HTML,它只在构建时为了特定的路由生成特定的几个静态页面,等于我们可以通过 Webpack 插件将一些特定页面组件 build 时就编译为 html 文件,直接以静态资源的形式输出给搜索引擎。

优势

  1. 几乎可以获得服务端渲染的所有优点,没有缺点
  2. 加载应用程序的路由,将结果保存在一个静态的HTML文件中
  3. 无需更改代码或添加服务器端

缺点

  1. 增加预编译时间
  2. 仅适用于少数页面的SEO

React SSR

渲染原理

node server 接收客户端请求,得到当前的req url path,然后在已有的路由表内查找到对应的组件,拿到需要请求的数据,将数据作为 propscontext或者store 形式传入组件,然后基于 react 内置的服务端渲染api renderToString() or renderToNodeStream() 把组件渲染为 html字符串或者 stream 流, 在把最终的 html 进行输出前需要将数据注入到浏览器端(注水),server 输出(response)后浏览器端可以得到数据(脱水),浏览器开始进行渲染和节点对比,然后执行组件的componentDidMount 完成组件内事件绑定和一些交互,浏览器重用了服务端输出的 html 节点,整个流程结束。

同构

用React SSR同构的核心在于便于维护且尽可能地重用代码,并且在双端渲染时(react组件会被执行两次,由服务端提供的html与客户端通过全局变量拿到的数据形成的客户端渲染),保证页面不会出现闪屏的情况。

路由同构

要实现路由同构,双端需要将路由指向一个文件即可,此时服务端和客户端只用拿到那个文件,然后通过用户访问的url渲染对应的组件即可。

//客户端路由组件
import routes from './routes-config.js';

function App(){
  return (
    <Layout>
      <Switch>
        {
          routes.map((item,index)=>{
            return <Route path={item.path} key={index} exact={item.exact} render={item.component}></Route>
          })
        }
      </Switch>
    </Layout>
  );
}

export default App;
//服务端路由
import { matchRoutes } from "react-router-config";
import routes from './routes-config.js';
//node server 
http.createServer((req, res) => {
    
  const url = req.url;
  //简单容错,排除图片等资源文件的请求
  if(url.indexOf('.')>-1) { res.end(''); return false;}

  res.writeHead(200, {
      'Content-Type': 'text/html'
  });
  const data = fetch();

  //查找组件
  const branch =  matchRoutes(routes,url);
  
  //得到组件
  const Component = branch[0].route.component;

  //将组件渲染为 html 字符串
  const html = renderToString(<Component data={data}/>);

  res.end(html);
        
 }).listen(8080);

然而该方式只适用于提前知道所有路由的对应关系,若路由信息需要动态获取,实现按需加载组件,我们可以将路由的处理逻辑写在一个函数里供两端调用,而由于两端在获取路由时的数据源不同,客户端的路由信息可以通过全局变量获取,而服务端没有BOM,其数据获取时机更早,是通过props传递 ,此时在服务端调用路由文件时,获取函数体(路由处理逻辑),然后将所需数据传递进去即可。此过程的组件引入过程为异步,调用时需要处理一下。

数据同构

spa的写法是将数据获取写在组件内,大部分的实现方式是外层包个container,从而实现数据层解耦,而实际上ssr渲染时服务端已经有数据,并且ssr会将该数据通过全局变量的形式传递给csr作为页面初始状态的数据,因此在双端共用的数据预取逻辑中需要判断数据源是props还是全局变量,此时通常会用一个boolean类型的值isSSR来判断数据源。

此处的同构在于两个方面,一是数据处理逻辑,二是数据源处理。数据处理逻辑中包括数据的请求逻辑和适配逻辑等,而这里通常为了方便服务端调用采用static async实现这个预期函数(服务端无法生存实例),以下是数据处理逻辑

//组件
class IndexService extends React.Component{
    constructor(props){
        super(props);
    }

    //数据预取方法  静态 异步 方法
    static async  getInitialProps(opt) {
        const fetch1 =await fetch('/xxx.com/a');
        const fetch2 = await fetch('/xxx.com/b');
				const data = this.adapt(fetch1, fetch2)
        return {
            res: data
        }
    }

  	adapt(fetch1,fetch2) {
      return {...fetch1,...fetch2}
    }
  
    render(){
        return (
            <Index defaltData={this.props.defaltData} />
        )
    }
}

关于数据源的处理逻辑,我们需要在ssr中调用该组件的数据处理函数,然后将数据通过全局变量传递给csr作为初始值。

// ./lib/ServerRenderer.js
const path = require('path');
const fs = require('fs');

module.exports = async (mod) => {
    // 获取组件
    const Component = require(path.resolve(__dirname, '../', mod));
    // 获取页面模板
    const template = fs.readFileSync(path.resolve(__dirname, '../index.html'), 'utf8');
    // 数据预取
    await Component.getInitialProps();
    // 将组件渲染的字符串输出到页面模板
    return template.replace(
        '<!-- ssr -->', 
        com.render() +
            // 把后端获取的数据放到全局变量中供前端代码初始化
            '<script>window.__initial_props__ = ' + 
            JSON.stringify(com.props) +
            '</script>'
    )
    // 替换插入静态资源标签
    .replace('${modName}', mod);
}

API

hydrate()

ReactDOM.hydrate(element, container[, callback])

render() 相同,但它用于在 ReactDOMServer 渲染的容器中对 HTML 的内容进行 hydrate 操作。React 会尝试在已有标记上绑定事件监听器。

React 希望服务端与客户端渲染的内容完全一致。React 可以弥补文本内容的差异,但是你需要将不匹配的地方作为 bug 进行修复。在开发者模式下,React 会对 hydration 操作过程中的不匹配进行警告。但并不能保证在不匹配的情况下,修补属性的差异。由于性能的关系,这一点非常重要,因为大多是应用中不匹配的情况很少见,并且验证所有标记的成本非常昂贵。

如果单个元素的属性或者文本内容,在服务端和客户端之间有无法避免差异(比如:时间戳),则可以为元素添加 suppressHydrationWarning={true} 来消除警告。这种方式只在一级深度上有效,应只作为一种应急方案(escape hatch)。请不要过度使用!除非它是文本内容,否则 React 仍不会尝试修补差异,因此在未来的更新之前,仍会保持不一致。

如果你执意要在服务端与客户端渲染不同内容,你可以采用双重(two-pass)渲染。在客户端渲染不同内容的组件可以读取类似于 this.state.isClient 的 state 变量,你可以在 componentDidMount() 里将它设置为 true。这种方式在初始渲染过程中会与服务端渲染相同的内容,从而避免不匹配的情况出现,但在 hydration 操作之后,会同步进行额外的渲染操作。注意,因为进行了两次渲染,这种方式会使得组件渲染变慢,请小心使用。

记得保证弱网环境下的用户体验。JavaScript 代码的加载要比最初的 HTML 渲染晚的多。因此如果你只在客户端渲染不同的内容,其转换可能会不稳定。但是,如果执行顺利,那么在服务端负责渲染的 shell 会对渲染提供帮助,并且只显示客户端上额外的小组件。欲了解如何在不出现标记不匹配的情况下执行此操作,请参考上一段的解释。

renderToString()

ReactDOMServer.renderToString(element)

将 React 元素渲染为初始 HTML。React 将返回一个 HTML 字符串。你可以使用此方法在服务端生成 HTML,并在首次请求时将标记下发,以加快页面加载速度,并允许搜索引擎爬取你的页面以达到 SEO 优化的目的。

如果你在已有服务端渲染标记的节点上调用 ReactDOM.hydrate() 方法,React 将会保留该节点且只进行事件处理绑定,从而让你有一个非常高性能的首次加载体验。

该方法可以理解为将React作为模板生成HTML文件供ssr使用。

总结

应用的哪些部分可以同构

  1. 单页应用的路由可以被同构,这样访问任意单页应用的子页面都可以享受 SSR 带来的好处。
  2. 模板,前后端共用一个渲染引擎就可以做到前后端共用模板,这样类似于因同一份数据要用于前后端渲染而需要开发两套模板的日子就一去不复返了。
  3. 数据请求,开发支持同构的 httpClient,那么前后端请求数据的代码也可以同构了。需要注意的是服务端没有 cookie,因此会话相关的请求代码需要极其小心。
  4. 其他平台不相关的代码,例如 react 和 vue 都有的全局状态管理模块、数据处理过程和一些平台无关的纯函数。

哪些东西不能同构

  • 平台相关代码,如只能在浏览器端执行 DOM、BOM 相关的操作,只能在服务端执行文件读写,数据库操作等。