next.js CSR

5,225 阅读11分钟

吐槽next.js官方文档

Next.js足够强大和弹性伸缩,如果不需要对项目进行额外的配置的话,那么next.js几乎可以零配置就能满足我们大部分的前端业务场景:

  • CSR(client side render) + SPA (single page application)
  • CSR + MPA(mutiple page application)
  • SSG(static site generation)
  • SSR(server side render)
  • SSR + CSR

关于怎么触发SSG和SSR,官方文档讲得挺多的。简而言之:

  • 如果page组件文件(page目录下的js文件)除了default export一个react组件之外,还export一个异步函数getStaticProps,那么next.js就会对这个页面进行SSG;
  • 如果page组件文件(page目录下的js文件)除了default export一个react组件之外,还export一个异步函数getServerSideProps,那么next.js就会对这个页面进行SSR。

但是,关于怎么触发CSR,官方文档好像只字不提。只有在Migrating from Create React App这个小节里面有提到相关的东西,但是也没有明确说明这是一个CSR方案。所以,我不得不自己去整理一下。

研究动机

首先说明一下,为什么我需要CSR呢?业务场景是这样的:我有一个页面,这个页面主要是加载一个高清的截图,用于对某些东西的使用步骤进行说明。图片很高清,则意味着体积很大,加载起来很慢,并且是从上到下一卡一卡地加载。这种加载体验太差了,不能忍啊,所以,大家能想出来的一个优化方案肯定是弄一个图片占位符或者说明文字,用来告诉用户:“图片正在加载中”。比如这样:

image.png

看起来很简单,于是撸起袖子就是干(这个页面组件姑且命名为“Test”):

import { useState} from 'react'

export default function Test(){
    const [iShow, setIshow] = useState(false)
    
    return (
        <div className="demo-container">
           <div className="demo-placeholder">加载中...</div>
           <img 
                className="demo-img"
                src="https://i.stack.imgur.com/xckZy.jpg" 
                onLoad={()=>{setIshow(true)}} 
                style={{visibility:iShow ? 'visible' : 'hidden'}}
                alt=""
            />
            <style jsx>{`
                .demo-container {
                    width: 200px;
                    height: 200px;
                    margin: 100px auto;
                    background-color: #ddd;
                    position: relative;
                }
                .demo-placeholder {
                   position: absolute;
                   left: 0;
                   top: 0;
                   width: 100%;
                   height: 100%;
                   display: flex;
                   align-items: center;
                   justify-content: center;
                   color: #333;
                }
                .demo-img {
                    width: 100%;
                    height: 100%;
                    position: absolute;
                    left: 0;
                    top: 0;
                }
            `}</style>
        </div>
    )
}

浏览器fast-refresh之后,由于是本地开发,虽然速度很快,但是我们还是看到先是显示我们的placeholder,然后才是最终我们要显示的图片。感觉一切都没有问题,心里沾沾自喜中。 慢着,当通过chrome开发者工具将网速降低到100kb/s,并重新刷新页面的时候,神奇的事情发生了- 最终的图片并没有显示出来。看一看html代码,img标签上还是<img style="visibility: hidden;" />。感觉()=>{setIshow(true)}这个事件处理器没有被调用啊, 为什么呢?

看来,我们得好好地理解一下next.js的渲染过程。首先,当我们的page组件没有写任何的async函数的时候,那么next.js默认是走SSG流程的,在构建时就预渲染出一个完整的静态页面。当客户端发来请求的时候,那么next.js服务器就会返回这个静态页面,并且通过加载相应的js代码对当前的页面hydrate。我们对上面的代码的预期是,先是对<img />进行load事件监听,然后挂载<img />这个DOM元素。从而触发图片加载,最后加载完毕的时候,浏览器触发我们注册的事件处理器,将图片的visibility属性设置为“visible”。

上面是我们的预期流程,但是实际情况并不总是这样的。一个反例就是网速很慢的时候,因为我们的页面走的是SSG,则意味着,next.js返回给浏览器的是这样的html字符串:

<div id="__next">
    <div class="jsx-251539375 demo-container">
        <div class="jsx-251539375 demo-placeholder">加载中...</div>
        <img src="https://i.stack.imgur.com/xckZy.jpg" alt="" class="jsx-251539375 demo-img" style="visibility: hidden;">
     </div>
</div>

这样的话,那么img的加载和负责对页面进行hydrate的js chunks(“s”表示多个chunk)的加载几乎同时进行,到底谁先加载完成是不确定的。就我当前这个示例而言,当网速降下到100kb/s的时候,img的加载会比js chunks的加载要先完成。这就意味着【页面的hydrate】要比【img加载完成事件(load事件)的触发】要发生得晚。而这就是问题之所在了。img加载加载完成之后,会马上触发load事件。当浏览器准备去调用load事件的事件处理器的时候,因为此时页面的hydrate还没有完成(页面的hydrate的主要任务就是给react在server side渲染得到的元素进行相应的事件监听),所以浏览器找不到load事件相应的事件处理器去调用。这就造成了setIshow(true)这一行代码没有被执行,因此img的visibility属性始终是“hidden”。

经过上面的分析,我们的解决方法就显而易见了:我们要保证先对img元素进行load事件监听,再去挂载img元素。那有什么办法能够达成上面的这个保证呢?熟悉react渲染原理(尤其是react合成事件系统)的读者就会知道,只要把react组件放在client side去渲染,那么上面的那个先后顺序肯定是能保证的。到这里,就引出了我们这篇文章的主题了:“在next.js框架下,如何写client-side only的代码呢?”。

废话不说,大概有三种方法:

  • 对widow对象或者process.browser标志位进行检测
  • 在客户端进行two-pass rendering
  • 使用next.js提供的动态import + 禁用SSR

方法一:对widow对象或者process.browser标志位进行检测

就拿上面的示例来说,在这个方法下,我们可以这么写:

import { useState} from 'react'

export default function Test(){
    const [iShow, setIshow] = useState(false)

    return (
        <div className="demo-container">
            <div className="demo-placeholder">加载中...</div>
            {
                typeof window !== 'undefined' && <img 
                className="demo-img"
                src="https://i.stack.imgur.com/xckZy.jpg" alt=""
                onLoad={()=>{setIshow(true)}} 
                style={{visibility:iShow ? 'visible' : 'hidden'}}
            />
            }
            <style jsx>{`
                .demo-container {
                    width: 200px;
                    height: 200px;
                    margin: 100px auto;
                    background-color: #ddd;
                    position: relative;
                }
                .demo-placeholder {
                   position: absolute;
                   left: 0;
                   top: 0;
                   width: 100%;
                   height: 100%;
                   display: flex;
                   align-items: center;
                   justify-content: center;
                   color: #333;
                }
                .demo-img {
                    width: 100%;
                    height: 100%;
                    position: absolute;
                    left: 0;
                    top: 0;
                }
            `}</style>
        </div>
    )
}

这样一来,我们服务端生成的html字符串就不包含img元素了。img元素的挂载是通过react组件在浏览器端渲染来完成的。这个方法似乎完美。但是,当你打开chrome开发者控制台的时候,你会发现react-dom会给你报了一个Warning:

image.png

这个警告是在说,react-dom期待你的react应用在服务端渲染和客户端渲染的结果(html字符串)是完全一样的(如果不一样,react觉得这样会容易导致潜在的bug埋藏)。如今,你客户端渲染的结果比服务端渲染的结果多了个<img>元素,所以,它在警告我们开发者。但是,实际上,我们知道自己是故意这么做的。为了去掉这个烦人的警告,我们也是有手段的,那就是使用suppressHydrationWarning属性去镇压它:

import { useState} from 'react'

export default function Test(){
    const [iShow, setIshow] = useState(false)

    return (
        <div className="demo-container" suppressHydrationWarning>
            <div className="demo-placeholder">加载中...</div>
            {
                typeof window !== 'undefined' && <img 
                className="demo-img"
                src="https://i.stack.imgur.com/xckZy.jpg" alt=""
                onLoad={()=>{setIshow(true)}} 
                style={{visibility:iShow ? 'visible' : 'hidden'}}
            />
            }
            <style jsx>{`
                .demo-container {
                    width: 200px;
                    height: 200px;
                    margin: 100px auto;
                    background-color: #ddd;
                    position: relative;
                }
                .demo-placeholder {
                   position: absolute;
                   left: 0;
                   top: 0;
                   width: 100%;
                   height: 100%;
                   display: flex;
                   align-items: center;
                   justify-content: center;
                   color: #333;
                }
                .demo-img {
                    width: 100%;
                    height: 100%;
                    position: absolute;
                    left: 0;
                    top: 0;
                }
            `}</style>
        </div>
    )
}

在next.js的fast refresh自动刷新后,你再去开发者控制台看看,这个时候那个警告就不见了。其实这只是达到我们眼不见心不烦的目的而已,实质上的服务端和客户端渲染结果不一致仍然存在。

除了对window对象进行检测之外,我们还可以对process.browser这个标志位进行检测,从而来区分当前的代码是在哪一端去执行。因为process.browser是webpack注入到process对象上的,假如你不使用webpack进行打包的话,那么process.browser在客户端和服务端的falsy值都是false。所以,社区推荐我们废弃这种写法,转而采用对window对象进行检测的这种方法。

方法二:在客户端进行two-pass rendering

上面提到,方法一虽然是达到了我们写client-side only的代码,但是它造成了客户端和服务端渲染结果不一样的副作用。方法二则是可以解决这个问题。这也是react核心团队所推荐的技术方案。

所谓的“two-pass rendering”指的是在客户端进行两次的渲染。第一次渲染结果跟服务端的渲染结果是保持一致的,然后,第二次渲染的时候,去执行再我们client-side only的代码。其核心原理是,componenentDidMount或者useEffect这类跟DOM相关的生命周期函数不会在服务端执行,只会在客户端执行。 那么,落实到代码层面,我们应该怎么写呢?下面直接上代码:

import { useEffect, useState} from 'react'

export default function Test(){
    const [iShow, setIshow] = useState(false)
    const [isClientSide, setIsClientSide] = useState(false)
    useEffect(()=>{setIsClientSide(true)},[])
    
    return (
        <div className="demo-container">
            <div className="demo-placeholder">加载中...</div>
            {
                isClientSide && <img 
                className="demo-img"
                src="https://i.stack.imgur.com/xckZy.jpg" alt=""
                onLoad={()=>{setIshow(true)}} 
                style={{visibility:iShow ? 'visible' : 'hidden'}}
            />
            }
            <style jsx>{`
                .demo-container {
                    width: 200px;
                    height: 200px;
                    margin: 100px auto;
                    background-color: #ddd;
                    position: relative;
                }
                .demo-placeholder {
                   position: absolute;
                   left: 0;
                   top: 0;
                   width: 100%;
                   height: 100%;
                   display: flex;
                   align-items: center;
                   justify-content: center;
                   color: #333;
                }
                .demo-img {
                    width: 100%;
                    height: 100%;
                    position: absolute;
                    left: 0;
                    top: 0;
                }
            `}</style>
        </div>
    )
}

在服务端和客户端的第一次渲染过程中,组件状态iShowisClientSide的值都是false,并且上面也说了,服务端不会执行useEffect这个hook。所以,服务端和客户端第一次渲染的结果是一模一样的。这样一来,我们就遵循了react核心团队所推荐的范式。

方法二中,各种渲染发生的顺序是:先是服务端渲染,再是客户端的第一次渲染,最后是客户端的第二次渲染。因为我们在客户端的一次渲染完毕加入useEffect这个hook,所以,react就会调用setIsClientSide(true)语句,从而触发了客户端的第二次渲染。在客户端的第二次渲染中,组件状态isClientSide为true,这个时候,react才会去挂载<img />这个host component,才会接下来走一系列的流程:

  1. 创建img这个DOM元素
  2. 对它进行load事件监听
  3. 将它插入到document文档中。

通过这个流程,我们可以保证load事件的事件处理器会被正确调用,从而把图片的visibilitycss属性设置为visible

使用next.js提供的动态import + 禁用SSR

用这种方案来实现话,我们需要将client-side only的代码抽离到一个单独的文件中。然后在父组件中重新动态import进来,并将ssr标志位设置为false。这个单独的文件,我们姑且命名为Image.js:

import {  useState } from 'react'

export default function Image() {
    const [iShow, setIshow] = useState(false)
    return (
        <>
            <img
                className="demo-img"
                src="https://i.stack.imgur.com/xckZy.jpg" alt=""
                onClick={() => { alert('next.js') }}
                onLoad={()=> {setIshow(true)}}
                style={{visibility:iShow ? 'visible' : 'hidden'}}
            />
            <style jsx>{`
            .demo-img {
                width: 100%;
                height: 100%;
                position: absolute;
                left: 0;
                top: 0;
                }    
            `}</style>
        </>
    )
}

然后,在/page/test.js文件中,我们重新使用next.js提供的动态import把它整合进来:

import dynamic  from 'next/dynamic'
const DynamicImage =  dynamic(()=> import('../component/Image'),{ssr:false}) // 在SSR的时候不要渲染<DynamicImage />组件

export default function Test(){

    return (
        <div className="demo-container">
            <div className="demo-placeholder">加载中...</div>
            <DynamicImage />
            <style jsx>{`
                .demo-container {
                    width: 200px;
                    height: 200px;
                    margin: 100px auto;
                    background-color: #ddd;
                    position: relative;
                }
                .demo-placeholder {
                   position: absolute;
                   left: 0;
                   top: 0;
                   width: 100%;
                   height: 100%;
                   display: flex;
                   align-items: center;
                   justify-content: center;
                   color: #333;
                }
                .demo-img {
                    width: 100%;
                    height: 100%;
                    position: absolute;
                    left: 0;
                    top: 0;
                }
            `}</style>
        </div>
    )
}

上面那两种方法能够实现子组件和父组件的逻辑代码上混编,并且从代码的运行时来看,算是一种从组件层级去做客户端和服务端代码分离的方案。而第三种方法虽然也能够满足我们编写client-side only的代码,但是它是从文件层级去做分离,这也决定了它不能和引用它的父组件进行逻辑代码上的混编。

何为逻辑代码上的混编呢?下面,还是用同一个示例进行说明。在本示例中,父组件是我们自定义的<Test />,子组件是host component - <img />。无论是方法一还是方法二中,我们在父组件的实现中都有这么一行代码:

const [iShow, setIshow] = useState(false)

即通过useState方法,我们在父组件的内部开启了一个状态标志位iShow。如你所见,我们在子组件身上引用了这个标志位和对这个标志位进行设置的setIshow()方法:

<img 
    className="demo-img"
    src="https://i.stack.imgur.com/xckZy.jpg" alt=""
    onLoad={()=>{setIshow(true)}} 
    style={{visibility:iShow ? 'visible' : 'hidden'}}
/>

以上就是我所说的“父组件与子组件逻辑代码的混编”。在方法一和方法二中,它们之所以能混编,是因为,在客户端,它们的代码是打包到一块的。而方法三本质上就是一个code split,父组件和子组件的代码会分别打包到不同的chunk里面去。在我的这个示例中,父组件对应的chunk是test.js,子组件对应的chunk是component_Image_js.js。如下截图:

image.png

如果这个技术方案能实现下面的写法,那么我就觉得它的便捷程度可以跟方法一是相媲美的:

export default function Test(){
    const [iShow, setIshow] = useState(false)
    
    return (
        <div className="demo-container" suppressHydrationWarning>
            <div className="demo-placeholder">加载中...</div>
            {
                dynamic(()=> (
                   <img
                        className="demo-img"
                        src="https://i.stack.imgur.com/xckZy.jpg" alt=""
                        onClick={() => { alert('next.js') }}
                        onLoad={()=> {setIshow(true)}}
                        style={{visibility:iShow ? 'visible' : 'hidden'}}
                    />
                ),{ssr:false})
            }
            {/* 其他样式代码*/}
        </div>
   )
}

可惜的是,它不能。原因:

  • 动态import()只支持传入url字符串参数;
  • dynamic()调用不能放在react组件的render方法里面,只能是放在模块文件作用域的顶部。有官方文档为证:

    Note: .... dynamic() can't be used inside of React rendering as it needs to be marked in the top level of the module for preloading to work, similar to React.lazy.

因此我说方法三是可行的,但是不是最便捷的。

总结

本文主要探讨了如何在next.js应用中去写client-side only 代码的方案。主要有三种方案,分别是:

  1. 对widow对象或者process.browser标志位进行检测
  2. 在客户端进行two-pass rendering
  3. 使用next.js提供的动态import + 禁用SSR

这两种方案各有自己的优缺点。第一种方案从严谨的角度上来说,不够规范,但是胜在便捷,并不损坏页面在客户端的加载性能。第二种方案,写法是挺规范的,但是稍显繁琐,并在如果应用的组件层级过深或者其中的某个组件渲染性能十分低下的时候,那么两次渲染必然会损害页面的加载性能和体验。第三种方案本质就是禁用SSR + 客户端代码的code split。它的分离粒度要比上面的两种方法要大(文件级别),稍显笨拙。三种技术方案各有优缺点,在实际开发中,我们可以根据自己项目的实际情况来做出选择。

参考

  1. blog.hao.dev/render-clie…
  2. github.com/vercel/next…
  3. stackoverflow.com/questions/4…
  4. www.nextjs.cn/docs/advanc…