vuejs设计与实现-同构渲染

272 阅读1分钟

CSR、SSR以及同构渲染

同构渲染分为首次渲染以及非首次渲染. 首次渲染与SSR的工作流程一致, 即当首次访问或刷新页面时, 整个页面的内容是服务端完成渲染的, 浏览器最终得到的是渲染好的HTML页面, 接下来浏览器会解析并渲染该页面.

在解析过程中, 浏览器会发现HTML代码中存在<link><script>标签, 于是会从CDN或服务器获取相应资源, 这一步与CSR一致. 当js资源加载完毕后, 会进行激活操作, 就是vusjs中的“hydration”. 包括:

  1. vuejs在当前页面已经渲染的dom元素以及vuejs组件所渲染的虚拟dom之间建立联系.
  2. vuejs从HTML页面中提取由服务端序列化后发送过来的数据, 用以初始化整个vuejs应用程序.

激活完成后, 整个应用程序已经完全被vuejs接管为CSR应用了. 后续操作都会按照CSR应用程序的流程来执行.

对同构渲染最多的误解是, 它能提升可交互时间. 事实是同构渲染仍然需要像CSR那样等待js资源加载完成, 并且在客户端激活完成, 才能响应用户操作. 因此理论上同构渲染无法提升可交互时间. 同构的含义是指同样一套代码既可以在服务端运行, 也可以在客户端运行. 例如编写一个vue组件, 既可以在服务端运行, 被渲染为HTML字符串; 也可以在客户端运行, 和普通的CSR应用程序一样.

将虚拟DOM渲染为HTML字符串

将普通标签类的虚拟节点渲染为HTML字符串, 本质上是字符串的拼接. 当然还需要考虑到一些边界条件:

  1. 是否为自闭合标签
  2. props属性名称是否合法, 对属性值进行HTML转义
  3. 子节点类型多种多样, 如Fragment、组件、函数式组件、文本等
  4. 标签的文本子节点也需要进行HTML转义
// 自闭合标签(void elemnt  WHATWG规范)  
const VOID_TAGS = 'area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr'.split(',')

// 将虚拟节点渲染为html字符串
function renderElementVnode(vnode){
    const { type: tag, props, children } = vnode
    const isVoidElement = VOID_TAGS.includes(tag)
    
    let ret = `<${tag}`
    if(props) {
        for(const k in props) {
            ret += ` ${k}=${props[key]}`
        }
    }
    
    // 判断是否为自闭合标签, 且不需要处理children
    ret += isVoidElement ? '/>' : '>'
        if(isVoidElement) return 
        
        if(typeof children === 'string') {
            ret += children
        } else {
            children.forEach(child => {
                ret += renderElementVnode(child)
            })
        }
        
        ret += `</${tag}>`
        
        return ret
}

// 处理props, ref、key或事件绑定等服务端渲染时忽略
const shouldIgnoreProp = ['key', 'ref']
function renderAttrs(props) {
    let ret = ''
    for(const key in props){
        if(shouldIgnoreProp.includes(key) || /^on[^a-z]/.test(key)) continue
        const val = props[key]
        ret += renderDynamicAttr(key, val)
    }
    return ret
}

// 完成props属性渲染
function renderDynamicAttr(key, val){
    // 1. 属性是否为 bool attribute 如 multiple、controls等
    if(isBooleanAttr(key)) {
        return val === false ? '' : ` ${key}`
    // 2. 属性名称是否合法且安全, 并对属性值进行转义(防御XSS攻击)
    } else if(isSSRSafeAttrName(key)) {
        return val === '' ? ` ${key}` : ` ${key}=${escapeHtml(val)}`
    } else {
        // error... 
    }
    return ``
}

将组件渲染为HTML字符串

把组件渲染为HTML字符串与把普通标签节点渲染为HTML字符串并没有本质区别. 组件的渲染函数用来描述要渲染的内容, 只需要执行组件的渲染函数得到对应的虚拟dom, 再将其渲染为HTML字符串.

服务端渲染时, 组件的初始化流程与客户端渲染时基本一致, 有两个重要区别.

  • 服务端渲染的是当前应用的快照, 不存在数据变更后重新渲染. 所有数据在服务端都无需是响应式的.
  • 服务端渲染只需要获取组件要渲染的subTree, 无须调用渲染器完成真实dom的创建.
function renderCompoentVnode(vnode){
    const isFuncal = typeof vnode.type === 'function'
    let compOptions = vnode.type
    
    if(isFuncal){
        compOptions = {
            render: vnode.type,
            props: vnode.type.props
        }
    }
    let { render, data, setup, beforeCreate, created, props: propsOptions } = compOptions
    // 1. 执行 beforeCreate
    beforeCreate?.()
    
    // 2. 初始化data/props的非响应式数据  
    const state = data ? data() : null
    // data不再是响应式数据 props也不是浅响应的
    const [props, attrs] = resolveProps(propsOptions, vnode.props)
    
    const slots = vnode.children || {}
    // 3. 创建组件实例
    const instance = {
        state,
        props,
        isMounted: false,
        subTree: null,
        slots,
        mounted: [],
        keepAliveCtx: null
    }
    
    function emit(){
         // ...
    }
    
    let setupState = null
    // 4. 执行setup
    if(setup){
        const setupContext = { attrs, emit, slots }
        // ...
        // 如果 setup 返回渲染函数, 则忽略render选项
    }
    
    const renderContext = new Proxy(instance, {
        get(){ ... },
        set(){ ... }
    })
    // 5. created. 无需设置render effect完成渲染, 也就没有其他生命周期
    created && created.call(renderContext)
    const subTree = render.call(renderContext, renderContext)
    return renderVnode(subTree)
}

function renderVnode(vnode){
    const type = typeof vnode.type
    if(type === 'string') {
        return renderElementVnode(vnode)
    } else if(type === 'object' || type === 'function') {
        return renderCompoentVnode(vnode)
    } else if(type === Text) {
        // 文本...
    } else if(type === Fragment) {
        // 片段...
    } else {
        // ...其他类型
    }
}

客户端的激活原理

对于同构渲染, 组件的代码会在服务端与客户端分别执行一次. 在服务端组件被渲染为静态的HTML字符串, 发送给浏览器后将其渲染出来, 此时页面已存在对应的dom元素. 同时组件也会被打包到js文件中, 并在客户端被下载到浏览器中解释并执行. 组件代码在客户端浏览器执行时不会再创建相应的dom元素, 而需要做以下事:

  1. 在页面的dom元素与虚拟节点对象之间建立联系
  2. 为页面中的dom元素添加事件绑定

renderer.render(vnode, container) -> renderer.hydrate(vnode, container)

// 服务端渲染->客户端激活过程
// 1.服务端渲染的字符串
const html = renderCompoentVnode(compVnode)
// 2.假设客户端拿到了渲染字符串
// 设置挂载点
const container = document.querySelector('#app')
// 设置innerHTML 模拟由服务端渲染的内容
container.innerHTML = html
// 3.调用 hydrate 激活
renderer.hydrate(compVnode, container)


function hydrate(vnode, container){
    // 从容器元素的第一个子节点开始
    hydrateNode(container.firstChild, vnode)
}

function hydrateNode(node, vnode){
    // 引用真实dom
    vnode.el = node
    
    if(typeof vnode.type === 'object') {
        // 组件类型调用mountComponent进行激活
        mountComponent(vnode, container, null)
    } else if(typeof vnode.type === 'string') {
        if(node.nodeTyle !== 1) {
            // 真实dom的类型与虚拟dom的类型不一样, 打印错误
        } else {
            // 普通元素调用hydrateElement进行激活
            hydrateElement(node, vnode)
        }
    }
    // 返回下一个兄弟节点, 方便后续的激活操作
    return node.nextSibling
}

// 激活普通元素类型的节点
function hydrateElement(el, vnode){
    if(vnode.props) {
        // 只有事件类型的props需要处理
    }
    
    // 递归激活子节点
    if(isArray(vnode.children)){
        let nextNode = el.firstChild
        const len = vnode.children.length
        for(i ... len) {
            // 激活子节点
            nextNode = hydrateNode(nextNode, vnode.children[i])
        }
    }
}

// 服务端渲染的页面中已经存在真实dom 
// 组件挂载时无须再次创建真实dom元素
function mountComponent(vnode, container, anchor){
    // ...
    instance.update = effect(() => {
        // ...  if(!instance.isMounted) 未挂载
        if(vnode.el) {
            // 真实dom已存在, 进行激活
            hydrateNode(vnode.el, subTree)
        } else {
            // 正常挂载
            patch(null, subTree, container, anchor)
        }
    })
}

编写同构的代码

由于一份代码既要在服务端运行, 又要在客户端运行. 所以在编写代码时需要注意运行环境的不同导致的差异.

组件的生命周期

组件的代码在服务端运行时, 并不会进行真正的挂载操作. 而是生成对应的HTML字符串. 又因为服务端渲染是应用的快照(当前数据状态下页面应该呈现的内容), 所以不存在数据变化后的重新渲染. 只有createdbeforeCreate两个钩子函数会在服务端执行.

构建工具会分别为客户端与服务端输出两个独立的包. 被if(!import.meta.env.SSR)判断包裹的代码只会在客户端包中存在.

使用跨平台的API

在编写代码时应该避免使用平台特有的API, 比如浏览器环境才有的windowdocument等对象. 不得不使用时, 可以利用如import.meta.env.SSR等环境变量做代码守卫. 同样nodejs中特有的API也无法在浏览器中运行, 这时可以选择跨平台的第三方库, 如Axios作为网络请求库.

只在某一端引入模块

有些功能需要客户端与服务端分别进行实现. 如客户端使用document.cookie、服务端根据请求头来获取cookie. 或是使用守卫只在某一端实现的特定功能.

// 客户端与服务端分别使用不同手段实现某个功能
let storage
if(import.meta.env.SSR){
    storage = import('./storage-server.js')
} else {
    storage = import('./storage.js')
}
// ...

避免交叉请求引起的状态污染

污染既可以是应用级的, 也可以是模块级的.

import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from 'App.vue'

// 每次调用render函数进行服务端渲染时
// 都会为当前请求调用createSSRApp函数创建一个新的应用实例
// 避免不同请求共用一个应用实例所导致的状态污染
async function render(url, manifest){
    const app = createSSRApp(App)
    
    const ctx = {}
    const html = await renderToString(app, ctx)
    
    return html
} 


<script>
// 模块级别的全局变量
let count = 0

export default {
    create(){
        count++
    }
}
</script>
// A发送请求到服务器时, 服务器会执行组件的代码即count自增
// B也发送请求到服务器, 服务器再次执行组件的代码count自增, 被A的请求影响便会造成交叉污染

<ClientOnly>组件

假设使用一个不兼容SSR的第三方组件, 我们可以通过<ClientOnly>组件实现只在客户端渲染该组件.

<ClientOnly>
    <ThirdComp />
</ClientOnly>

// 利用生命周期钩子控制是否渲染
export const ClientOnly = defineComponent({
    setup(_, { slots }){
        const show = ref(false)
        onMounted(() => {
            show.value = true
        })
        return () => (show.value && slots.default) ? slots.default() : null
    }
})

在客户端激活时, mounted钩子还没有被触发, 服务端与客户端渲染的内容一致. 等到激活完成, 且mounted钩子触发之后, 才会在客户端将<ClientOnly>组件插槽内容渲染出来.

总结

编写同构的组件代码时, 需要注意以下几点:

  1. 注意组件的生命周期. 更新、挂载、卸载等钩子不会在服务端执行.
  2. 使用跨平台的API. 如网络请求库Axios.
  3. 特定端的实现, 保证功能的一致性.
  4. 避免交叉请求引起的状态污染.
  5. <ClientOnly>实现仅在客户端渲染的内容.