代码都甩在 Github 上面了,欢迎随手 star 😁javascript
踩坑的过程大概都在 TypeScript + Webpack + Koa 搭建 React 服务端渲染 这篇文章里面css
踩坑的 DEMO 放在 customize-server-side-renderhtml
我对服务端渲染有着深深的执念,后来再次基础上写了第一个版本的服务端渲染库 it-ssr
,可是为了区分是否服务端,是否客户端,是否生产环境,抽取了太多 config 文件,在服务端渲染上也加了不少不合理的逻辑,it-ssr
重写了五六次java
在开发时须要开启两个 HTTP 服务:node
一、相似 webpack-dev-server
的静态资源文件服务,用来提供客户端使用的静态资源文件react
二、开发时主要访问这个服务,接受客户端的 HTTP
请求,并将 jsx
代码渲染成 HTML
字符串的服务。webpack
在渲染 HTML
的时候,动态加入打包生成的静态 js 文件git
而后最简单渲染大概就能跑得起来,可是,要作一个 library 的话,其余开发者怎么使用这个库,入口在哪里?怎么区分
server
和client
?这个问题当时踩了不少坑github
client
和 server
都提供一个同名的 render
方法,接受同样的参数web
在 webpack
配置下面的 resolve -> alias
区分不一样环境导出不一样的文件
const config = {
resolve: {
alias: {
'server-renderer': isServer
? 'server-renderer/lib/server.js'
: 'server-renderer/lib/client.js',
}
}
}
复制代码
配置文件和开发等核心代码都会利用 TypeScript
开编写
一、配置文件、开发服务等 ts 代码会利用 taskr
将 ts 转 js
二、库的核心代码会利用 rollup
进行打包
三、使用这个库的业务代码代码,使用 webpack
进行打包
配置文件和开发服务的代码一样能够利用 rollup
,
core
下面放置核心的代码文件sevrer.tsx
导出使用的服务端渲染逻辑
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.props
return (
<App> <Component /> </App>
)
}
}
复制代码
而后由于入口在库这边,因此 ReactDOM.hydrate(<App />, container)
这一步是由咱们去完成的,所以还须要一个 container
ReactDOM.hydrate(<App />, document.querySelector(container)) 复制代码
因此可传入的配置项预设为
export interface Route {
name: string
path: string
component: React.ComponentType<any>
}
export type AppComponentType = React.ComponentType<AppProps>
export type AppProps<T = {}> = T &{
Component: React.ComponentType<any>
}
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 = (
<App Component={matchedRoute.component} /> ) hydrate(app, document.querySelector(opts.container)) } 复制代码
这样子的话,一个粗糙的 client.tsx
就差很少了
在这里并无判断 App 是否为 Fragment 和 matchedRoute 为 null 的状况
服务端作的事就会比客户端多一些,在开发的时候大概须要之后流程
接受页面的请求,根据请求的地址匹配路由
利用 ReactDOM/server
将 jsx
渲染成 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(
<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
在 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
样式打包的配置在踩坑里面写过了(customize-server-side-render)
或者查看具体文件 server-renderer/scripts/webpack-config.ts
开发的逻辑放在 scripts/dev.ts
中
有了 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',
})
复制代码
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 childProcess
serverCompiler.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' })
})
复制代码
开发和核心的代码大概写了差很少了,而后就是怎么调试,让咱们这个库跑起来
利用 taskr
将 scripts
下面的脚本,都打包到 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…
若是有错误或者能够优化的地方,请指正