Skip to content

同构渲染 ​

参考资料:

https://github.com/yacan8/blog/issues/30

https://juejin.cn/post/7196335649728151612

https://cn.vitejs.dev/guide/ssr.html

https://juejin.cn/post/6844903943902855176#heading-1

https://juejin.cn/post/7145746941366763527

https://zhuanlan.zhihu.com/p/562815409

公司内部项目代码

1. 基本概念 ​

1.1 客户端渲染和服务端渲染的方式和区别 ​

  • 两者的渲染方式:

    • 顾名思义, 客户端渲染是在客户端进行dom的绘制, 首先请求模板文件, 随后请求相应资源, 然后基于js代码进行渲染, 也就是面试时候常背的css树, dom树渲染那一套;
    • 服务端渲染则是在服务器上(指node服务)根据访问的路由, 获取到相应的页面组件, 然后将组件在服务端组装为html文件, 最后返回给客户端.(后续有流程图会详细介绍)
  • 两者的区别

    • 客户端渲染是目前主流的方式, 其主要优点为:
      • SPA架构, 路由跳转基于js实现, 页面跳转更快
      • 页面渲染工作放在客户端, 减轻了服务端的压力
      • 前后端分离的开发模式, 开发效率更高
    • 服务端渲染的优点为:
      • 同等条件的网络链路下, 服务端渲染的页面白屏时间更短(参考下面流程图)
      • seo友好, 便于提高网站收录率(主要为C端)
  • 两者的渲染流程

  1. 客户端渲染的主要流程
  1. 服务端渲染的主要流程

1.2 同构渲染和客户端/服务端渲染 ​

  • 基本概念

    • 同构, 也即是指相同的代码在客户端和服务端渲染两次

    • 第一次访问页面是服务端渲染,基于第一次访问后续的交互就是 SPA 的效果和体验,还不影响SEO 效果

    • 同时拥有服务端的优点, 也拥有客户端的优点

  • 同构渲染的流程

2. 同构渲染的几个问题 ​

  • 服务端渲染组件的方式

    • Static Route和普通客户端路由Router
  • 客户端渲染组件的方式

    • hydrateRoot和createRoot的区别
  • 数据的获取/传递

    • 注水和脱水
  • 开发调试部署

    • 本地调试
    • nginx 和启动的node服务关系

3. 简单实现 ​

主要技术框架: vite + react, 搭建完成后的目录结构如下所示

image-20230815175630015

3.1 搭建项目 ​

这里我使用vite进行项目的搭建, 搭建好项目后修改index.html模板文件, 便于后续html替换. 替换后的index.html 文件如下所示

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"><!--APP_HTML--></div>
    <script type="module" src="/src/main.tsx"></script>
    <script><!--SSR_DATA--></script>
  </body>
</html>

可以看到相比于默认的模板文件, 在root 根标签内增加了<!--APP_HTML--> 标识位, 其主要是用于ssr 替换; <script><!--SSR_DATA--></script> 则是用于注入数据, 具体的下面有详细介绍.

3.2 搭建node服务 ​

这里使用express 作为node 服务, 安装好express 后, 在项目的根目录增加server.js 文件, 该文件是服务端的执行文件, 具体内容如下

javascript
import fs from "fs";
import express from "express";
import { SRR_ROUTERS } from "./src/router/routers.js";
const app = express();

// 通过vite创建server服务
const { createServer: createViteServer } = await import("vite");
const isProduction = process.env.NODE_ENV === "production";
const ssrManifest = isProduction
  ? fs.readFileSync("./dist/client/ssr-manifest.json", "utf-8")
  : undefined;

//创建vite服务实例
let vite;
if (!isProduction) {
  // 开发环境下
  vite = await createViteServer({
    server: { middlewareMode: true },
    appType: "custom",
    base: "/",
  });
  // 使用 vite 中间件
  app.use(vite.middlewares);
} else {
  // 生产环境下,设置静态目录
  app.use(express.static("./dist/client"));
}

app.get("*", async (req, res) => {
  const url = req.originalUrl;
  let template;
  let render;

  // 如果是生产环境,直接读取打包后的index.html和server-entry.js
  // 开发环境实时编译
  if (isProduction) {
    template = fs.readFileSync("./dist/client/index.html", "utf-8");
    render = (await import("./dist/server/server-entry.js")).render
  } else {
    template = fs.readFileSync("index.html", "utf8");
    template = await vite.transformIndexHtml(url, template)
    render = (await vite.ssrLoadModule("/src/server-entry.tsx")).render
  }

  if (!SRR_ROUTERS.includes(url)) {
    res.status(200).set({ "Content-Type": "text/html" }).end(template);
    return;
  }

  // 服务端渲染
  const { html, data: ssrData } = await render(url);

  if (ssrManifest.url) {
    res.redirect(301, ssrManifest.url);
    return;
  }

  // 给index.html的id为root标签中添加 <!--APP_HTML-->,做为后边要替换的标志
  let responseHtml = template.replace("<!--APP_HTML-->", html);
  responseHtml = responseHtml.replace("<!--SSR_DATA-->", `window.ssrData = ${JSON.stringify(ssrData)}`);
  res.status(200).set({ "Content-Type": "text/html" }).end(responseHtml);
});
app.listen(5173, () => {});

在上面的代码中, 我们首先使用环境变量区分了生产环境和开发环境, 然后根据不同的环境做了不同的配置.

  1. 开发环境使用vite 中间件, 进行文件的代理; 生产环境直接使用线上打包好的静态文件

    原因: 因为我们使用的是node启动的项目, 如果想要本地调试, 一些热更新等功能是没有的, 所以需要借助vite 来实现, 也即构建一个中间件供express 使用; 包括下面获取模板文件, 也是相同的原因

  2. 开发环境直接读取项目中的模板文件(使用前需要进行转换)和render 函数; 生产环境读取打包后的文件

    vite.ssrLoadModule 是在 Node.js 环境下加载模块及其依赖,并且在 Vite 同一个 Node.js 环境下执行,并且让这个模块经过Vite所有插件 SSR 模式转换,让同一份代码在SSR模式下也支持 Vite 提供的语法糖

  3. 生产环境需要生成ssr-manifest.json 文件

    ssrManifest的作用是告知 Node.js 端渲染这个模块下所有的依赖, 可以进行一些预加载的工作(需要在构建客户端资源的命令中配置, 见3.6节, 但是这个文件的具体使用还是不清楚)

3.3 服务端渲染 ​

新增服务端渲染入口文件

javascript
//服务端渲染的入口
import ReactDOMServer from "react-dom/server";
import RootApp from "./ssr/RootApp.tsx";
import App from "./App.tsx";

export function render(url:string) {
  // 获取数据, 可自己配置
  const ssrData = {
    data: {
      url: undefined as string | undefined,
    }
  }
  ssrData.data.url = url === '/home' ? undefined : `ssr render`
  const html = ReactDOMServer.renderToString(
    // 注水
    <RootApp ssr ssrData={ssrData.data} ssrPathname={url}>
      <App />
    </RootApp>
  )
  return {
    html,
    data: ssrData.data
  };
}

为什么使用ReactDOMServer ?

ReactDOMServer 在服务端将 React 组件渲染成静态的(HTML)标签, 然后返回给客户端

3.4 客户端渲染 ​

使用hydrateRoot 处理路由渲染

使用SSRProvider 进行数据脱水

javascript
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import {BrowserRouter} from "react-router-dom";
import {SSRProvider} from "./ssr/SSRProvider.tsx";

ReactDOM.hydrateRoot(document.getElementById('root')!,  <BrowserRouter>
  <React.StrictMode>
    <SSRProvider>
      <App />
    </SSRProvider>
  </React.StrictMode>
</BrowserRouter>)

为什么使用hydrateRoot 而不是createRoot

hydrate()配合 SSR 使用,与render()的区别在于渲染过程中能够复用服务端返回的现有 HTML 节点,只为其附加交互行为(事件监听等),并不重新创建 DOM 节点

3.5 数据的注水和脱水 ​

可以看到, 在服务端渲染的入口文件中, 我们将ssrData 返回到服务端执行文件中, 在此文件中就可以进行注水, 其和替换html 的逻辑一样

javascript
// 数据脱水
  responseHtml = responseHtml.replace("<!--SSR_DATA-->", `window.ssrData = ${JSON.stringify(ssrData)}`);

同时修改模板文件, 增加新的标签

javascript
<script><!--SSR_DATA--></script>

在SSRProvider 组件中进行脱水

javascript
import React, {createContext, useMemo} from "react";

export const SSRContext = createContext<{ ssrData?: any }>({
  ssrData: undefined,
});
SSRContext.displayName = 'SSRContext';

interface ISSRProviderProps {
  ssrData?: any
  children: React.ReactNode | React.ReactNode[]
}
export const SSRProvider: React.FC<ISSRProviderProps> = ({ ssrData, children }) => {
  const value = useMemo(() => {
    // 兼容服务端渲染
    let tempData;
    if (typeof window !== "undefined") {
      tempData = (window as any)?.ssrData ?? ssrData;
    } else {
      tempData = ssrData;
    }
    return {
      ssrData: tempData,
    };
  }, [ssrData]);

  return <SSRContext.Provider value={value}>{children}</SSRContext.Provider>;
}

具体的使用

javascript
import React, {memo} from 'react';
import {useSSR} from "../../../hooks/useSSR.ts";
import './index.css'

export interface IProps {

}

// 服务端和客户端同一套代码, 数据的获取在SSRProvider组件中已经做了兼容处理
const Backend: React.FC<IProps> = () => {
  const { ssrData } = useSSR<{ url: string }>()
  return <div className="backend">this is backend page {ssrData?.url}</div>;
};

export default memo(Backend);

3.6 开发调试部署 ​

  • 本地调试
json
"scripts": {
    "server": "npm run build && cross-env NODE_ENV=production node server.js",
    "build": "npm run build:client && npm run build:server",
    "build:client": "tsc && vite build --ssrManifest --outDir dist/client",
    "build:server": "tsc && vite build --outDir dist/server --ssr src/server-entry.tsx",
  }
  • 线上部署

需要nginx 监听相应的端口, 随后转发到服务端的启动端口

一般来讲, nginx监听宿主机的80/443 端口, 服务端的node 启动在8081 端口, 那么nginx 只需要将根路径/ 下的请求转发到8081端口即可,node` 服务进行页面渲染/资源回传

function test() {
    let flag;
    {
        const a1 =1;
        let a22;
        flag = a1
    }
    let a = { b: 1}
    
    
    console.log(flag)
    with(a){
        console.log(b)
    }
}

test()

4. SEO相关优化 ​

这部分内容主要是来自Next 的引导学习网站: https://nextjs.org/learn

seo优化主要是针对浏览器爬虫引擎做一些改进, 使得搜索引擎可以更好的找到当前网站, 进而提高网站的排名, 增加曝光.

4.1 robots.txt ​

该文件主要是用来告诉搜索引擎哪些页面可以爬虫, 哪些不能爬虫. 这个文件必须放在服务器的根目录或者是网站可以重定向到这个文件

使用Next 的话可以直接在public 文件夹内部添加该文件, 文件的格式如下

tex
//robots.txt

# Block all crawlers for /accounts
User-agent: *
Disallow: /accounts

# Allow all crawlers
User-agent: *
Allow: /

4.2 XML Sitemaps ​

该文件是和搜索引擎交流最方便的方式, 它能表示哪些url属于你网站本身的, 还能告诉搜索引擎你有哪些内容的更新. 使用Next 的话也是可以直接在public 文件夹里面进行添加, 如下

xml
<!-- public/sitemap.xml -->
<xml version="1.0" encoding="UTF-8">
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url>
      <loc>http://www.example.com/foo</loc>
      <lastmod>2021-06-01</lastmod>
    </url>
  </urlset>
</xml>

还有一种根据不同页面动态生成的方式, 即将其放在getServerSideProps 接口中, 由于代码有点多, 所以可以直接参考这里

4.3 Tags ​

这里主要分为Meta tags 和 Canonical tags 对于Meta tags 或者是robots.txt , 搜索引擎一般都会遵守, 但是Canonical tags 则是搜索引擎可以自己决定要不要遵守. 其具体例子如下

html
// 标准
<meta name="robots" content="noindex,nofollow" />
// 针对谷歌引擎的
<meta name="googlebot" content="noindex,nofollow" />

对于Canonical Tags来说, 其应用场景主要为: 如果网站有许多重复页面, 该tag可以用来告诉搜索引擎哪个是最具有代表性的. 如果不这样做, 同时搜索引擎根据自己的机制不能分辨出来, 那么就会降低网站的排名.

有如下例子: (https://example.com/products/phone) and http://example.com/phone. 我们考虑使用https://example.com/products/phone 来进行排名, 则可以配置为下面所示

html
<link rel="canonical" href="https://example.com/products/phone" />

4.4 URL标签结构化 ​

比如learn/next 会好过learn/1 这种不够语义化的链接

4.5 Metadata ​

这个标签是当前网页内容的抽象, 一般会有title, description 和 image, 这些标签不但利于搜索引擎seo, 也可以在搜索列表中进行展示.

html
<head>
	<title>iPhone 12 XS Max For Sale in Colorado - Big Discounts | Apple</title>
  <meta
  name="description"
  content="Check out iPhone 12 XR Pro and iPhone 12 Pro Max. Visit your local store and for expert advice."
/>
</head>

该网页会在搜索结果中被展示为

image-20230816104621067