Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] Umi 3 SSR & Prerender #4500

Closed
49 of 60 tasks
ycjcl868 opened this issue Apr 20, 2020 · 25 comments · Fixed by #4499
Closed
49 of 60 tasks

[RFC] Umi 3 SSR & Prerender #4500

ycjcl868 opened this issue Apr 20, 2020 · 25 comments · Fixed by #4499
Assignees
Labels
type(ssr) UmiJS SSR

Comments

@ycjcl868
Copy link
Contributor

ycjcl868 commented Apr 20, 2020

[RFC] Umi 3 SSR & Prerender

背景

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


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

目标

  • 发布 umi 3.2 支持 SSR,umi 官网支持预渲染(即 dumi 也同时支持静态站点的渲染)
  • dumi 站点默认开启 prerender
  • 内部前台 Bigfish 2 项目(蚂蚁官网、罗汉堂、外滩大会官网)迁移到 Bigfish 3.2
  • 对接 SFF,能够较低成本的使用 SSR 。

特性

  • 开箱即用,集成 umi-server@umijs/plugin-prerender ,真正做到开箱即用,不再加任何额外包和插件。
  • 默认不 node externals,考虑到 import from 'umi'  的问题,服务端不会 resolve  到插件导出的方法和属性。
  • polyfill 支持
  • 默认关闭(开启后会使三方库 isBrowser 判断失效,带来渲染不一致问题)
  • 服务端无关(不仅有 expressjs、也会有 koajseggjs 等)
  • 前端框架无关(不仅局限于 react SSR、可能会有 Vue SSR 等)
  • SSR 渲染失败后降级为 CSR
  • 去 cheerio,减少服务端包大小
  • 动态路由是默认 index.html 生成 [id].html 文件,支持动态路由渲染
  • 支持自定义 html 模板
  • 支持流式渲染,支持 staticMarkup 配置,同时 prerender 默认使用 staticMarkup。
  • 支持 dynamicImport 动态加载下的服务端渲染
  • 支持插件方式增强 SSR 渲染功能 ,进而可以扩展 umi.server.js ,不再需要用户手动扩展服务端,例如:
  • 超时渲染时自动切换到 CSR
  • title 标题渲染支持 @umijs/plugin-helmet
  • Stream 方式渲染
  • 支持应用级 getInitialProps ,通过配置 app.ts 增加 getInitialProps,让全应用有公共渲染数据,不再通过 Layout 加全局数据。
  • benchmarks


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,
}

全局变量

渲染流程:

  • 服务端渲染会调用 getInitialProps 后,会将数据注入到全局变量中。
  • 浏览器端渲染时直接读全局变量中的数据,若没有,会调用 getInitialProps 。 

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

  • window.g_initialData 来存储应用级初始数据
  • window.g_initialProps 来存储页面级初始数据

导出工具方法

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

预渲染

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

任务拆分

服务端 entry

umi.server.js

  • 增加 webpack server 配置
  • 构建出服务端使用的入口 umi.server.js 
  • 支持降级为 CSR 渲染
  • stream 渲染支持
  • runtime 扩展支持
  • StaticRouter base 支持
  • 默认 html 模板格式有点难看
  • useLayoutEffect 报 warning 
  • 开启 dynamicImport 后的问题
  • 样式闪烁
  • html 结构与客户端不同

React renderServer

  • 与客户端的 location 对象保持一致(因为客户端用的 history-with-query)
  • StaticRouter 路由支持
  • getInitialProps 支持

开发

  • 去掉 /154c3d37774b8f0a99ec.hot-update.json 这种路由
  • devServer 支持渲染
  • 增加 middleware 

客户端 entry

  • 页面级数据预获取, getInitialProps 支持
  • 应用级数据支持
  • hydrate 渲染
  • 页面 hash 变化时,Component 不频繁 mounted component,不频繁执行 getInitialProps
  • 非 div 标签的 dangerouslySetInnerHTML 渲染问题(文档已补充)
  • 开启 dynamicImport 后,切换路由不触发 getInitialProps。

构建

  • 编译后将 index.html 默认注入进 umi.server.js

部署

  • CDN 的 publicPath 如何处理?

插件支持

相关插件:

  • @umijs/plugin-dva 支持 SSR 数据流 feat: support dva ssr plugins#199
  • @umijs/plugin-helmet 支持 SSR 标题、标签渲染  feat: add helmet plugin plugins#180
  • @umijs/plugin-model 验证
  • @umijs/plugin-initial-state 数据流支持
  • @umijs/plugin-locale 国际化支持
  • dumi 默认开启 SSR(dumi 生产出来的应该是高质量组件库,那么应具备组件服务端渲染的能力,同时将一些不规范的组件写法在 dev 时就规范起来)

预渲染

preset-built-int/plugins/prerender.ts

  • 支持构建渲染
  • 动态路由支持

其它

  • 测试用例
  • 文档
  • prefetch 和 preload ,通过独立的插件 prefetch
  • 首次编译后才 copy ip,其它不 copy
  • ANALYZE 与 ANALYZE_SSR 端口冲突
  • 导出 isBrowser

TODO

  • checksum 支持
  • window.g_initialProps 隐藏,通过黑魔法将 window.g_initialProps 脚本注入到 umi.js 中,避免 html 过多 scripts,影响百度 SEO 权重
  • 运行时增强:preload 支持、prefetch 、服务端 babel 优化等

参考


@ycjcl868 ycjcl868 mentioned this issue Apr 20, 2020
4 tasks
@sorrycc
Copy link
Member

sorrycc commented Apr 20, 2020

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

@ClearSeve
Copy link

终于迎来了更新!

@xiexingen
Copy link

关注了好久了

@ycjcl868 ycjcl868 self-assigned this Apr 22, 2020
@williamnie
Copy link
Contributor

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

@ycjcl868
Copy link
Contributor Author

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

@ycjcl868
Copy link
Contributor Author

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

@CyberNika
Copy link

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

@guanweisong
Copy link

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

@ycjcl868
Copy link
Contributor Author

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

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

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

@ycjcl868
Copy link
Contributor Author

include

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

@qingxiao
Copy link

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

  • 路由路径一样,query不一样,比如ssr访问 /search?key=a, 用户操作后,通过前端 route.push({pathname: '/search', query: {key:'b'} )页面跳转到 /search?key=b ,但是window下记录的数据对应的是 '/search' 路径, 导致页面获取数据还是原来 key=a的数据

  • @3.x中server.tpl 里面有如下代码,不知道最后源码是不是这个


  // extend the `params` of getInitialProps(params) function
  const initialPropsParams = modifyGetInitialPropsParams ? await modifyGetInitialPropsParams(defaultInitialProps) : defaultInitialProps;
  pageInitialProps = component?.getInitialProps
    ? await component.getInitialProps(initialPropsParams)
    : null;
  let appInitialData = {};
  if (typeof getInitialData === 'function') {
    const defaultInitialData = {
      isServer: true,
      ...restRouteParams,
    };
    appInitialData = await getInitialData(defaultInitialData);
  }
  return {
    pageInitialProps,
    appInitialData,
  };
  • 好像还是不能解决 [Feature Request] SSR预期是什么时候支持上 #4357 (comment) 这个问题 :-<
    期望就是,有一个runtime函数,能够获取到node-server的req(主要使用cookie,如果是浏览器执行,没有req也不影响),支持异步来获取一些配置,然后能作为pageInitialProps & appInitialData 函数的参数传入

@ycjcl868
Copy link
Contributor Author

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

image

@ycjcl868 ycjcl868 pinned this issue Apr 30, 2020
@xiexingen
Copy link

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

@delonzhou
Copy link

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

@ycjcl868
Copy link
Contributor Author

ycjcl868 commented May 12, 2020

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

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

@xiaohuoni xiaohuoni added the type(ssr) UmiJS SSR label May 13, 2020
@ycjcl868
Copy link
Contributor Author

ycjcl868 commented May 13, 2020

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

@xiexingen
Copy link

getInitialProps

Layout上有getInitialProps么

@ycjcl868
Copy link
Contributor Author

getInitialProps

Layout上有getInitialProps么

有的

@onur-ozkan
Copy link

onur-ozkan commented May 16, 2020

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:

  • redux-persist failed to create sync storage. falling back to noop storage.

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

@ycjcl868
Copy link
Contributor Author

ycjcl868 commented May 17, 2020

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
Copy link
Contributor Author

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

会加在路由上

@ycjcl868 ycjcl868 unpinned this issue May 18, 2020
@ycjcl868
Copy link
Contributor Author

@williamnie
Copy link
Contributor

👍👍👍👍👍👍

@petitspois
Copy link

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
Copy link

briefguo commented Aug 17, 2020

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type(ssr) UmiJS SSR
Projects
None yet
Development

Successfully merging a pull request may close this issue.