将博客重构为SSR渲染

前段时间写了一个简易的类React框架:NeZha,现在打算加上服务端渲染的功能,并准备重构整个博客的同构渲染。本文将整理SSR在框架的项目构建方面的原理,然后进行简单实现。

<!--more-->

本文参考

1. 篇头思考

如果想使用现代前端框架如ReactVue编写单页面应用,但又不想放弃首屏渲染速度、SEO等服务端渲染的优势,那我们就需要了解SSR。

我们先整理一下SSR的工作流程,大致如下所示

  • 浏览器请求某个url路径,服务端根据这个url找到需要渲染的页面组件
  • 初始化应用和数据,获取该页面组件对应的虚拟DOM树
  • 根据虚拟DOM的跨平台特性,将其编译成HTML文件,并作为响应返回给浏览器
  • 浏览器接收到HTML文本,此时用户可以看见完整的初始页面
  • 客户端需要注册生命周期函数、事件等,此后用户可以参与交互,后续更新渲染均由客户端接管

在这个过程,我们需要考虑下面几个问题

  1. 服务端的初始化工作,只是为了生成虚拟DOM树,然后编译出浏览器可以直接识别的HTML文件而已。建虚拟DOM树需要完成整个页面的初始化,我们需要保证在服务端执行这个初始化工作的时候不会带来相关的副作用,包括:携带定时器和网络请求之类的钩子函数、浏览器相关的API等。同样地对于浏览器而言,需要避免服务端相关的API
  2. 没必要在服务端渲染整个应用,只需要渲染请求url路径页面组件的HTML即可。因此,浏览器和服务端需要共同维护一套路由配置,这样才能在相同的url时渲染相同的页面组件
  3. 服务端渲染时一般会直接从数据库或RPC服务中直接获取数据;在浏览器往往是通过HTTP接口请求数据,我们必须考虑初始化时页面数据来源的问题
  4. 浏览器接收到HTML响应后,我们需要继续未完成的工作:注册钩子函数、注册事件等,有两种方案:重新初始化整个应用,或者“激活”(hydrate)服务端渲染HTML对应的DOM节点。出于浏览器性能考虑,最优的做法应该是尽可能地复用已经被服务端渲染的DOM节点。

可以看见,上述问题的核心思想就是同构,我们需要编写一套与平台无关的代码,保证他们能够同时运行在服务端和客户端;至于那些平台相关的代码,我们可以通过某种特定的打包机制来分别处理。基于以上分析,在实现SSR之前,有下面几个亟待解决的问题

  • 实现在服务端初始化应用,获取虚拟DOM树,然后将其编译成HTML字符串
  • 实现服务端和客户端在路由配置、数据源等代码通用

接下来我们会实现这些API,最终完成整个博客SSR重构。

2. 虚拟DOM

在虚拟DOM层我们需要实现两个问题

  • 将虚拟DOM树转换为HTML字符串
  • 在浏览器初始化应用时需要进行hydrate操作复用已经渲染的DOM树

2.1. VNode2HTML

在NeZha中,我实现了一个VNode2HTML方法,用于将VNode转换为HTML字符串。

function VNode2HTML(root: VNode): string {
    let {type, props, children} = root

    let sub = '' // 获取子节点渲染的html片段
    Array.isArray(children) && children.forEach(child => {
        sub += VNode2HTML(child)
    })

    let el = '' // 当前节点渲染的html片段
    if (isComponent(type)) {
        el += sub // 组件节点不渲染任何文本
    } else if (isTextNode(type)) {
        // @ts-ignore
        el += props.nodeValue // 纯文本节点则直接返回
    } else {
        let attrs = ''
        for (let key in props) {
            if (key === 'dangerouslyInnerHTML') {
                sub += props[key]
            } else {
                attrs += getAttr(key, props[key])
            }
        }
        el += `<${type}${attrs}>${sub}</${type}>` // 将子节点插入当前节点
    }

    return el

    function getAttr(prop, val) {
        if (isFilterProp(prop)) return ''
        // 渲染HTML,我们不需要 事件 等props,而是在hydrate阶段重新设置属性
        return isEventProp(prop) ? '' : ` ${prop}="${val}"`
    }
}

通过后序遍历,将子节点渲染的HTML标签插入父标签中,最终返回完整的HTML文件。

2.2. hydrate

初始化应用时我们已经有了虚拟DOM树和真实DOM树,在hydrate(某些地方翻译成脱水)阶段,就是将真实DOM树的DOM节点依次赋值给虚拟DOM树,这样就可以避免浪费浏览器在解析HTML时已经渲染好的DOM节点了。

这个过程需要同时遍历虚拟DOM树和真实DOM树,下面的代码演示了将真实DOM树一次更新到虚拟DOM树上的大致过程。

function clearContainer(dom) {
    Array.from(dom.children).forEach(child => {
        dom.removeChild(child)
    })
}
// 当vnode树和dom树无法完全匹配时,强制重新根据vnode渲染DOM节点
function forceHydrate(root, dom) {
    // 为了验证ssr,暂时将其处理成清空容器,由浏览器重新渲染
    clearContainer(dom)
    renderDOM(root, dom)
}

// 遍历虚拟DOM和真实DOM
function walk(node: VNode, dom: any): Boolean {
    if (!node && !dom) return true
    if (!node || !dom) return false

    while (isComponent(node.type)) {
        node = node.$child
    }
    // 处理文本节点
    if (isTextNode(node.type) && dom.nodeType === 3) {
        node.$el = dom
        return true
    }

    // 处理元素节点
    if (node.type !== dom.tagName.toLowerCase()) return false

    node.$el = dom // 复用已经渲染的DOM节点
    setAttributes(node, node.props) // 更新DOM节点属性,如注册事件等
    if (node.props.dangerouslyInnerHTML) return true // 如果子节点是通过dangerouslyInnerHTML设置的,则直接跳过后续工作

    let children = node.children
    let domList = dom.childNodes
    if (children.length !== domList.length) return false

    for (let i = 0; i < children.length; ++i) {
        if (!walk(children[i], domList[i])) {
            return false
        }
    }
    return true
}
// 尝试将已存在的DOM树脱水到vnode树上,更新vnode的$el
function hydrateDOM(root, dom) {
    diffSync(null, root)
    root.$parent = {
        $el: dom
    }
    // 与第一个真实DOM做比较,因此此处要求组件都是单节点结果
    let isSuccess = walk(root, dom.children[0])
    if (!isSuccess) forceHydrate(root, dom)
}

需要注意在某些特定情况下不标准的HTML导致浏览器渲染出的DOM树和原始虚拟DOM树不一致的情况(如tbody、th等),实际情况hydrate要比上面的代码更加复杂。

3. 同构

同构指的是在客户端和服务端运行同一套代码

  • 在服务端运行的代码会渲染初始的HTML文件
  • 在客户端运行的代码会执行hydrate工作,然后接管页面的交互和渲染

总体来说,在通过过程中,我们需要解决下面几个问题

3.1. 服务端

在整个项目中,我将服务端分为了两部分

  • 一个纯粹的数据服务器,提供数据查询接口,不参与SSR的任何工作
  • 一个是SSR服务器,主要处理服务端渲染相关逻辑

单独启动一个ssr服务器的好处是在服务端我们可以直接监听/*请求,这样就不用额外解析routes来告知服务器哪些是页面路由了。

app.get("/*", ()=>{
    // ... 
})

下面代码展示了SSR时服务端的大致工作流程,大致包括

  • 根据url找到需要渲染的页面组件
  • 根据组件的asyncData方法获取页面渲染需要的数据,并存放在store里面
  • 从应用根节点开始传入url,构建整个虚拟DOM树
  • 将虚拟DOM树转换为HTML字符串,服务端返回响应
// server.ts
app.get("/*", async (req, res) => {
    let url = req.url
    // step1 根据路由配置找到需要渲染的组件,并调用约定的asyncData方法获取组件初始化需要的数据
    let {component, path} = getMatchRouteConfig(url, routes)
    let store = createStore()
    let location = createLocation(url, path)

    let pageData = component.asyncData && await component.asyncData(store, location)
    // 处理单个页面的seo数据,启动传入的data为Home.asyncData方法获取到的返回值,方便根据页面内容动态处理tdk
    let seoData = component.serverSEO && await component.serverSEO(pageData)

    // step2 根据数据渲染完整的应用
    let vnode = (<App context={{store}} url={url}/>)
    let html = renderHTML(vnode)

    // step3 并将使用的数据埋入页面,返回给浏览器
    res.writeHead(200, {"Content-Type": "text/html"});
    let initData: any = store.getState()

    let tpl: string = getTemplate(html, initData, seoData)
    res.end(tpl);
})

上面的代码无法直接通过node运行,为了支持tsx,我们需要配置babel并引用babel/register,此处可以查看完整的.babelrc配置(在博客下一个大版本更新前应该都有效...)

require("@babel/register")(); // 支持tsx等

然后就可以通过ts-node ./ssr/server.tsx的方式直接启动ssr服务器,这样做的好处是我们不需要打包服务端的代码,当然客户端的代码还是需要通过webpack打包的。

回到正题,我们来看看上面代码中出现的API实现。

3.2. 获取当前url对应的组件

对于服务端渲染的初始页面而言,我们只需要获取当前页面所需的数据即可。

NeZhaRouter的实现中,提供了routes的配置,用于传递一份客户端和服务端可以通用的路由配置文件,将url与需要渲染的Component关联起来

const routes: Array<RouteConfig> = [
    {path: '/', component: Home},
    {path: '/about', component: About},
    {path: '/list', component: List},
    {
        component: () => {
            return (<div>404</div>)
        }
    }
]

export function getMatchRouteConfig(url: string, routes: Array<RouteConfig>): RouteConfig {
    for (let route of routes) {
        if (!route.path || route.path === url || match(route.path, url)) return route
    }
}

通过getMatchRouteConfig这个方法,我们可以方便地通过url来获取目标页面组件,当拿到页面组件后,我们就可以访问到组件上提供的asyncDataserverSEO等接口了。

3.3. 页面初始化数据

按照约定,需要预取数据的页面组件,会提供一个asyncData的方法实现,用于获取初始化数据

Home.asyncData = async (store, location) => {
    let searchParams = {
        page: location.query.page
    }
    // 获取数据
    let result = await getArticleList(searchParams)
    // 将数据存放在store中
    store.dispatch({
        type: 'store_index_list',
        payload: {
            ...result,
            searchParams
        }
    })
    return result
}

在浏览器hydrate阶段,我们需要同一份页面数据,用来生成相同的vnode,进而才能成功完成该过程。因此我们需要将asyncData获取的数据同时传递给浏览器,我们可以在HTML中埋入全局变量的方式来实现,关联模板文件的细节在下一个章节会介绍到。

`<script>
    window.INIT_DATA = ${JSON.stringify(initData)}
</script>
`

此外,对于浏览器而言,在渲染每个页面时,通过asyncData获取数据也是理所应当的,我们可以在路由变化时实现这个逻辑,为此NeZhaRouter提供了onChange的接口

// client.ts
const onRouteChange = async (from, to) => {
    let RouteComponent = to.type
    let {location} = to.props

    if (RouteComponent.asyncData) {
        await RouteComponent.asyncData(store, location)
    }
}

通过asyncData,我们完成了获取页面初始化所需数据的通用方式。

3.4. 数据接口

我们在getArticleList中封装页面数据获取的方法,这个获取数据的方法理应支持同时在服务端和客户端运行的。

  • 在服务端,我们可能需要访问数据库、访问文件查询数据
  • 在浏览器端,我们需要通过AJAX请求数据

前面提到,数据接口服务器与SSR服务器分开管理(将博客上一个同构版本的包含swig服务端渲染的服务器调整为纯数据服务器的成本很低,只需要简单修改接口响应即可),因此对于SSR服务端和浏览器而言,我们都可以通过访问数据接口服务器来获取数据。

我们可以通过判断运行环境的方式封装这个逻辑,也可以使用如axios来同时处理Node和浏览器中的网络请求,由于axios本身支持这两个平台的网络访问,因此不需要额外的同构开发成本。

import axios from 'axios'

let isBrowser = typeof window !== 'undefined'
// 通过nginx转发到server服务,port:3000,  server端服务使用内网域名,减少cdn查询延迟
axios.defaults.baseURL = isBrowser ? `//www.shymean.com/api/` : `http://localhost:3000/`

// 获取首页文章列表
export const getArticleList = async (params) => {
    let url = `/${params.page || 1}`
    let result = await axios.get(url, {params})
    return result.data
}

获取数据之后,在渲染页面时,我们会通过Nax提供的connect方法将state通过props注入页面组件,完成组件的渲染。

const Home = connect((state) => {
    return {
        ...state.home
    }
})(({articles = [], total, page, location}) => {
    return (<div>
        {
            articles.map(post => {
                return <Article post={post}/>
            })
        }
    </div>)
})

Nax是我实现的一个简易版Redux,大致实现了createStoreapplyMiddlewareconnect等功能。

正常情况下,一个web应用只需要包含一个全局的store仓库即可。由于Node服务器会通过守护进程在后台运行,为了避免不同请求造成的全局污染,我们不能在同构代码中使用全局变量;相反的,我们为每一个访问都构造全新的store,用于存放当前页面渲染所需的数据。

// 在服务端需要保证每个请求返回的都是不同的store
export function createStore(initState = {}): Nax.Store {
    return Nax.createStore(reducer, initState);
}

这样,在服务端我们就完成了根据url获取组件->根据组件获取渲染所需的数据->渲染整个虚拟DOM树的过程,然后再调用renderHTML方法将整个应用转换为HTML字符串即可

let html = renderHTML(vnode)
res.writeHead(200, {"Content-Type": "text/html"})
// seoData也是通过约定组件的serverSEO方法返回
let tpl: string = getTemplate(html, initData, seoData)
res.end(tpl)

4. 模板文件

4.1. 页面组件的SEO

对于SEO而言,我希望每个路由组件能够提供各自对应的TDK。跟asyncData类似,我们也可以在页面组件上约定一个serverSEO方法,返回每个页面所需的SEO信息,然后修改head标签内对应的数据即可。

react-helmet这个组件库貌似可以实现类似于修改tdk功能,为了减少额外的学习成本,我们在这里直接使用字符串拼接的方式处理SEO信息。

getTemplate中,我们会返回完整的HTML页面,最简单的实现就是拼接字符串,下面是该方法的完整实现,可以看见进行的工作包括

  • 处理serverSEO生成的SEO信息
  • 插入应用根节点生成的HTML字符串
  • 将服务端初始化的数据INIT_DATA埋入页面
  • 加载客户端打包后的资源文件staticResource.cssstaticResource.js
export default function getTemplate(html, initData, seoData): string {
    // 默认的SEO信息
    if (!seoData) {
        seoData = {
            title: '橙红年代',
            description: 'Author: shymean, Category: IT Blog, Name: 橙红年代, shymean前端开发个人博客,专注web前端、后端开发技术,总结工作经历及心得。',
            keywords: 'shymean,橙红年代,前端开发,个人博客,HTML,CSS,JavaScript,React,Vue,NodeJS',
        }
    }
    return `
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>${seoData.title}</title>
    <meta name="keywords" content="${seoData.keywords}">
    <meta name="description" content="${seoData.description}">
    <link rel="stylesheet" href="${staticResource.css}">
</head>
<body>
    <div id="root">${html}</div>
    <script>
        window.INIT_DATA = ${JSON.stringify(initData)}
    </script>
    <script src="${staticResource.js}"></script>
</body>
</html>`
}

4.2. 打包样式表

在上面的代码中,我们刻忽略了除了JavaScript之外的其他代码,如样式表、静态资源等。在React中,一种比较常见的CSS处理方案是CSS Modules,这要求我们在组件中引入样式表,通过webpack的loader,我们可以将其进行打包。但问题在于,我并不希望打包服务端代码

由于在之前的同构渲染版本中,并没有使用CSS模块化之类的样式表管理,而是单纯地使用了BEM管理样式表,然后引入了一个全局的样式表文件。因此在本次重构中,对于样式表的处理也只是在浏览器的入口文件中引入样式表,然后继续使用webpack打包而已。

// client.ts
import './assets/scss/blog.scss'

这样可以实现只在客户端打包样式表,对于SSR服务端而言,并不关注和处理样式表的逻辑。

缺点在于我暂时无法使用CSS模块等优秀的CSS方案特性了,因此我认为一种解决方案是只在webpack环境下加载css文件。

4.3. 静态资源输出

我们看见getTemplate方法中使用的staticResource.cssstaticResource.js

客户端与服务端的枢纽在于模板文件,我们通过模板文件上的scriptlink等标签加载打包的后的静态资源,因此我们需要将打包输出的文件关联到模板中。

如果输出的资源文件名是固定的,那通过硬编码在模板中直接写死静态资源路径即可,但我们往往会在通过hash文件名控制缓存,因此如何动态修改staticResource对应的文件路径呢?

我的处理是调用webpack接口手动打包(其实这个功能是博客上一个同构版本中实现的,因此这个版本直接复用该命令)。其大致实现原理为:分析webpack的output文件,解析输出路径,然后生成一个map.json的文件,用于保存输出文件的路径信息,通过require('map.json')的方式获取staticResource,参与getTemplate的拼接。

function createOutputMap(outputFile) {
    let mapPath = path.resolve(__dirname, "../ssr/public/map.json")

    fs.writeFile(mapPath, JSON.stringify(outputFile), function (err) {
        if (err) throw err;
        console.log(`打包完成,成功生成${mapPath}`)
    })
}

bundler.run((err, stats) => {
    if (err) throw err
    let assets = stats.toJson().assets

    let outputFile = getOutputFileName(assets)
    outputFile = getOutputFilePath(outputFile, config.output.path)

    createOutputMap(outputFile)
})

完整脚本打包代码可以移步源码查阅。

5. 小结

本文主要整理了将博客从Koa同构渲染迁移到SSR渲染,现在来回顾一下整个流程

  • 编写页面路由组件,并实现asyncDataserverSEO方法
  • 配置routes,绑定url和页面组件
  • 在服务端通过当前访问url获取页面组件,
  • 初始化store,调用asyncData获取渲染页面必需的数据,调用serverSEO获取SEO信息
  • 初始化根节点,获取完整的虚拟DOM树,然后通过renderHTML将虚拟DOM树转换为HTML字符串
  • 拼接完整的HTML字符串,返回给浏览器
  • 浏览器接收后进行根据INIT_DATA重新初始化虚拟DOM,然后进行hydrate,接手后续交互和路由跳转。

在整个过程中,会遇见很多问题,从实现的一个简易Recat框架NeZha开始,逐步添加NeZhaRouter路由组件、Nax状态管理库等功能,最后搭建环境并实现一个比较粗略的SSR版本。

在这个过程中,学到了很多东西,包括虚拟DOM跨端原理、React源码实现、状态管理等。由于精力和时间有限,整个项目应该还存在一些BUG,如果大家发现博客访问存在问题,欢迎留言或issue帮忙指正。