Vue3 的服务端渲染(SSR)原理(同构原理)

#vue3

目录

总结

  • ssr 简单说就是把 vdom 渲染成 HTML 字符串
  • 客户端激活原理:需要干==两件事情==
    • 会==递归== 在虚拟节点与真实DOM元素之间建立联系,即 vnode.el = el。这样才能保证后续更新程序正确运行。
    • 为 DOM 元素==添加事件绑定==。
  • 激活完成后,整个应用程序已经完全==被 Vue.js 接管为 CSR 应用程序==

1. CSR、SSR、同构

图片&文件

  • CSR 和 SSR 如上图
  • 同构
    • 首次渲染与 SSR 的工作流程是一致的
    • 激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了

1.1. 渲染对比

图片&文件

2. Vdom 渲染成 HTML 字符串

2.1. renderElementVNode:渲染标准的 DOM

    // 自闭合标签
const VOID_TAGS =
	"area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr".split(
		",",
	);
function renderElementVNode(vnode) {
	const { type: tag, props, children } = vnode;
	const isVoidElement = VOID_TAGS.includes(tag);
	// 开始标签
	let ret = `<${tag}`;
	// 属性,考虑属性名称是否合法,还要对属性值进行 HTML 转义
	if (props) {
		ret += renderAttrs(props);
	}
	// 结束标签
	ret += isVoidElement ? `/>` : `>`;

	// 如果是自闭合标签, 直接返回
	if (isVoidElement) return ret;

	// 子节点: 说明是文本节点,直接拼接
	if (typeof children === "string") {
		ret += children;
		// 子节点: 说明是数组,递归渲染 , 拼接
	} else {
		children.forEach((child) => {
			ret += renderElementVNode(child);
		});
	}

	// 走到这里说明是普通标签,需要闭合
	ret += `</${tag}>`;

	// 返回拼接好的字符串
	return ret;
}

2.2. renderAttrs

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 value = props[key];
		if (key === "class") {
			// ret += ` class="${renderClass(value)}"`
		} else if (key === "style") {
			// ret += ` style="${renderStyle(value)}"`
		} else {
			ret += renderDynamicAttr(key, value);
		}
	}
	return ret;
}

function isRenderableValue(value) {
	if (value == null) {
		return false;
	}
	const type = typeof value;
	return type === "string" || type === "number" || type === "boolean";
}

const isBooleanAttr = (key) =>
	(
		`itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly` +
		`,async,autofocus,autoplay,controls,default,defer,disabled,hidden,` +
		`loop,open,required,reversed,scoped,seamless,` +
		`checked,muted,multiple,selected`
	)
		.split(",")
		.includes(key);
const isSSRSafeAttrName = (key) => true;

function renderDynamicAttr(key, value) {
	if (!isRenderableValue(value)) {
		return ``;
	}
	if (isBooleanAttr(key)) {
		return value === false ? `` : ` ${key}`;
	} else if (isSSRSafeAttrName(key)) {
		return value === "" ? ` ${key}` : ` ${key}="${escapeHtml(value)}"`;
	} else {
		console.warn(
			`[@vue/server-renderer] Skipped rendering unsafe attribute name: ${key}`,
		);
		return ``;
	}
}

2.3. escapeHtml

不展开了

3. Vue 组件渲染成 HTML 字符串

比如自定义组件 MyComponent,如下:

const MyComponent = {
	name: "App",
	setup() {
		const str = ref("foo");
		return () => {
			return {
				type: "div",
				children: [
					{
						type: "span",
						children: str.value,
						props: {
							onClick: () => {
								str.value = "bar";
							},
						},
					},
					{ type: "span", children: "baz" },
				],
			};
		};
	},
};

const compVNode = {
	type: MyComponent,
};

详细代码

function renderComponentVNode(vnode) {
	const isFunctional = typeof vnode.type === "function";
	let componentOptions = vnode.type;
	if (isFunctional) {
		componentOptions = {
			render: vnode.type,
			props: vnode.type.props,
		};
	}
	let {
		render,
		data,
		setup,
		beforeCreate,
		created,
		beforeMount,
		mounted,
		beforeUpdate,
		updated,
		props: propsOption,
	} = componentOptions;

	// 生命周期钩子
	beforeCreate && beforeCreate();

	// 不需要转成响应式数据,服务端渲染不需要
	const state = data ? data() : null;
	const [props, attrs] = resolveProps(propsOption, vnode.props);

	const slots = vnode.children || {};

	const instance = {
		state,
		props,
		isMounted: false,
		subTree: null,
		slots,
		mounted: [],
		keepAliveCtx: null,
	};

	function emit(event, ...payload) {
		const eventName = `on${event[0].toUpperCase() + event.slice(1)}`;
		const handler = instance.props[eventName];
		if (handler) {
			handler(...payload);
		} else {
			console.error("事件不存在");
		}
	}

	// setup
	let setupState = null;
	if (setup) {
		const setupContext = { attrs, emit, slots };
		const prevInstance = setCurrentInstance(instance);
		const setupResult = setup(
			shallowReadonly(instance.props),
			setupContext,
		);
		setCurrentInstance(prevInstance);
		if (typeof setupResult === "function") {
			if (render)
				console.error(
					"setup 函数返回渲染函数,render 选项将被忽略",
				);
			render = setupResult;
		} else {
			setupState = setupContext;
		}
	}

	vnode.component = instance;

	const renderContext = new Proxy(instance, {
		get(t, k, r) {
			const { state, props, slots } = t;

			if (k === "$slots") return slots;

			if (state && k in state) {
				return state[k];
			} else if (k in props) {
				return props[k];
			} else if (setupState && k in setupState) {
				return setupState[k];
			} else {
				console.error("不存在");
			}
		},
		set(t, k, v, r) {
			const { state, props } = t;
			if (state && k in state) {
				state[k] = v;
			} else if (k in props) {
				props[k] = v;
			} else if (setupState && k in setupState) {
				setupState[k] = v;
			} else {
				console.error("不存在");
			}
		},
	});

	// created
	created && created.call(renderContext);
	// 执行 render 得到 subTree,即组件要渲染的内容
	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 renderComponentVNode(vnode);
	} else if (vnode.type === Text) {
		// 处理文本...
	} else if (vnode.type === Fragment) {
		// 处理片段...
	} else {
		// 其他 VNode 类型
	}
}

3.1. 两种渲染初始化的对比

图片&文件

4. 客户端激活的原理

  • 在同构渲染过程中,组件的代码会分别在服务端和浏览器中执行一次。
    • 在服务端,组件会被渲染为静态的 HTML 字符串,并发送给浏览器。
    • 浏览器则会渲染由服务端返回的静态的 HTML 内容,并下载打包在静态资源中的组件代码。 当下载完毕后,浏览器会解释并执行该组件代码。
    • 当组件代码在客户端执行时,由于页面中已经存在对应的 DOM 元素,
      • 所以渲染器并不会执行创建 DOM 元素的逻辑,而是会执行激活操作。
  • 激活操作可以总结为两个步骤
    • 在虚拟节点与真实DOM元素之间建立联系,即 vnode.el = el。这样才能保证后续更新程序正确运行。
    • 为 DOM 元素添加事件绑定。

主要代码如下

// 激活 客户端渲染,水合
function hydrate(vnode, container) {
	hydrateNode(container.firstChild, vnode);
}

function hydrateNode(node, vnode) {
	const { type } = vnode;
	vnode.el = node;

	// 如果是组件, 则调用 mountComponent 完成激活
	if (typeof type === "object") {
		mountComponent(vnode, container, null);
		// 如果是真实的 DOM 元素,需要和 vnode 进行水合
	} else if (typeof type === "string") {
		hydrateElement(node, vnode);
	}

	// 返回下一个节点, 用于下次激活操作
	return node.nextSibling;
}

function hydrateElement(el, vnode) {
	if (vnode.props) {
		for (const key in vnode.props) {
			// 只有事件属性才需要进行水合(激活)
			if (/^on/.test(key)) {
				patchProps(el, key, null, vnode.props[key]);
			}
		}
	}
	// 递归激活子节点
	if (Array.isArray(vnode.children)) {
		let nextNode = el.firstChild;
		const len = vnode.children.length;
		for (let i = 0; i < len; i++) {
			nextNode = hydrateNode(nextNode, vnode.children[i]);
		}
	}
}

mountComponent 需要特殊处理下,如下

图片&文件

5. 编写同构代码的注意事项

5.1. 生命周期

beforeUpdate、updated、 beforeMount、mounted、beforeUnmount、unmounted 等生命周期钩子函数不会在服务端执行。

  • 与 DOM 相关的钩子函数都不会在服务端执行

比如下面的代码node 端会出内存泄漏问题

图片&文件

解决方案,两个,如下图

图片&文件

5.2. 使用跨平台的 API

  • 由于组件的代码既要在浏览器中运行,也要在服务器中运行,所以编写组件代码时,要额外注意代码的跨平台性。
  • 通常我们在选择第三方库的时候,会选择支持跨平台的库, 例如使用 Axios 作为网络请求库。

5.3. 只在某一端引入模块

特定端的实现。无论在客户端还是在服务端,都应该保证功能的一致性。

例如,组件需要读取 cookie 信息。

  • 在客户端,我们可以 通过 document.cookie 来实现读取;
  • 而在服务端,则需要根据请求头来实现读取。所以,很多功能模块需要我们为客户端和服务端分别实现。

比如

图片&文件

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

状态污染既可以是应用级的,也可以是模块级的。

  • 对于应用,我们应该为每一个请求创建一个独立的应用实例。
    • 图片&文件
  • 对于模块,我们应该避免使用模块级的全局变量。这是因为在不做特殊处理的情况下,多个请求会共用模块级的全局变量,造成请求间的交叉污染。因为服务器与用 户是一对多的关系,下图,造成了 count 的污染
    • 图片&文件

5.5. ClientOnly

仅在客户端渲染组件中的部分内容。这需要我们自行封装 <ClientOnly> 组件,被该组件包裹的内容仅在客户端才会被渲 染。

520