博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
用 TypeScript 编写一个 React 服务端渲染库(1)
阅读量:7211 次
发布时间:2019-06-29

本文共 11238 字,大约阅读时间需要 37 分钟。

前言

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

踩坑的过程大概都在 这篇文章里面

踩坑的 DEMO 放在

我对服务端渲染有着深深的执念,后来再次基础上写了第一个版本的服务端渲染库 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 (      
{this.props.children}
) }}复制代码

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

export interface AppProps {  Component: React.ComponentType
}class App extends React.Component
{ public render() { const { Component } = this.props return (
) }}复制代码

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

ReactDOM.hydrate(
, document.querySelector(container))复制代码

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

export interface Route {  name: string  path: string  component: React.ComponentType
}export type AppComponentType = React.ComponentType
export type AppProps
= T &{ Component: React.ComponentType
}export interface RenderOptions { container: string routes: 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.Fragment  const { pathname } = window.location  // 假设一定匹配到,没有 404  const matchedRoute = opts.routes.find(({ path }) => path2regexp(path).test(pathname))  const app = (    
) 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 // container  private readonly originalHTML: string // src/index.html 读取的原始 HTML  private readonly App: ServerRenderer.AppComponentType  private readonly routes: ServerRenderer.Route[]    constructor(opts: ServerRenderer.RenderOptions) {  }    // 启动开发服务  public start() {}    // 处理请求  private handleRequest() {}    // 渲染成 HTML  private 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.container    this.App = opts.App || React.Fragment    this.routes = opts.routes    // 这里要区分是否开发环境,    // 开发环境取模板来拼接 HTML    // 生产环境直接去编译后的 HTML 文件,因为生产环境的文件名可能会有 hash 值等会导致 clientChunkPath 错误    // 而且生产环境没有 webpack-dev-server,拼接的 clientChunkPath 会错误    const htmlPath = isDev ? config.htmlTemplatePath : config.htmlPath    this.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.serverPort      router.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.App        const routes = this.routes        const matchedRoute = // find matched route        const content = renderToString(          
) // 拼接脚本等让 renderHTML 去做 ctx.body = this.renderHTML(content) }}复制代码

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

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

import * as cheerio from 'cheerio'class Server {  private renderHTML(content: string) {    // decodeEntities 会转译汉字,还有文本的       `)    }    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 path  serverPublicPath: string // 服务端静态文件 public path  clientChunkName: 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?: boolean  isServer?: boolean}复制代码

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

import * as path from 'path'import * as webpack from 'webpack'import { getConfig } from './config'export interface GenerateWebpackOpts {  rootDirectory: string  isDev?: boolean  isServer?: boolean}export function genWebpackConfig(opts: GenerateWebpackOpts) {  const { isDev = false, isServer = false } = opts  const 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 样式打包的配置在踩坑里面写过了()

或者查看具体文件

开发的 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 提供了这些钩子

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

这里我用的是 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-ignore  childProcess = 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,具体可以参考

问题

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

转载地址:http://mkwym.baihongyu.com/

你可能感兴趣的文章
自定义报表是这样实现的
查看>>
How to Convert Dynamic Disk to Basic Disk without Losing Data?
查看>>
Tomcat的安装
查看>>
Script:ASM修复脚本,寻找LISTHEAD和Kfed源数据
查看>>
ubuntu开机进入字符界面方法
查看>>
硬盘常见故障
查看>>
python 自动下载网站相关附件
查看>>
centos 6.5安装视频解码器
查看>>
Freeradius, 执行 radtest, 出现错误
查看>>
Android启动出现白屏的解决办法(theme)
查看>>
设计模式之单例设计模式
查看>>
LVS DR模型详解
查看>>
linux 源码安装Rabbitmq
查看>>
python 练习-登录接口
查看>>
pt-heartbeat 监测RDS延迟
查看>>
使用IDEA导入工程时无反映的问题处理
查看>>
python selenium爬取kuku漫画
查看>>
关闭“一致性网络设备命名法” 修改centos网卡名称为 eth0
查看>>
VTP 导致的网络故障
查看>>
puppet自动化运维之package资源
查看>>