umijs / umi

A framework in react community ✨
https://umijs.org
MIT License
15.39k stars 2.65k forks source link

[RFC] Umi 3 SSR & Prerender #4500

Closed ycjcl868 closed 4 years ago

ycjcl868 commented 4 years ago

[RFC] Umi 3 SSR & Prerender

背景

在 Umi 2 版本时,中途支持了 SSR 功能 #2543,有一些修改比较 hack,维护上有一定困难。与此同时,由于服务端 umi-server 与 umi 分离,用户使用门槛高,背离了 umi 开箱即用的特性。

Umi 3 发布时因时间原因,没加 SSR 功能,但与 SSR 有关的接口预留了,加上社区的催促 #4357、 dumi 静态站点、内部前台/官网项目 SEO 需求,使得现在得着手加上 服务端渲染 和 预渲染 功能。

目标

特性



SSR 功能尽可能集中在 preset-built-in/plugins/ssr.ts 中,与正常功能解耦,避免 SSR 强耦合 Umi 框架。

用法


配置一行,即可开启 SSR 模式

// .umirc.ts
import { defineConfig } from 'umi';

export default defineConfig({
    ssr: {
    /** 
     * 隐藏 window.g_initialProps ,让客户端渲染强行执行 getInitialProps
     * 以 client 渲染优先,数据始终取最新
     */
    // forceInitial: false,

    /**
     * umi dev 时,在 devServer 中执行服务端渲染
     * 如果与 Chair 结合时,服务端渲染应该在 Controller 中调用
     */
        // devServerRender: true

    // 流式渲染
    // mode: 'stream',

    // 需不需要 staticMarkup,exportStatic 默认开启
    // staticMarkup: false,

    // TODO: 检查客户端与服务端渲染是否一致,不一致走客户端
    // checksum: false
  },
})

开发

和 SPA 开发一样,然后执行 umi dev ,访问页面,就是 SSR 后的(相当于 umi-server 直接集成 umi dev 中)。
通过 webpack-dev-middleware 的 writeToDisk 写 umi.server.js 到文件系统(这里不清楚需要将 writeToDisk 作为 devServer 配置中的属性不?)


如果是与 Chair / Egg 集成的方式,会通过环境变量不启动内置的 dev server middleware。

全局数据

TODO

页面数据

依旧是通过 getInitialProps ,这个不变

// pages/Home.tsx
import { isBrowser } from 'umi';
import React from 'react';

const Home = (props) => {
  const { layout, page, ...restProps } = props;
  return (
    <div>{layout}-{page}</div>
  )
}

Home.getInitialProps = async (params) => {
  // isBrowser() => 'node' || 'browser' 等环境
  // initialData 来自 server 上的 initialData
  const { layout, initialData } = params;
  return {
    layout,
    page: 'Home',
  }
}

// => <div>Hello Home</div>

构建

执行 umi build ,除了正常的 umi.js 外,会多一个 server 文件: umi.server.js (相当于服务端入口文件,类比浏览器加载 umi.js 客户端渲染)

- dist
    - umi.js
    - umi.css
    - index.html
    - umi.server.js

部署

直接使用 umi start ,会默认使用 pm2 + expressjs 直接运行在生产环境。

如果有单独服务端集成需求,可以单独引入 dist/umi.server.js 使用:

使用内置 render:

const fs = require('fs');
const path = require('path');

// 如果是 CDN 方式,可使用 const path = await downloadFromCDN('http://cdn.com/umi.server.js');
const render = require('./dist/umi.server');

router.get('/*', async () => {
  /**
   * [Break Change] ssrHtml => html
   * html 里是渲染好的 html,如果 error ,html 则返回未渲染的模板,降级走 CSR
   * rootContainer 里面只包含 #root 里面的渲染,可以拿到后在 Koa/Express/Chair/Egg 做封装
   */
  const { html, rootContainer, error } = await render({
    // [Break Change] req: { url } => { path }
    path: '/bar?locale=en-US',
    // 透传到 getInitialProps({ data, ...params })
    initialData: {
    },
    // 支持自定义 html 模板,不传时读同目录的 index.html
    // htmlTemplate: htmlTemplate,
    // 默认 root
    mountElementId: 'root',
    context: {},
  })

  ctx.body = html;
})


自定义 Render(内置 string 渲染、stream 渲染,先不暴露 createServerApp):

const ReactDOMServer = require('react-dom/server');
const { createServerApp } = require('./dist/umi.server');

router.get('/*', async () => {
  const ServerApp = await createServerApp({
    path: '/bar?locale=en-US',
    initialData: {
    },
    context: {},
  });
    ReactDOMServer.renderToStaticNodeStream(ServerApp); // 流式渲染
});

运行时增强

单独增加 prefetch(预获取) 和 preload(预加载) 插件,由用户决定是否开启:

// .umirc.ts
export default {
    prefetch: {},
  preload: {},
}

// => <link rel="preload" href="/umi.js" as="script"/>
// => <link rel="prefetch" href="/umi.css"  />

prerender 预渲染

umi@3 使用内置的方式,不再提供 prerender 配置,通过现有的 exportStatic 来做:

// .umirc.ts
export default {
    ssr: {},
  exportStatic: {},
}


执行 umi build 后,产物会生成所有页面的 html
同时会删掉 umi.server.js (感觉没必要再生成 服务端文件,已经渲染出来了)

- dist  
  - index.html
  - news
    - index.html
        - 1.html
    - [id].html => 实际是默认的 html 模板,走 CSR


因为预渲染是在编译时进行,所以对于动态路由,默认只生成 SPA 的 html ,如果需要生成特定的动态路由,可以配置 extra 扩展额外的路由: 

export default {
    ssr: {},
  exportStatic: {
    extraRoutes: ['/news/1', '/news/2']
  }
}

实现

见 #4499 PR

流程图


ssr 和 prerender 功能都通过一个插件完成,写在 packages/preset-built-in/src/plugins/features/ssr.ts  内置插件中,作为 umi 内置功能。

开发 & 构建

通过 api.chainWebpack 增加一个新 entry umi.server ,打出 commonjs2 类型。可以按 umi 2 的方式来写 umi-build-dev/src/getWebpackConfig.ts#L50-L86

通过在 dev 下去增加一个 devServer 的中间件,来做服务端渲染,保证了开箱即用。

数据流

直接扩展 runtime 中的 ssr:

前端框架无关

ssr.ts 插件不依赖 React 、 Vue ,在 Umi 中每个 renderer 会提供 renderClient 和 renderServer 方法,分别处理客户端渲染和服务端渲染。

同时还会提供 createServerElement  方法,只返回处理后的将渲染的 Element 元素(其中『处理』包括 getInitialProps 处理、路由处理等)

由下可知,umi.server.js 中的 render 方法,实际上是由
getInitial 
+ renderServer (ReactDOM.renderToString + getServerElement  )
+ handleHtml 

共同组成,如果需对 render 方法进行定义,可直接基于上面的方法进行定义:

// dist/umi.server.js
// 未压缩

// 这里可以通过 umi config,修改 renderer,但必须提供
import { renderServer } from '@umijs/renderer-react';

// 导出供服务端直接使用
export default const render = async () => {

  // Step 1:处理数据
  const { pageInitialProps, appInitialData } = await getInitial({
    path
  });

  // Step 2:通过 path 获取元素进行渲染(包括 `createServerElement` 获取 Element)
  const rootContainer = await renderServer({
    path
  });

  // Step 3: 处理 rootContainer 加入到 html 模板的逻辑
  const html = handleHtml({
    rootContainer, 
    pageInitialProps, 
    appInitialData,
    mountElementId
  })

  return html;
}

export { 
  // 处理 数据预获取
  getInitial,
  /** const ServerElement = await getServerElement(opts);
   * ReactDOMServer.renderToStaticNodeStream(ServerElement);
   */ 主要用于不需要内置 render,只想拿到待渲染的 Element,例如自定义 Stream 流式渲染  
    getServerElement,
  handleHtml,
}; 


如果后面需要 Vue 渲染,也可以实现:

// 通过插件
api.modifyServerRenderer(async () => '@umijs/renderer-vue');

// @umijs/renderer-vue 中只需要提供 renderServer,renderClient,createServerElement 即可完成
export {
    renderServer,
  renderClient,
  createServerElement,
}

全局变量

渲染流程:

umi 2 中使用了 window.g_initialData 存储应用初始数据
在 umi 3 中

导出工具方法

通过 api.addUmiExports 导出 isBrowser 方法,提供给开发者在 getInitialProps 函数中,能够根据客户端还是服务端做不同的数据返回。

预渲染

直接将 @umijs/plugin-prerender 插件融合进来,这块还好,没太大改动。

任务拆分

服务端 entry

umi.server.js

React renderServer

开发


客户端 entry

构建

部署

插件支持

相关插件:

预渲染

preset-built-int/plugins/prerender.ts


TODO

参考


sorrycc commented 4 years ago

语雀复制过来格式(空行)有点乱。

ClearSeve commented 4 years ago

终于迎来了更新!

xiexingen commented 4 years ago

关注了好久了

williamnie commented 4 years ago

做数据流的时候,注意下dva单例的问题,在服务端每次初始化一个dva,或者是每个请求后清空dva数据,保证每个用户得到的数据是干净的最小集,页面的体积会小点,服务器的内存也会小。期待3.0的ssr

ycjcl868 commented 4 years ago

这个在 应用级数据 里已经考虑到了

ycjcl868 commented 4 years ago

发了一个 Umi 3.2.0-beta.1 版本,支持 SSR,抢先体验 Demo:https://github.com/ycjcl868/umi-ssr-demo-test

CyberNika commented 4 years ago

exportStatic 可否增加一个 include 的选项,使用者可以控制哪一些静态化?

guanweisong commented 4 years ago

能否支持基于hooks的数据流?例如hox或者unstated-next

ycjcl868 commented 4 years ago

能否支持基于hooks的数据流?例如hox或者unstated-next

支持的,通过 app.ts 里暴露的:

export const ssr = {
  // 扩展 params 参数
  modifyGetInitialPropsParams: async (memo) => {
    return {
      ...memo,
      store1: '666',
    };
  },
};
ycjcl868 commented 4 years ago

include

可以来个 PR,这个优先级目前较低

qingxiao commented 4 years ago

现在使用umi@2的ssr遇到些框架上的问题, 期待@3.x的ssr能够解决

ycjcl868 commented 4 years ago

Aliyun FC 验证 ok: http://umi.ssr-fc.com/ , 对比 Umi 2,string 渲染模式下 TTFB 从 2.3s 减少到 243 ms

image

xiexingen commented 4 years ago

基础数据会不会重新请求 谁尝试过umi3的ssr了没

delonzhou commented 4 years ago

文档中说的umi start是不是还没有实现,这个大概什么时间可以好呢?

ycjcl868 commented 4 years ago

文档中说的umi start是不是还没有实现,这个大概什么时间可以好呢?

想了下 umi start 实际上也只是执行了下 umi.server.js,想把这部分的功能交由用户决定,真实服务端使用是比较复杂的

ycjcl868 commented 4 years ago

全局数据获取可以通过不同的 Layout 上的 getInitialProps 获取,先不加 app.ts 应用级别的

xiexingen commented 4 years ago

getInitialProps

Layout上有getInitialProps么

ycjcl868 commented 4 years ago

getInitialProps

Layout上有getInitialProps么

有的

onur-ozkan commented 4 years ago

I am testing 3.2.0-beta.9 version on my prod level project and have some issues using redux persist. Whenever I force load the pages, I get the following error:

One more thing, when will SSR feature be on the stable version ?

ycjcl868 commented 4 years ago

I am testing 3.2.0-beta.9 version on my prod level project and have some issues using redux persist. Whenever I force load the pages, I get the following error:

One more thing, when will SSR feature be on the stable version ?

I released the 3.2.0-beta.12,supporting the dynamicImport, if you want to use redux in the umi ssr project, you can use the api modifyGetInitialPropsCtx like this:

// src/app.ts
export const ssr = {
  modifyGetInitialPropsCtx: async (ctx) => {
    const { _store } = getApp();
    ctx.store = _store;
  },
}

and you will get the ctx.store in every getInitialProps functions, details in https://github.com/umijs/plugins/pull/199/files.

Another thing the stable version(3.2.0) would be released in this week 🚀.

ycjcl868 commented 4 years ago

exportStatic 可否增加一个 include 的选项,使用者可以控制哪一些静态化?

会加在路由上

ycjcl868 commented 4 years ago

https://github.com/umijs/umi/releases/tag/v3.2.0

williamnie commented 4 years ago

👍👍👍👍👍👍

petitspois commented 4 years ago
static getInitialProps = async (ctx) => {
        const res = await getStatistic({type:0, timeType:'latest1Month', ...ctx.query}, ctx.store.dispatch, ctx.history)
        if(res.error){
            //毫无反应是什么原因呢
            ctx.history.push('/inspection')
        }
        return ctx.store.getState()
    }

还有部署后.css, .js 类型都为text/html, 路径不正确

briefguo commented 4 years ago

自定义 Render(内置 string 渲染、stream 渲染,先不暴露 createServerApp):

为啥不暴露出来呢,我感觉有些地方还是要用的。 比如这个场景下, nextjs就有对应的renderPage方法来实现styled-components的SSR