likes
comments
collection
share

从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容 )

作者站长头像
站长
· 阅读数 25

文章介绍

项目地址

分支指南🧭

  • node-ssr 基础
  • vite_plugin_ssr 贼好用的集成方案
  • nest_ssr newegg在用的方案

本文目录大概分三个部分

  • Base Node SSR (最简单的机遇nodejs+gulp 完成的SSR方案)
  • 基于 vite-plugin-ssr 的实现,这个东西简直了!非常傻瓜🤪 好用至极
  • 基于Nest + Vite 实现的SSR

Base Node SSR

通过这个部分,我们将学习到什么是SSR,什么是SSR同构,如何做 ,核心内容是什么

SSR 是什么

先简单的说了一下SSR ,技术,它是 Server-Side Rendering ,说白了就是让服务器去完成html 的构建,比如之前一些nodejs上的template 技术比如ejs ,当你请求服务器的时候,它直接给你丢一个 完整的html 出来,而不是像 csr 给你的是一个只有html 和一个叫root 的div ,然后所有的页面渲染 都是由 js 去完成,什么?你不知道CSR?啊这,来上图。

从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容 )

从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容 )

好了这个图非常的直观,这里不展开说明了,但是我们不由发现一个问题,如果服务器去处理 那么我们的事件怎么办?这个就是下面的同构内容了,服务器当然不能完成 browser 环境中的事件绑定 dom操作逻辑,那都是 browser 去做的事情。需要js browser 环境中 运行,才能操作dom 绑定事件,调用browser的api等

SSR 同构 ?

所谓的SSR同构就是在SSR 返回 html 时,也把 js 的给送出去,让js 去控制页面上的事件绑定,逻辑处理等内容。在server (服务器) 构建首屏html 的时候,有部分js逻辑 也能在服务器中完成,完成对应的渲染。这样一套代码既能在server 运行又能在borwsr运行 ,这就是所谓的“同构应用” 总的来说大部分的页面逻辑还是在client 执行,只不过 首屏的内容是 SSR 去完成的。而不是返回一个“空”的html。 这也引出一个名称:“hydrate” 意思就是在浏览器(browser) 环境中 绑定逻辑。

基于node 和 React 的同构 如何实现呢?

  1. 首先我们设计一下文件夹结构

从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容 ) shard 是通用的工具 client 主要放 入口函数,和page 以及component 。server 放一个执行indexjs 和 一个render 里面是一些SSR需要用到的方法,router是一份路由表 ,hooks下是一个 全局的content 对象

  1. 然后我们先实现一个简单的server 并且把 react 组建 渲染成html

//简单的写一下

++++
import { renderToString } from "react-dom/server";

const render = () => {
  return renderToString(<div>666</div>);
};

++++
import express from "express";
const app = express();
const reactContentString = render(req.path, data);
app.get("/p/*", async (req, res) => {
  res.setHeader("Content-Type", "text/html");
  res.send(reactContentString)
}

当然我们这样写铁定是运行要报错,因为jsx 我们还没编译,我们借助babel编译它 ,然后再执行 编译后的文件,如此依赖它就正常运行了

$ ./node_modules/.bin/babel ./src -d ./dist -w
$ nodemon /dist

但我们的事件绑定这么办?client 中的内容怎么办?css 怎么办?下面一个一个说明

  1. 我们说过 关SSR还不够,我们需要一份js 到browser 运行,去处理逻辑 绑定事件...

这个又引发另一个话题了 这个js 如何构建? ,答案是 如同csr 那样去构建 我们还需要去重新像csr 一样去build 一份js bundle,不同的是我们不用render方法也不需要index.html ,下面是srver 完整的实现

server 端


app.use(express.static("public")); // 启动静态资源 主要是要访问到client build 的js 进行 hydrate


// 完整的html 而不是一个div
const htmlTLP = (reactContentStream ) => ` 
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title></title>
  </head>
  <body>
    <div id="root"> ${reactContentStream} </div>
    <!-- 绑定事件 -->
    <script src="/js/app.js"></script> 
  </body>
  </html>
  `;
  
  app.get("/p/home", async (req, res) => {
  res.setHeader("Content-Type", "text/html");
  const data = {
    name: "",
    page: req.path,
    message: "pro",
    basename: "pro",
    list: [],
    // 页面特定的 每个页面都不一样
    data: [
      {
        email: "bmlishizeng@gmial.com",
        id: 1,
      },
    ],
  };

  const reactContentStream = render(<button onClick={()=>{ console.log('666') }}> 点击我</button>, data);
  res.send(htmlTLP(reactContentStream));
});

这个话题还涉及工程化 你也看到了我们都是import 的语法 这在node端执行不了哈!需要编译,而且我们还需要build csr的模块 把不同的js 都build 到一个js中,我这里使用gulp 完成了这件事

从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容 )

const path = require("path");
const gulp = require("gulp");
const babelify = require("babelify");
const browserify = require("browserify"); // 插件,
const source = require("vinyl-source-stream"); // 转成stream流
const buffer = require("vinyl-buffer"); // 转成二进制流(buffer)
const { series } = require("gulp");
const { watch } = require("gulp");
const { exec, execSync, spawnSync } = require("child_process");

// 原产物
const clean = (done) => {
  execSync('rm -rf ./dist')
  execSync('rm -rf ./public/js')
  done()
};

// 构建 浏览器使用的js 绑定事件
const _script = () => {
  return browserify("./src/client/index.js")
    .transform(babelify, {
      presets: ["@babel/preset-env", "@babel/preset-react"],
      plugins: [
        "@babel/plugin-transform-runtime",
        ["@babel/plugin-proposal-decorators", { legacy: true }],
        ["@babel/plugin-proposal-class-properties", { loose: false }],
      ],
    })
    .bundle()
    .pipe(source("app.js"))
    .pipe(buffer())
    .pipe(gulp.dest("./public/js"));
};

// 构建 node server 需要的 sst
const _scriptServer = (cb) => {
  // 执行一段 shell  就好了 不需要merge
  exec(
    "./node_modules/.bin/babel ./src -d ./dist",
    function (err, stdout, stderr) {
      console.log(stdout);
      cb(err);
    }
  );
};


// 启动server
let isOpen = false;
const startServer = () => {
  if (isOpen) return;
  isOpen = true;

  const scriptPath = (script) => path.join(__dirname, 'script', script);
  execSync(`chmod u+x ./script/nodemon.sh`);

  // 执行一段 shell  就好了 不需要merge
  spawnSync(
    "open",
    ["-a",
      "terminal",
      scriptPath('nodemon.sh'),
    ],
    {
      cwd: path.join(__dirname),
    }
  );
};

// 初始化
const init = (done) => {
  series(clean, _script, _scriptServer, startServer)()
  done()
}

// dev server & client
const server_build = (done) => {
  const watcher = watch(["./src/**/*.js", "./src/**/*.jsx"]);
  watcher.on("change", () => {
    console.log("update file...");
    series(clean, _script, _scriptServer, startServer)()
  });
  done();
};

exports.dev = series(init , server_build);

{
  "presets":[
    ["@babel/preset-env"],
    ["@babel/preset-react"]
  ]
}

nodemon.sh

THIS_DIR=$(cd -P "$(dirname "$(readlink "${BASH_SOURCE[0]}" || echo "${BASH_SOURCE[0]}")")" && pwd)

echo $THIS_DIR


cd "${THIS_DIR}/../"

nodemon --inspect ./dist
"scripts": {
    "dev": "gulp dev"
  },

经过以上的操作,我们dev 的时候 ,还会弹出一个 窗口 里面运行 nodemon ./dist 。然后还有一个窗口正在通过 babel watch src下的文件,这样一个非常简单的 工程化 功能就做好了,

client的入口


ReactDom.hydrate(
 <button onClick={()=>{ console.log('666') }}> 点击我</button>
, document.getElementById("root"));

以上基本上就 最基础的 ssr 了, 这个 hydrate完成后,到浏览器访问也是正常的了和事件处理也都正常了

完善它

以上我们做的还远远不够 比如 路由怎么办?你现在只有一个简单的div 我需要page 和component 下的文件都能正常使用。初始化数据怎么办比如我希望 我能把上文的data 在ssr 的时候就渲染出来,并且到 borower 上也能获取到这个数据 还不用重新在borwser发起请求,因为我在 server ssr 的时候都已经获取了 ?css怎么办?我希望css 也能正常使用,下面我们逐个攻破

  1. 路由

这里我介绍的全部页面的同构直出,至于为什么见下面的分析

/shard/Router.js
import React from 'react';
import Home1 from '../client/page/Home/Hom1'
import Home2 from '../client/page/Home/Hom2'
import P1 from '../client/page/Production/P1'
import P2 from '../client/page/Production/P2'

const Router = {
  "/home" : Home1,
  "/home2" : Home2,
  "/p/p1" : P1,
  "/p/p2" : P2,
};

export  {
  Router
}

/server/render
const App = (props) => {
  const Component =  useMemo(() =>{
    const CH  = Router[props.path]  || (() => <></>)
    return <CH></CH>
  }, []);

  return (
      <>
      { Component  }
      </>
  );
};

const render = (path ) => {
  return renderToString(<App data={data} ></App>);
};


/client
const App = () => {
  const Component =  useMemo(() =>{
    const CH  = Router[location.pathname]  || <></>
    return <CH></CH>
  }, []);

  return (
      <>
        { Component }
      </>
  );
};

ReactDom.hydrate(<App></App>, document.getElementById("root"));


/server  就是改造一下render 参数把path 向下传递这里就不详细的说明了

这样就基本上完成了路由同构了

  1. 关于数据怎么办?如何把data 传进去

这里引入两个名词:注水 / 脱水,所谓的注水 就是在server render 的htmlString 中把server获取的data 以某种方式携带在htmlString 中,你可以使用JSON.stringify实现,这就是 “注入(水/数据)”

/server
const htmlTLP = (reactContentStream, data ) => ` 
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title></title>
  </head>
  <body>
    <div id="root"> ${reactContentStream} </div>
    <!-- 注水 -->
    <script>
    window.__INIT_STATE__ = ${JSON.stringify(data)};
    </script>

    <!-- 绑定事件 -->
    <script src="/js/app.js"></script> 
  </body>
  </html>
  `;
// 调用的时候把data 传递进去
res.send(htmlTLP(reactContentStream, data))

// 我们还使用了一个content 和useReducer 把 它存在了顶层组件中
import React, { useContext } from "react";

const InitStateContext = React.createContext({
  name: "",
  page: "", // home or pro
  message: "",
  list: [],
  // 页面特定的 每个页面都不一样
  data: "",
});

const reducer = (state, action) => {
  switch (action.type) {
    case "changeTheme":
      return {
        ...state,
        ...action.payload,
      };
    default:
      return {
        ...state,
        ...action.payload,
      };
  }
};

const useInitState = () => {
  const initStateCtx = useContext(InitStateContext);
  const [state = {}, dispatch = null] = initStateCtx;
  return [state, dispatch];
};

export { InitStateContext, useInitState, reducer };

// 然后改造一下 server render
const App = (props) => {
  const [state, dispatch] = useReducer(reducer, props.data);
  
  const Component =  useMemo(() =>{
    const CH  = Router[state.page]  || (() => <></>)
    return <CH></CH>
  }, []);

  return (
    <InitStateContext.Provider value={[state, dispatch]}>
      { Component  }
    </InitStateContext.Provider>
  );
};

const render = (path, data ) => {
  return renderToString(<App data={data} path={path}></App>);
};

export { render };

// 最后是脱水,所谓的脱水 就是当 borwser 中的js 执行的时候把数据读取出来 交给 borwser 控制
const get_initState = () => {
  return window.__INIT_STATE__;
};

const App = () => {
  const [state, dispatch] = useReducer(reducer, get_initState());
  
  const Component =  useMemo(() =>{
    const CH  = Router[state.page]  || <></>
    return <CH></CH>
  }, []);

  return (
      <InitStateContext.Provider value={[state, dispatch]}>
        { Component }
      </InitStateContext.Provider>
  );
};

ReactDom.hydrate(<App></App>, document.getElementById("root"));

好以上就是 数据的注水和脱水

  1. css 怎么办?其他资源和脚本怎么办?

我们这里由于是简单的实现基础的ssr ,不深入了,只做了一个简单的办法


const injectCssLink  = ( links ) => {
  let temp = '';

  links.forEach( (item ) => {
      temp += `<link rel="stylesheet" href="${item}"> </link>
      `
  } )

  return temp
};

const htmlTLP = (reactContentStream, data, links ) => ` 
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title></title>
    ${links || ''}
    
  </head>
  <body>
    <div id="root"> ${reactContentStream} </div>
    <!-- 注水 -->
    <script>
    window.__INIT_STATE__ = ${JSON.stringify(data)};
    </script>

    <!-- 绑定事件 -->
    <script src="/js/app.js"></script> 
  </body>
  </html>
  `;

+++
  const reactContentString = render(req.path, data);
  res.send(htmlTLP(reactContentString, data, injectCssLink([
    '/style/home/index.css'
  ])));

好以上,就是全部的内容,我们的基础的base-node-ssr 就完成了✅, 通过上面的内容 相信你对SSR的全部核心的点 都已经有了一个大概的了解了。下面我们看看借助其他工具的实现

不容错过的内容

这里有一个非常坑爹的地方 ,当你使用Router-DOM 做同构的时候 由于静态路由,如果你像下面这样写 这是不行的, 会导致 进入不到 server 的 render 函数中 html 只会返回一次,在这个时候 hydrate 的js 会进入到 browser,接管页面的之后的所有操作,至此server 将不在介入交互的其中

a. 机制 / 或者其他的ssr 返回, 一旦东西交给了 browser,那么所有的路由操作都在 浏览器了, 不会再经服务器 有ssr的页面了, 之后的所有页面都不在是ssr,和csr 一致

b. 路由同构 路由刷新的时候比如 从 / -> /production 由于/进入的时候 浏览器接管路,因此不会进入 server 如果要改变 initState 将不可能

c. 闪动 由于 hydrate 和ssr 在 /production 的行为不一致,会导致 页面的闪动,原因是:ssr 是production 但 hydrate 初始化的一面 不是同一个dom 结构

// browser
const get_initState = () => {
  return window.__INIT_STATE__;
};

const App = () => {
  const [state, dispatch] = useReducer(reducer, get_initState());

  return (
    <BrowserRouter >
      <InitStateContext.Provider value={[state, dispatch]}>
        <Router></Router>
      </InitStateContext.Provider>
    </BrowserRouter>
  );
};

ReactDom.hydrate(<App></App>, document.getElementById("root"));

// server
const App = (props) => {
  const [state, dispatch] = useReducer(reducer, props.data);
  return (
    <InitStateContext.Provider value={[state, dispatch]}>
      <StaticRouter>
        <Router />
      </StaticRouter>
    </InitStateContext.Provider>
  );
};

const render = (path, data, components) => {
  console.log('render->', path);
  return renderToString(<App data={data} path={path}></App>);
};

app.get("*", async (req, res) => {
  res.setHeader("Content-Type", "text/html");
  const value = await axios.get("http://localhost:3030/api/users");
  const data = {
    name: "",
    page: "",
    message: "",
    list: [],
    // 页面特定的 每个页面都不一样
    data: value.data.data,
  };
  const reactContentStream = render(req.path, data, Home);

  res.send(htmlTLP(reactContentStream, data));
});


有鉴于此 ,突然发现 除了 第一次ssr 之外,这个ssr 同构好像有点鸡肋, 我们考虑了两种处理方案 ,1. 要么全部同构直出 ,2. 我们是否可以 做权衡,都要一点点🤏 不过分吧, 3. 最优解:如果需要你可以在判断路由的match

建立 层级 ( 平衡 )

在client 上,我们使用 不同的层级处理

比如 /home 下的路由 包括子路由全部给home 处理,然后在 加上 basename 进行处理, 这样处理的话,意味着我们 对 client 的router 拆分更详细的模块, 建立多个 hydrate bundle 和 ssr render

至于闪动 我们需要想法子 加上loading 处理,对于page 直接的跳转也需要分两种 module 内 和module 外

// 如果你这样 会有问题

const App = (props) => {
  const [state, dispatch] = useReducer(reducer, props.data);
  return (
    <InitStateContext.Provider value={[state, dispatch]}>
      <StaticRouter location={props.path}>
        {state.basename === "home" && <HRouter basename={state.basename}></HRouter>}
        {state.basename === "pro" && <PRouter basename={state.basename}></PRouter>}
      </StaticRouter>
    </InitStateContext.Provider>
  );
};

//    <HRouter basename={state.basename}></HRouter>

// 这样会有问题 由于 每次 server 回来,都是动态的  HRouter baserName 判断,会导致browser 中的router  不会生效
 app.get("/pro/*", async (req, res) => {
 app.get("/home/*", async (req, res) => {

// 要处理这个问题 就得把他们分多份  比如下面这样子 每个 client 单独搞一个  server 端也单独搞一个
    <InitStateContext.Provider value={[state, dispatch]}>
      <StaticRouter location='home'>
        <HRouter basename={state.basename}></HRouter>
      </StaticRouter>
    </InitStateContext.Provider>

// 然后在 server ssr 匹配到 子路径就不要渲染了,避免闪动 代码就不敲了 这是一种方案

做完这些之后 基本能够符合我们的要求了

全部Page 同构直出

这个的话 就相对的非常的简单了,仅仅是单纯在server 端传入 你需要的组件就好了,在 client,也是如此 这里简单期间 全部打包 📦,然后 用page 判断 (当然后续要做拆分哈 加载当前页面用到的就好了)

// router
import React from 'react';
import Home1 from '../client/page/Home/Hom1'
import Home2 from '../client/page/Home/Hom2'
import P1 from '../client/page/Production/P1'
import P2 from '../client/page/Production/P2'

const Router = {
  "/home" : Home1,
  "/home2" : Home2,
  "/p/p1" : P1,
  "/p/p2" : P2,
};

export  {
  Router
}

// client & server 
+++++
  const Component =  useMemo(() =>{
    const CH  = Router[state.page]  || <></>
    return <CH></CH>
  }, []);

  return (
      <InitStateContext.Provider value={[state, dispatch]}>
        { Component }
      </InitStateContext.Provider>
  );

+++++

// server
app.get('/', (req, res) => {
  res.redirect('/home')
});

app.get("/p/*", async (req, res) => {
  res.setHeader("Content-Type", "text/html");
  const data = {
    name: "",
    page: req.path,
    message: "pro",
    basename: "pro",
    list: [],
    // 页面特定的 每个页面都不一样
    data: [
      {
        email: "861795660@qq.com",
        id: 1,
      },
    ],
  };

  const reactContentStream = render(req.path, data);
  console.log('reactContentStream pro',reactContentStream);
  res.send(htmlTLP(reactContentStream, data));
});


app.get("/home", async (req, res) => {
  res.setHeader("Content-Type", "text/html");
  const data = {
    name: "",
    page: "/home",
    message: "home",
    basename: "home",
    list: [],
    // 页面特定的 每个页面都不一样
    data: [
      {
        email: "861795660@qq.com",
        id: 1,
      },
    ],
  };

  const reactContentStream = render(req.path, data);
  console.log('reactContentStream pro', reactContentStream);
  res.send(htmlTLP(reactContentStream, data));
});

app.get("/home2", async (req, res) => {
  res.setHeader("Content-Type", "text/html");
  const value = await axios.get("http://localhost:3030/api/users");
  const data = {
    name: "",
    page: "/home2",
    message: "",
    basename: "home",
    list: [],
    // 页面特定的 每个页面都不一样
    data: value.data.data,
  };
  const reactContentStream = render(req.path, data);
  console.log("reactContentStream home", reactContentStream);
  res.send(htmlTLP(reactContentStream, data));
});

Vite-plugin-ssr

这个!这个非常推荐,香到飞天!不需要复杂的配置,傻瓜式使用 所有的ssr功能该具备的都具备,关于路由使用的方案是 全页面直出 ,文档 官方文档,下面是一些我的实践

首先是使用 它进行init初始化

$ yarn init vite-plugin-ssr@latest
# 下面的提问选择你需要的就好了 我选择react-ts

然后你就得到了这样的目录,ts都不需要你配置 bable 也不需要!

从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容 )

shared 是我自己加的功能和上文一样,

特性之 usePageContext

这个意思很简单,在两端(server 和borwser 你都能获取到 上下文context)注意bowser中比较弱

// 假设你有这样的场景,你需要在某个页面render 前做一次fetch ,你只需要在client 组件中声明 function 在server 的时候就能执行,且把数据注入其中

/ pages/index
++++
export { Page };
export const getDescription = (pageProps: any) =>
  `User: ${pageProps.firstName} ${pageProps.lastName}`;
export const fn = (pageProps: any) => {
  return "2";
};

export const query = { modelName: "Product", select: ["name", "price"] };
+++
export  const Page = () => <>....</>

/renderer/_default.page.server.tsx

// 这里export的东西能够在ssr前拿到 在page 中 export的东西能够在 serverRedner 中content上获取到 非常的方便

export { onBeforeRender };

async function onBeforeRender(pageContext: any) {
  // Our `query` export values are available at `pageContext.exports.query`
  const { query } = pageContext.exports;
  const { getDescription, fn } = pageContext.exports;

  const pageProps = {
    data: 666,
    yourQuery: query,
    fnR: fn && fn(1),
  };

  return { pageContext: { pageProps } };
}

async function render(pageContext: PageContextServer) {
// 它会先执行 onBeforeRender 然后 render 前就拿到数据了 (pageContext)中
....这里的内容看官方,官方给你生成好了
}

关于css / scss

你需要配置?不你甚至配都不需要配!安装sass 直接用!

$ yarn add sass

/pages/about

import React, { useEffect } from "react";
import Button from "@/shared/components/Button";

import "./code.css";
import "./page.scss";

export const query = {
  modelName: "User",
  select: ["firstName", "lastName"],
};

export { getDocumentProps };
function getDocumentProps(pageProps: any) {
  return {
    title: pageProps.product.name,
    description: pageProps.product.description,
  };
}

export { Page };

function Page(initialState: any) {
  // const count = usePageContext()
  useEffect(() => {
    console.log("initData", initialState);
  }, []);

  return (
    <>
      <h1>About</h1>
      <p className="my-scss">F</p>
      <p>
        Demo using <code>vite-plugin-ssr</code>.
      </p>
      <Button onChange={() => {}}>点击我</Button>
    </>
  );
}

ok 直接去看看 你发现它已经生效了!没错就是这么简单 less 也是一样

集成mobx

也非常的简单 但是搞这个前 我们先总结一下

  1. 路由问题

vite-plugin-ssr 的路由上默认的都是在pages下依据path 来指定 ,同next 很类似,index.page.tsx 就是page 页面

  1. render 问题 它们在哪里?

它们分别位于 renderer 下的的 _default.page.client ,_default.page.server 文件中,一份是给client 用的一份是给server 端用的,如果你需要全局注入某些东西,也从这里开始。比如mobx

  1. 好介绍完之后我们开始整mobx

先看目录结构

从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容 ) 再看内容

/about.ts
import { makeAutoObservable } from "mobx";
import type { AppStore } from "..";

export class AboutStore {
  count = 0;

  name = "";

  root: AppStore;

  async fetchName() {
    // const res = await fetch(`${import.meta.env.VITE_SERVER_URL}/api/name`);
    // const name = await res.text();
    const name = "AboutStore";
    this.name = name;
    console.log("about", name);
  }

  constructor(root: AppStore) {
    makeAutoObservable(this);
    this.root = root;
  }

  increment() {
    this.count++;
  }
}

/index.ts

import { createContext, useContext } from "react";
import { HomeStore } from "./modules/home";
import { AboutStore } from "./modules/about";

export type PrefetchStore<State> = {
  // merge ssr prefetched data
  hydrate(state: State): void;
  // provide ssr prefetched data
  dehydra(): State | undefined;
};

type PickKeys<T> = {
  [K in keyof T]: T[K] extends PrefetchStore<unknown> ? K : never;
}[keyof T];

export class AppStore {
  home: HomeStore;

  about: AboutStore;

  constructor() {
    this.home = new HomeStore(this);
    this.about = new AboutStore(this);
  }

  hydrate(data: Record<string, unknown>) {
    Object.keys(data).forEach((key) => {
      const k = key as PickKeys<AppStore>;

      if (import.meta.env.DEV) {
        console.info(`hydrate ${k}`);
      }

      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      if (this[k]) this[k]?.hydrate?.(data[k] as any);
    });
  }

  dehydra() {
    type Data = Record<PickKeys<AppStore>, unknown>;
    const data: Partial<Data> = {};

    Object.keys(this).forEach((key) => {
      const k = key as PickKeys<AppStore>;

      data[k] = this[k]?.dehydra?.();
    });

    return data as Data;
  }
}

const appStore = new AppStore();

export const createStore = () => appStore;
export const RootContext = createContext<AppStore>(appStore);
export const useStore = <T extends keyof AppStore>(key: T): AppStore[T] => {
  const root = useContext(RootContext);

  return root[key];
};

export { appStore };

关于全局注入的问题,我刚才已经说过了 不多说了

// 仅用client 举例子 server 也是一样的
async function render(pageContext: PageContextClient) {
  const { Page, pageProps } = pageContext;

  hydrateRoot(
    document.getElementById("page-view")!,
    <RootContext.Provider value={appStore}> // RootContext和 appStore 就是mobx的东西
      <PageShell pageContext={pageContext}>
        <Page {...pageProps} />
      </PageShell>
    </RootContext.Provider>
  );
}

使用的时候也很方便


const Counter = observer(() => {
  const [count, setCount] = useState(0);
  const { fetchName } = useLocalObservable(() => appStore.home);

  useEffect(() => {
    console.log("count", count);
    fetchName((count || 0).toString());
  }, [count]);

  return (
    <>
      <div>👌</div>
      <br />
      <button type="button" onClick={() => setCount((count) => count + 1)}>
        Counter {count}
      </button>
    </>
  );
});

也许你会怀疑其build 的优化

答案是它不会让你失望!代码的 build 优化人家全给你做了, 如果不满意你可以再次定制!

从零构建自己的SSR同构框架(包含Vite + Vite-plugin-ssr + React + Nest 等内容 )

Nest + Vite 自己实现SSR

看了的Nest文章的同学,抓紧也来看看这个文章吧 哈哈哈, 现在有不少公司的SSR同构就是这么做的,包括我们公司,不过我们公司是自己的构建工具基于webpackge 我就基于vite 搞了一个 效果 还ok

Nest的基础SSR

我们使用nest cli 初始化一个最简单的项目骨架,然后需要修改一点点 🤏 Nest 的ts 配置 把es2017 改成 es5

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "module": "commonjs",
    "declaration": true,
    "removeComments": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "target": "ES2015",
    "sourceMap": true,
    "outDir": "./dist",
    "baseUrl": "./",
    "incremental": true,
    "skipLibCheck": true,
    "strictNullChecks": false,
    "noImplicitAny": false,
    "strictBindCallApply": false,
    "forceConsistentCasingInFileNames": false,
    "noFallthroughCasesInSwitch": false
  }
}

Nest自定义装饰器 + 过滤器 完成SSR

我实现的nest 方案如下方所示 通过 装饰器 + 过滤器 完成, 大概的使用如下, 具体的实现在后面, 从原理上而言非常的简单 ,当Controller class 实例化 的时候,RenderReact就会工作把 组件绑定到类中,当请求了就先看看 有没有class 是否需要render 如果需要就 RenderInterceptor 中render string 就好了, 由于我使用了vite 所以没有使用 自己 的方式,当然第一版本的方案是使用自己render 一个string 和html string 的,我保留了部分代码 你可以自己看

@Controller()
@UseInterceptors(RenderInterceptor)
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('/home')
  @RenderReact(Home)
  home() {
    return {
      name: '',
      message: '',
      list: [],
      data: '',
    };
  }

}

  • RenderInterceptor

@Injectable()
export class RenderInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<string> {
    const [req, res] = context.getArgs<[Request, Response]>();
    const apc = context.getClass<any>().prototype;
    const PageReactContent = apc.Components[req.path];
    const vs = req['viteServer'] as ViteDevServer;

    // 如果有 react 渲染印记,请转入渲染函数中执行 ssr
    return next.handle().pipe(
      map(async (value) => {
        return this.pipRender({
          res: res,
          req: req,
          page: PageReactContent,
          path: req.path,
          vs: vs,
        })(value);
      }),
      from,
    );
  }

  private pipRender = (options: InterPipRender) => {
    return async (initData: any) => {
      const { vs, res, req } = options;
      initData.page = options.path;

      // 读取html
      let template = '';

      if (process.env.NODE_ENV_ === 'production') {
        template = readFileSync(
          resolve(__dirname, '../../../client', 'index.html'),
          'utf-8',
        );
      } else {
        template = readFileSync(
          resolve(__dirname, '../../../', 'index.html'),
          'utf-8',
        );
      }

      // 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
      template = await vs.transformIndexHtml(req.originalUrl, template);

      // 得到一段ssr str
      const appHtml = render(options.page, initData);

      const html = template.replace(`<!--ssr-outlet-->`, appHtml);

      // 返回
      return html;
    };
  };

// 这个已经没有什么用的,只是做参考它是第一版本方案
  private htmlTLP = (
    reactContentStream: string,
    data?: any,
    links?: string,
  ) => ` 
  <!DOCTYPE html>
  <html lang="en">
  <head>
    <script type="module" src="/@vite/client"></script>
    <script type="module">
    import RefreshRuntime from "/@react-refresh"
    RefreshRuntime.injectIntoGlobalHook(window)
    window.$RefreshReg$ = () => {}
    window.$RefreshSig$ = () => (type) => type
    window.__vite_plugin_react_preamble_installed__ = true
    </script>

    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title></title>
    ${links || ''}
  </head>
  <body>
    <div id="root"> ${reactContentStream} </div>
    <!-- 注水 -->
    <script>
        window.__INIT_STATE__ = ${JSON.stringify(data)};
    </script>

    <!-- 绑定事件 -->
    <!-- <script src="/client/assets/index.58becadd.js"></script>  -->
    <script type="module" src="/src/share/render/client.tsx"></script>
  </body>
  </html>
  `;
}

  • RenderReact
export const RenderReact = (pageContent: PageReactContent) => {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  return applyDecorators((controller: any, router: string) => {
    // 加上一个属性 标记这个是一个组件 注意它只能为
    controller.Components = {
      [`/${router}`]: pageContent,
      ...controller.Components,
    };
  });
};

使用Vite 做中间价 详细请去看vite官方文档,它具备了HRM 的能力,也让client 的build 更简单

使用非常的简单 只需 加上这个中间价就好了

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets(join(__dirname, '..', 'public')); // 这两个和vite 无关 是nest 自己的static 
  app.useStaticAssets(join(__dirname, '..', 'client'));

  // Vite 中间,为了能在其他的ctx 访问 , viteServer 实例
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: 'custom',
  });

  app.use((req, res, next) => {
    req['viteServer'] = vite;
    next();
  });

  app.use(vite.middlewares);

  // 这样就能够选择正确的东西了
  await app.listen(3000);
}
bootstrap();

viteConfig, 注意不要写成ts 要不然 nest 的cli 会出问题

import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'client',
  },
});

总结

我们先使用node-srr + gulp 完成了一个最简单的SSR 同构方案,这让我们对SSR的核心和路线有了非常全面的了解;然后我们使用vite-plugin-ssr 完成了 基于vite + react 的ssr ,非常的简单!非常的给力,开箱即用;最后我们使用Nest + vite 完成了基于Nest的ssr同构方案,这样的项目架构,甚至都可以成为一个全栈开发的 骨架!我给出了他们的分支对应关系 分别在 base-node, vite_plugin_ssr, nest-ssr, 你可以在分支上找到哦相关的代码 以做参考

参考文档

项目地址

gulp文档

bable文档

vite文档-如何集成ssr

vite-plugin-ssr

nest-webpack-config

nest-demo官方demo