内容简介:代码都甩在踩坑的过程大概都在踩坑的 DEMO 放在
代码都甩在 Github 上面了,欢迎随手 star :grin:
踩坑的过程大概都在 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 的话,其他开发者怎么使用这个库,入口在哪里?怎么区分 server
和 client
?这个问题当时踩了很多坑
-
client
和server
都提供一个同名的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
下面放置核心的代码文件
sevrer.tsx client.tsc
-
config
下面放置打包 library 代码的rollup
配置文件 -
script
放置webpack
配置文件和打包业务代码开启的开发服务等
:package:server-renderer ┣ :open_file_folder:config ┃ ┣ :scroll:rollup.client.js ┃ ┗ :scroll:rollup.server.js ┣ :open_file_folder:core ┃ ┣ :scroll:client.tsx ┃ ┗ :scroll:server.tsx ┣ :open_file_folder:scripts ┃ ┣ :scroll:dev.ts ┃ ┣ :scroll:build.ts ┃ ┗ :scroll: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
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
样式打包的配置在踩坑里面写过了( 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 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' }) }) 复制代码
开发和核心的代码大概写了差不多了,然后就是怎么调试,让我们这个库跑起来
打包 scripts 下面的脚本
利用 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…
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- Octane渲染入门-渲染设置图文版
- 通过分析 WPF 的渲染脏区优化渲染性能
- React 服务器端渲染和客户端渲染效果对比
- iOS渲染-将视频原始数据(RGB,YUV)渲染到屏幕上
- 通过共享内存优化 Flutter 外接纹理的渲染性能,实时渲染不是梦
- 列表渲染 wx:key 的作用、条件渲染 wx:if 与 hidden 的区别
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。