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

2,461 阅读14分钟

文章介绍

缘起:Hi 各位掘金的朋友,你们好 我是 无双 (joney),几个月前我转了一个部门目前在做toC的业务,主要的工作就是维护 这个站点 newegg, 一个北美的电商站点,它由NodeSSR技术支撑。由于第一个做toC的业务,比较的陌生,期间呢学习和见识了很多不同于toB的业务技术。借助这个工作经验,和学习经验,我们从零开始学习和设计了一个简单的实用性强的SSR 同构框架,在你不需要使用Next的情况下,以供参考。

项目地址

分支指南🧭

  • 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?啊这,来上图。

image.png

image.png

好了这个图非常的直观,这里不展开说明了,但是我们不由发现一个问题,如果服务器去处理 那么我们的事件怎么办?这个就是下面的同构内容了,服务器当然不能完成 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. 首先我们设计一下文件夹结构

image.png 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 完成了这件事

image.png

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 也不需要!

image.png

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

先看目录结构

image.png 再看内容

/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 优化人家全给你做了, 如果不满意你可以再次定制!

image.png

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