前言

代码都甩在 Github 上面了,欢迎随手 star ?

踩坑的过程大概都在 TypeScript + Webpack + Koa 搭建 React 服务端渲染 这篇文章里面

踩坑的 DEMO 放在 customize-server-side-render

我对服务端渲染有着深深的执念,后来再次基础上写了第一个版本的服务端渲染库 it-ssr,但是为了区分是否服务端,是否客户端,是否生产环境,抽取了太多 config 文件,在服务端渲染上也加了很多不合理的逻辑,it-ssr 重写了五六次

思路

  • 在开发时需要开启两个 HTTP 服务:

    1、类似 webpack-dev-server 的静态资源文件服务,用来提供客户端使用的静态资源文件

    2、开发时主要访问这个服务,接受客户端的 HTTP 请求,并将 jsx 代码渲染成 HTML 字符串的服务。

  • 在渲染 HTML 的时候,动态加入打包生成的静态 js 文件

然后最简单渲染大概就能跑得起来,但是,要做一个 library 的话,其他开发者怎么使用这个库,入口在哪里?怎么区分 serverclient?这个问题当时踩了很多坑

  • clientserver 都提供一个同名的 render 方法,接受一样的参数

  • webpack 配置下面的 resolve -> alias 区分不同环境导出不同的文件

 const config = {resolve: {alias: {'server-renderer': isServer? 'server-renderer/lib/server.js' : 'server-renderer/lib/client.js',}}}
复制代码

开发

配置文件和开发等核心代码都会利用 TypeScript 开编写

1、配置文件、开发服务等 ts 代码会利用 taskr 将 ts 转 js

2、库的核心代码会利用 rollup 进行打包

3、使用这个库的业务代码代码,使用 webpack 进行打包

配置文件和开发服务的代码同样可以利用 rollup

目录结构

  • core 下面放置核心的代码文件
  1. sevrer.tsx 导出使用的服务端渲染逻辑

  2. client.tsc 导出使用的客户端渲染逻辑

  • config 下面放置打包 library 代码的 rollup 配置文件
  • script 放置 webpack 配置文件和打包业务代码开启的开发服务等
?server-renderer┣ ?config┃ ┣ ?rollup.client.js┃ ┗ ?rollup.server.js┣ ?core┃ ┣ ?client.tsx┃ ┗ ?server.tsx┣ ?scripts ┃ ┣ ?dev.ts┃ ┣ ?build.ts┃ ┗ ?start.ts
复制代码

核心代码编写

在编写库的时候,将 react 和 react-dom 作为 peerDependencies 安装

(本来觉得可以写完的,后面发现太多了,路由同构、切换和数据注水脱水等只能下次再写一篇了...)

我们的目标是希望使用者只传入一个 routes 配置就可以跑得起来,形如下面

import { render } from 'server-renderer'const routes = [{path: '/',component: YourComponent,}
]render(routes)
复制代码

但是使用者可能希望,外层包裹一层自己的组件

class App extends React.Component {public render() {return (<App>{this.props.children}</App>      )}
}
复制代码

但是直接把匹配到的路由组件传给 App 并不太方便,踩了很多坑以后采用 next 的设计方式

export interface AppProps {Component: React.ComponentType<any>
}class App extends React.Component<AppProps> {public render() {const { Component } = this.propsreturn (<App><Component /></App>      )}
}
复制代码

然后因为入口在库这边,所以 ReactDOM.hydrate(<App />, container) 这一步是由我们去完成的,因此还需要一个 container

ReactDOM.hydrate(<App />, document.querySelector(container))
复制代码

所以可传入的配置项预设为

export interface Route {name: stringpath: stringcomponent: React.ComponentType<any>
}export type AppComponentType = React.ComponentType<AppProps>export type AppProps<T = {}> = T &{Component: React.ComponentType<any>
}
export interface RenderOptions {container: stringroutes: Route[]App?: AppComponentType
}
复制代码

客户端

确定了参数,就可以写个大概了,客户端是最简单的,所以从 client.tsx 开始

import * as React from 'react'
import { hydrate } from 'react-dom'
import path2regexp from 'path-to-regexp'export function render(opts: RenderOptions) {const App = opts.App || React.Fragmentconst { pathname } = window.location// 假设一定匹配到,没有 404const matchedRoute = opts.routes.find(({ path }) => path2regexp(path).test(pathname))const app = (<App Component={matchedRoute.component} />)hydrate(app, document.querySelector(opts.container))
}
复制代码

这样子的话,一个粗糙的 client.tsx 就差不多了

在这里并没有判断 App 是否为 Fragment 和 matchedRoute 为 null 的情况

服务端

服务端做的事就会比客户端多一些,在开发的时候大概需要以后流程

  • 接受页面的请求,根据请求的地址匹配路由

  • 利用 ReactDOM/serverjsx 渲染成 HTML 字符串

  • 读取 HTML 模板(指的是:src/index.html),将上一步生成的字符串追加到模板中

  • 取得客户端静态资源的路径,动态添加 script 脚本

  • 返回给浏览器

所以可以大概确定这个结构

class Server {private readonly clientChunkPath: URL // 开发时客户端的脚本地址private readonly container: string // containerprivate readonly originalHTML: string // src/index.html 读取的原始 HTMLprivate readonly App: ServerRenderer.AppComponentTypeprivate readonly routes: ServerRenderer.Route[]constructor(opts: ServerRenderer.RenderOptions) {}// 启动开发服务public start() {}// 处理请求private handleRequest() {}// 渲染成 HTMLprivate renderHTML() {}
}export function render(opts: ServerRenderer.RenderOptions) {const server = new Server(opts)server.start()
}
复制代码

在构造函数里面将 App 和 routes 等参数保存下来,然后确定一下脚本路径,HTML 模板字符串等

import { readFileSync } from 'fs'const config = getConfig()
const isDev = process.env.NODE_ENV === 'development'class Server {constructor(opts: ServerRenderer.RenderOptions) {// 根据配置拼接this.clientChunkPath = new URL(config.clientChunkName,`http://localhost:${config.webpackServerPort}${config.clientPublicPath}`)this.container = opts.containerthis.App = opts.App || React.Fragmentthis.routes = opts.routes// 这里要区分是否开发环境,// 开发环境取模板来拼接 HTML// 生产环境直接去编译后的 HTML 文件,因为生产环境的文件名可能会有 hash 值等会导致 clientChunkPath 错误// 而且生产环境没有 webpack-dev-server,拼接的 clientChunkPath 会错误const htmlPath = isDev ? config.htmlTemplatePath : config.htmlPaththis.originalHTML = readFileSync(htmlPath, 'utf-8')}
}
复制代码

然后 start 方法比较简单,就是启动 koa 服务,并让所有的请求让 handleRequest 处理

import * as Koa from 'koa'
import * as KoaRouter from 'koa-router'class Server {public start() {const app = new Koa()const router = new KoaRouter()const port = config.serverPortrouter.get('*', this.handleRequest.bind(this))app.use(router.routes())app.listen(port, () => {console.log('Server listen on: http://localhost:' + port)})}
}
复制代码

接着就是核心的 handleRequest 了,不过我们还是先写个简陋版本的

import { renderToString } from 'react-dom/server'class Server {private handleRequest(ctx: Koa.ParameterizedContext) {const App = this.Appconst routes = this.routesconst matchedRoute = // find matched routeconst content = renderToString(<App Component={matchedRoute.component} />)// 拼接脚本等让 renderHTML 去做ctx.body = this.renderHTML(content)}
}
复制代码

renderHTML 因为需要找到 container 节点,并在开发时动态添加 script

这时我们安装 cheerio 这个库,他提供了 jQuery 那样的方法操作 HTML 字符串

import * as cheerio from 'cheerio'class Server {private renderHTML(content: string) {// decodeEntities 会转译汉字,还有文本的 <script> 等关键词,对防止 XSS 有一定作用const $ = cheerio.load(this.originalHTML, { decodeEntities: true })$(this.container).html(content)if (isDev) {$('body').append(`<script type='text/javascript' src='${this.clientChunkPath}'></script>`)}return $.html()}
}
复制代码

然后服务端方面也写的差不多

但是不管在客户端或者服务端,都没有路由切换的逻辑

开发时的逻辑

在开发时需要在改变时自动打包,这个可以利用 webpack(config).watch 来完成,也可以直接利用 webpack-dev-middleware

Webpack 配置

scripts 下面新建一个 webpack-config.ts 文件,用来导出 Webpack 配置

  • webpack 打包时会有输出路径,文件名等一些配置,为了方便维护,或者后期开放出给用户自定义,这里在新建一个 config.ts 文件,可以预设这个配置导出的数据
export interface Configuration {webpackServerPort: number // 开发服务监听的端口serverPort: number // 渲染服务监听的端口clientPublicPath: string // 客户端静态文件 public pathserverPublicPath: string // 服务端静态文件 public pathclientChunkName: string // 客户端打包生成的文件名serverChunkName: string // 服务端打包生成的文件名htmlTemplatePath: string // HTML 模板路径buildDirectory: string // 服务端打包输出路径staticDirectory: string // 客户端打包输出路径htmlPath: string // HTML 打包后的路径srcDirectory: string // 业务代码文件夹customConfigFile: string // 自定义配置的文件名(项目根目录)
}
复制代码

在这里导出一个或者上述配置的方法

import { join } from 'path'// 项目根目录
const rootDirectory = process.cwd()export function getConfig(): Configuration {const staticDirName = 'static'const buildDirName = 'build'const srcDirectory = join(rootDirectory, 'src')return {clientChunkName: 'app.js',serverChunkName: 'server.js',webpackServerPort: 8080,serverPort: 3030,clientPublicPath: '/static/',serverPublicPath: '/',htmlTemplatePath: join(srcDirectory, 'index.html'),htmlPath: join(rootDirectory, staticDirName, 'client.html'),buildDirectory: join(rootDirectory, buildDirName),staticDirectory: join(rootDirectory, staticDirName),srcDirectory,customConfigFile: join(rootDirectory, 'server-renderer.config.js'),}
}
复制代码
  • 导出 webpack 配置

webpack 配置需要区分是否服务端和是否生产环境,所以定义一个方法,接受以下参数

export interface GenerateWebpackOpts {isDev?: booleanisServer?: boolean
}
复制代码

然后利用传入的参数导出不同的 webpack 配置

import * as path from 'path'
import * as webpack from 'webpack'
import { getConfig } from './config'export interface GenerateWebpackOpts {rootDirectory: stringisDev?: booleanisServer?: boolean
}export function genWebpackConfig(opts: GenerateWebpackOpts) {const { isDev = false, isServer = false } = optsconst config = getConfig()// 区分不同环境导出不同的配置const webpackConfig: webpack.Configuration = {mode: isDev ? 'development' : 'production',target: isServer ? 'node' : 'web',entry: path.resolve(config.srcDirectory, 'index.tsx'),output: {path: isServer ? config.buildDirectory : config.staticDirectory,publicPath: isServer ? config.serverPublicPath : config.clientPublicPath,filename: isServer ? config.serverChunkName : config.clientChunkName,libraryTarget: isServer ? 'commonjs2' : 'umd',},}if (!isServer) {webpackConfig.node = {dgram: 'empty',fs: 'empty',net: 'empty',tls: 'empty',child_process: 'empty',}}return webpackConfig
}复制代码

其他的 typescript 配置和 css 样式打包的配置在踩坑里面写过了(customize-server-side-render)

或者查看具体文件 server-renderer/scripts/webpack-config.ts

开发的 HTTP 服务

开发的逻辑放在 scripts/dev.ts

有了 webpack 配置就可以编写一个静态资源的开发服务器了

  • 生成 webpack 配置
import { genWebpackConfig } from './webpack-config'const rootDirectory = process.cwd()
const clientDevConfig = genWebpackConfig({ rootDirectory, isDev: true, isServer: false,
})
复制代码
  • 安装 webpack-dev-middleware,然后生成一个 HTTP 服务的中间件
$ yarn add webpack-dev-middleware
复制代码
const clientCompiler = webpack(clientDevConfig)const clientDevMiddleware = WebpackDevMiddleware(clientCompiler, {publicPath: clientDevConfig.output.publicPath,writeToDisk: false,logLevel: 'silent',
})
复制代码
  • 启动 HTTP 服务
const app = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {clientDevMiddleware(req, res, () => {res.end()})
})app.listen(getConfig().webpackServerPort, () => {console.clear()console.log(chalk.green(`正在启动开发服务...`))
})
复制代码

上面做的事基本就是一个 webpack-dev-server

渲染开发服务的开发

渲染开发服务同样需要监听文件的变化,然后进行重新打包并重启

重新打包利用 webpack-dev-middleware 或者 webpack(config).watch 都可以

用同样的方式生成一个服务端的中间件

const rootDirectory = process.cwd()
const serverDevConfig = genWebpackConfig({ rootDirectory, isDev: true, isServer: true,
})
const serverCompiler = webpack(serverDevConfig)
const serverDevMiddleware = WebpackDevMiddleware(serverCompiler, {publicPath: serverDevConfig.output.publicPath,writeToDisk: true, // 和客户端不同,这里需要写到硬盘,因为我们需要用到它logLevel: 'silent',
})
复制代码

不过这里生成的 serverDevMiddleware 并没有什么用,然后就是服务的重启了

我们需要在每次打包成功后重启服务,正好 webpack 提供了这些钩子 webpack.docschina.org/api/compile…

然后就是打包后如何运行打包后的文件,重启如何杀死上一个服务,重新开启新的服务

这里我用的是 node 的 child_process/fork,当然还有很多其他的方法

import * as webpack from 'webpack'
import { fork } from 'child_process'
import { join } from 'path'
import chalk from 'chalk'let childProcessserverCompiler.hooks.done.tap('server-compile-done', (stats: webpack.Stats) => {if (childProcess) {childProcess.kill()console.clear()console.log(chalk.green('正在重启开发服务...'))}// webpack 打包后的资源信息const assets = stats.toJson().assetsByChunkName// 拼接成完整的路径const chunkPath = join(serverDevConfig.output.path, assets.main)// @ts-ignorechildProcess = fork(chunkPath, {}, { stdio: 'inherit' })
})
复制代码

开发和核心的代码大概写了差不多了,然后就是怎么调试,让我们这个库跑起来

打包 scripts 下面的脚本

利用 taskrscripts 下面的脚本,都打包到 lib/scripts 下面

打包 typescript 需要 @taskr/typescript

$ yarn add taskr @taskr/typescript -D
复制代码

在项目根目录创建 taskfile.js 文件

// 引入 tsconfig 文件
const config = require('./tsconfig.json')exports.scripts = function* (task) {yield task.source('scripts/**.ts').typescript(config).target('lib/scripts')
}exports.default = function* (task) {yield task.start('scripts')
}
复制代码

然后运行 taskr 即可

调试

新建文件夹,编写代码,利用 yarn link server-renderer 在本地调试

server-renderer
$ yarn link
$ cd demo
$
demo
$ yarn link server-renderer
$ node ./node_modules/server-renderer/lib/scripts/dev.js
复制代码

写了一个运用 server-renderer 的 DEMO,具体可以参考 github.com/wokeyi/musi…

问题

如果有错误或者可以优化的地方,请指正

转载于:https://juejin.im/post/5cb84aaef265da03904c110e

用 TypeScript 编写一个 React 服务端渲染库(1)相关推荐

  1. 手把手带你用next搭建一个完善的react服务端渲染项目(集成antd、redux、样式解决方案)

    前言 本文参考了慕课网jokcy老师的React16.8+Next.js+Koa2开发Github全栈项目,也算是做个笔记吧. 源码地址 github.com/sl1673495/n- 介绍 Next ...

  2. 一个较复杂的React服务端渲染示例(React-Koa-Redux等)

    好久没写博客了~前段时间一直在忙着一个项目上线,最近终于完事了~有一段清闲,正好研究研究React的服务端渲染: 其实React服务端渲染就是用Node.js的v8引擎,在Node端执行JS代码,把R ...

  3. React服务端渲染实现(基于Dva)

    React服务端渲染实现 (基于Dva) 功能 基于 Dva 的 SSR 解决方案 (react-router-v4, redux, redux-saga) 支持 Dynamic Import (不再 ...

  4. React服务端渲染(SSR)入门及项目搭建

    代码已经关联到github: 链接地址 文章有更新也会优先在这,觉得不错可以顺手点个star,这里会持续分享自己的开发经验(: 前言 服务端渲染是什么?我们什么情况下需要使用它?想要了解这些,需要简单 ...

  5. ssr Android简书,react服务端渲染ssr

    Next.js 一个轻量级的 React 服务端渲染框架 1 概念 SPA single page application : 单页面应用程序 缺点:首屏加载慢,不利于SEO SSR Server-s ...

  6. 美少女秃头思考:react服务端渲染

    富婆来报道,今天想问题想不出来,随手抓了一下头发,没想到啊没想到,我那浓(mei)密(sheng)茂(ji)盛(gen)的秀发又少了好几根,一定要改掉这个想不出来问题就揪头发的坏习惯.你们遇到问题想不 ...

  7. React 服务端渲染 umi服务端渲染

    react 服务端渲染原理不复杂,其中最核心的内容就是同构. node server 接收客户端请求,得到当前的req url path,然后在已有的路由表内查找到对应的组件,拿到需要请求的数据,将数 ...

  8. next.js+react+typescript+antd+antd-mobile+axios+redux+sass react服务端渲染构建项目,从构建到发布,兼容pc+移动端

    简介:该教程兼容pc+移动端,如只需一端,可忽略兼容部分教程,根据需要运行的客户端构建项目 antd官网:https://ant.design/components/overview-cn/ antd ...

  9. React 服务端渲染方案完美的解决方案

    最近在开发一个服务端渲染工具,通过一篇小文大致介绍下服务端渲染,和服务端渲染的方式方法.在此文后面有两中服务端渲染方式的构思,根据你对服务端渲染的利弊权衡,你会选择哪一种服务端渲染方式呢? 什么是服务 ...

最新文章

  1. redis(3)redis的基础入门(java)
  2. 导出excel--多个sheet
  3. 猴子吃桃问题 python
  4. BufferedInputStream
  5. UVa712 S-Trees满二叉树
  6. Android之Launcher分析和修改5——HotSeat分析
  7. 德勤预判:2022技术七大趋势
  8. 零基础带你学习MySQL—加密函数和系统函数(十六)
  9. 【Spring】Spring的AOP术语解释
  10. mask rcnn算法分析_注意力模型RPN(faster-rcnn)与APN(RA-CNN)对比精析
  11. JavaWeb案例(MVC+MySQL+分页功能)+前后端分离
  12. Python时区设置与pytz的应用
  13. java排序之选择排序
  14. xp系统一直跳出宽带连接服务器,xp系统一直显示正在获取网络地址的操作方案...
  15. 怎么查看内网ip?如何分辨IP是公网IP还是内网IP?
  16. Android爬虫(一)使用OkHttp+Jsoup实现网络爬虫
  17. 浅谈Thumbnails压缩gif图片质量的实现方式
  18. Oauth2 数据库表说明
  19. ctfshow Nodejs
  20. 微信小程序图片转换成文字_微信小程序中用canvas将文字转成图片,文字自动换行...

热门文章

  1. Spam(垃圾邮件)
  2. 掌握 ASP.NET 之路:自定义实体类简介
  3. java杀死自身并重启_java – android服务在应用程序被杀死时自动重启
  4. linux7开放3306端口,CentOS 7 开放3306端口访问
  5. oracle 删掉虚拟目录,创建虚拟目录失败,必须为服务器名称指定“localhost”
  6. python123数字形式转换_python基本数据类型的使用、转换----数字(有待完善)
  7. C语言playsoundw函数,使用inline hook实现修改PC微信通知铃声-哥哥微信来了
  8. mysql实现树状查询_MySQL实现树状所有子节点查询的方法
  9. android文字广告的循环滚动,android怎样写一个循环文字滚动的TextView
  10. 有关计算机组装的书,计算机组装实习报告书.doc