前言
因为自己以前就搭建了自己的博客系统,那时候博客系统前端基本上都是基于vue
的,而现在用的react
偏多,于是用react
对整个博客系统进行了一次重构,还有对以前存在的很多问题进行了更改与优化。系统都进行了服务端渲染SSR
的处理。
本项目完整的代码:GitHub 仓库
本文篇幅较长,会从以下几个方面进行展开介绍:
核心技术栈
React 17.x
(React 全家桶)Typescript 4.x
Koa 2.x
Webpack 5.x
Babel 7.x
Mongodb
(数据库)eslint
+stylelint
+prettier
(进行代码格式控制)husky
+lint-staged
+commitizen
+commitlint
(进行 git 提交的代码格式校验跟 commit 流程校验)
核心大概就是以上的一些技术栈,然后基于博客的各种需求进行功能开发。像例如授权用到的jsonwebtoken
,@loadable
,log4js
模块等等一些功能,我会下面各个功能模块展开篇幅进行讲解。
目录结构详解
|-- blog-source
|-- .babelrc.js // babel配置文件
|-- .commitlintrc.js // git commit格式校验文件,commit格式不通过,禁止commit
|-- .cz-config.js // cz-customizable的配置文件。我采用的cz-customizable来做的commit规范,自己自定义的一套
|-- .eslintignore // eslint忽略配置
|-- .eslintrc.js // eslint配置文件
|-- .gitignore // git忽略配置
|-- .npmrc // npm配置文件
|-- .postcssrc.js // 添加css样式前缀之类的东西
|-- .prettierrc.js // 格式代码用的,统一风格
|-- .sentryclirc // 项目监控Sentry
|-- .stylelintignore // style忽略配置
|-- .stylelintrc.js // stylelint配置文件
|-- package.json
|-- tsconfig.base.json // ts配置文件
|-- tsconfig.json // ts配置文件
|-- tsconfig.server.json // ts配置文件
|-- build // Webpack构建目录, 分别给client端,admin端,server端进行区别构建
| |-- paths.ts
| |-- utils.ts
| |-- config
| | |-- dev.ts
| | |-- index.ts
| | |-- prod.ts
| |-- webpack
| |-- admin.base.ts
| |-- admin.dev.ts
| |-- admin.prod.ts
| |-- base.ts
| |-- client.base.ts
| |-- client.dev.ts
| |-- client.prod.ts
| |-- index.ts
| |-- loaders.ts
| |-- plugins.ts
| |-- server.base.ts
| |-- server.dev.ts
| |-- server.prod.ts
|-- dist // 打包output目录
|-- logs // 日志打印目录
|-- private // 静态资源入口目录,设置了多个
| |-- third-party-login.html
|-- publice // 静态资源入口目录,设置了多个
|-- scripts // 项目执行脚本,包括启动,打包等等
| |-- build.ts
| |-- config.ts
| |-- dev.ts
| |-- start.ts
| |-- utils.ts
| |-- plugins
| |-- open-browser.ts
| |-- webpack-dev.ts
| |-- webpack-hot.ts
|-- src // 核心源码
| |-- client // 客户端代码
| | |-- main.tsx // 入口文件
| | |-- tsconfig.json // ts配置
| | |-- api // api接口
| | |-- app // 入口组件
| | |-- appComponents // 业务组件
| | |-- assets // 静态资源
| | |-- components // 公共组件
| | |-- config // 客户端配置文件
| | |-- contexts // context, 就是用useContext创建的,用来组件共享状态的
| | |-- global // 全局进入client需要进行调用的方法。像类似window上的方法
| | |-- hooks // react hooks
| | |-- pages // 页面
| | |-- router // 路由
| | |-- store // Store目录
| | |-- styles // 样式文件
| | |-- theme // 样式主题文件,做换肤效果的
| | |-- types // ts类型文件
| | |-- utils // 工具类方法
| |-- admin // 后台管理端代码,同客户端差不太多
| | |-- .babelrc.js
| | |-- app.tsx
| | |-- main.tsx
| | |-- tsconfig.json
| | |-- api
| | |-- appComponents
| | |-- assets
| | |-- components
| | |-- config
| | |-- hooks
| | |-- pages
| | |-- router
| | |-- store
| | |-- styles
| | |-- types
| | |-- utils
| |-- models // 接口模型
| |-- server // 服务端代码
| | |-- main.ts // 入口文件
| | |-- config // 配置文件
| | |-- controllers // 控制器
| | |-- database // 数据库
| | |-- decorators // 装饰器,封装了@Get,@Post,@Put,@Delete,@Cookie之类的
| | |-- middleware // 中间件
| | |-- models // mongodb模型
| | |-- router // 路由、接口
| | |-- ssl // https证书,目前我是本地开发用的,线上如果用nginx的话,在nginx处配置就行
| | |-- ssr // 页面SSR处理
| | |-- timer // 定时器
| | |-- utils // 工具类方法
| |-- shared // 多端共享的代码
| | |-- loadInitData.ts
| | |-- type.ts
| | |-- config
| | |-- utils
| |-- types // ts类型文件
|-- static // 静态资源
|-- template // html模板
以上就是项目大概的文件目录,上面已经描述了文件的基本作用,下面我会详细博客功能的实现过程。目前博客系统各端没有拆分出来,接下里会有这个打算。
项目环境启动
确保你的node
版本在10.13.0 (LTS)
以上,因为Webpack 5
对 Node.js
的版本要求至少是 10.13.0 (LTS)
执行脚本,启动项目
首先从入口文件开始:
"dev": "cross-env NODE_ENV=development TS_NODE_PROJECT=tsconfig.server.json ts-node --files scripts/start.ts"
"prod": "cross-env NODE_ENV=production TS_NODE_PROJECT=tsconfig.server.json ts-node --files scripts/start.ts"
1. 执行入口文件scripts/start.js
// scripts/start.js
import path from 'path'
import moduleAlias from 'module-alias'
moduleAlias.addAliases({
'@root': path.resolve(__dirname, '../'),
'@server': path.resolve(__dirname, '../src/server'),
'@client': path.resolve(__dirname, '../src/client'),
'@admin': path.resolve(__dirname, '../src/admin'),
})
if (process.env.NODE_ENV === 'production') {
require('./build')
} else {
require('./dev')
}
设置路径别名,因为目前各端没有拆分,所以建立别名(alias)
好查找文件。
2. 由入口文件进入开发development环境的搭建
首先导出webpack
各端的各自环境的配置文件。
// dev.ts
import clientDev from './client.dev'
import adminDev from './admin.dev'
import serverDev from './server.dev'
import clientProd from './client.prod'
import adminProd from './admin.prod'
import serverProd from './server.prod'
import webpack from 'webpack'
export type Configuration = webpack.Configuration & {
output: {
path: string
}
name: string
entry: any
}
export default (NODE_ENV: ENV): [Configuration, Configuration, Configuration] => {
if (NODE_ENV === 'development') {
return [clientDev as Configuration, serverDev as Configuration, adminDev as Configuration]
}
return [clientProd as Configuration, serverProd as Configuration, adminProd as Configuration]
}
webpack
的配置文件,基本不会有太大的区别,目前就贴一段简单的webpack
配置,分别有 server,client,admin 不同环境的配置文件。具体可以看博客源码
import webpack from 'webpack'
import merge from 'webpack-merge'
import {
clientPlugins } from './plugins' // plugins配置
import {
clientLoader } from './loaders' // loaders配置
import paths from '../paths'
import config from '../config'
import createBaseConfig from './base' // 多端默认配置
const baseClientConfig: webpack.Configuration = merge(createBaseConfig(), {
mode: config.NODE_ENV,
context: paths.rootPath,
name: 'client',
target: ['web', 'es5'],
entry: {
main: paths.clientEntryPath,
},
resolve: {
extensions: ['.js', '.json', '.ts', '.tsx'],
alias: {
'@': paths.clientPath,
'@client': paths.clientPath,
'@root': paths.rootPath,
'@server': paths.serverPath,
},
},
output: {
path: paths.buildClientPath,
publicPath: paths.publicPath,
},
module: {
rules: [...clientLoader],
},
plugins: [...clientPlugins],
})
export default baseClientConfig
然后分别来处理admin
和client
和server
端的webpack
配置文件
以上几个点需要注意:
admin
端跟client
端分别开了一个服务处理webpack的文件,都打包在内存中。client
端需要注意打包出来文件的引用路径,因为是SSR
,需要在服务端获取文件直接渲染,我把服务端跟客户端打在不同的两个服务,所以在服务端引用client
端文件的时候需要注意引用路径。server
端代码直接打包在dist
文件下,用于启动,并没有打在内存中。
const WEBPACK_URL = `${
__WEBPACK_HOST__}:${
__WEBPACK_PORT__}`
const [clientWebpackConfig, serverWebpackConfig, adminWebpackConfig] = getConfig(process.env.NODE_ENV as ENV)
// 构建client 跟 server
const start = async () => {
// 因为client指向的另一个服务,所以重写publicPath路径,不然会404
clientWebpackConfig.output.publicPath = serverWebpackConfig.output.publicPath = `${
WEBPACK_URL}${
clientWebpackConfig.output.publicPath}`
clientWebpackConfig.entry.main = [`webpack-hot-middleware/client?path=${
WEBPACK_URL}/__webpack_hmr`, clientWebpackConfig.entry.main]
const multiCompiler = webpack([clientWebpackConfig, serverWebpackConfig])
const compilers = multiCompiler.compilers
const clientCompiler = compilers.find((compiler) => compiler.name === 'client') as webpack.Compiler
const serverCompiler = compilers.find((compiler) => compiler.name === 'server') as webpack.Compiler
// 通过compiler.hooks用来监听Compiler编译情况
const clientCompilerPromise = setCompilerTip(clientCompiler, clientWebpackConfig.name)
const serverCompilerPromise = setCompilerTip(serverCompiler, serverWebpackConfig.name)
// 用于创建服务的方法,在此创建client端的服务,至此,client端的代码便打入这个服务中, 可以通过像 https://192.168.0.47:3012/js/lib.js 访问文件
createService({
webpackConfig: clientWebpackConfig,
compiler: clientCompiler,
port: __WEBPACK_PORT__
})
let script: any = null
// 重启
const nodemonRestart = () => {
if (script) {
script.restart()
}
}
// 监听server文件更改
serverCompiler.watch({
ignored: /node_modules/ }, (err, stats) => {
nodemonRestart()
if (err) {
throw err
}
// ...
})
try {
// 等待编译完成
await clientCompilerPromise
await serverCompilerPromise
// 这是admin编译情况,admin端的编译情况差不太多,基本也是运行`webpack(config)`进行编译,通过`createService`生成一个服务用来访问打包的代码。
await startAdmin()
closeCompiler(clientCompiler)
closeCompiler(serverCompiler)
logMsg(`Build time ${
new Date().getTime() - startTime}`)
} catch (err) {
logMsg(err, 'error')
}
// 启动server端编译出来的入口文件来启动项目服务
script = nodemon({
script: path.join(serverWebpackConfig.output.path, 'entry.js')
})
}
start()
createService
方法用来生成服务, 代码大概如下
export const createService = ({
webpackConfig, compiler}: {
webpackConfig: Configurationcompiler: Compiler}) => {
const app = new Koa()
...
const dev = webpackDevMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath as string,
stats: webpackConfig.stats
})
app.use(dev)
app.use(webpackHotMiddleware(compiler))
http.createServer(app.callback()).listen(port, cb)
return app
}
开发(development
)环境下的webpack
编译情况的大体逻辑就是这样,里面会有些webpack-dev-middle
这些中间件在koa中的处理等,这里我只提供了大体思路,可以具体细看源码。
3. 生成环境production环境的搭建
对于生成环境的下搭建,处理就比较少了,直接通过webpack
打包就行
webpack([clientWebpackConfig, serverWebpackConfig, adminWebpackConfig], (err, stats) => {
spinner.stop()
if (err) {
throw err
}
// ...
})
然后启动打包出来的入口文件 cross-env NODE_ENV=production node dist/server/entry.js
这块主要就是webpack
的配置,这些配置文件可以直接点击这里进行查看
Server端源码解析
由上面的配置webpack配置延伸到他们的入口文件
// client入口
const clientPath = utils.resolve('src/client')
const clientEntryPath = path.join(clientPath, 'main.tsx')
// server入口
const serverPath = utils.resolve('src/server')
const serverEntryPath = path.join(serverPath, 'main.ts')
- client端的入口是
/src/client/main.tsx
- server端的入口是
/src/server/main.ts
因为项目用到了SSR
,我们从server端
来进行逐步分析。
1. /src/server/main.ts入口文件
import Koa from 'koa'
...
const app = new Koa()
/*
中间件:
sendMidddleware: 对ctx.body的封装
etagMiddleware:设置etag做缓存 可以参考koa-etag,我做了下简单修改,
conditionalMiddleware: 判断缓存是否是否生效,通过ctx.fresh来判断就好,koa内部已经封装好了
loggerMiddleware: 用来打印日志
authTokenMiddleware: 权限拦截,这是admin端对api做的拦截处理
routerErrorMiddleware:这是对api进行的错误处理
koa-static: 对于静态文件的处理,设置max-age让文件强缓,配置etag或Last-Modified给资源设置强缓跟协商缓存
...
*/
middleware(app)
/*
对api进行管理
*/
router(app)
/*
启动数据库,搭建SSR配置
*/
Promise.all([startMongodb(), SSR(app)])
.then(() => {
// 开启服务
https.createServer(serverConfig.httpsOptions, app.callback()).listen(rootConfig.app.server.httpsPort, '0.0.0.0')
})
.catch((err) => {
process.exit()
})
2.中间件的处理
对于中间件主要就讲一讲日志处理中间件loggerMiddleware
和权限中间件authTokenMiddleware
,别的中间件没有太多东西,就不浪费篇幅介绍了。
日志打印主要用到了log4js
这个库,然后基于这个库做的上层封装,通过不同类型的Logger来创建不同的日志文件。
封装了所有请求的日志打印,api的日志打印,一些第三方的调用的日志打印
1. loggerMiddleware的实现
// log.ts
const createLogger = (options = {
} as LogOptions): Logger => {
// 配置项
const opts = {
...serverConfig.log,
...options
}
// 配置文件
log4js.configure({
appenders: {
// stout可以用于开发环境,直接打印出来
stdout: {
type: 'stdout'
},
// 用multiFile类型,通过变量生成不同的文件,我试了别的几种type。感觉都没这种方便
multi: {
type: 'multiFile', base: opts.dir, property: 'dir', extension: '.log' }
},
categories: {
default: {
appenders: ['stdout'], level: 'off' },
http: {
appenders: ['multi'], level: opts.logLevel },
api: {
appenders: ['multi'], level: opts.logLevel },
external: {
appenders: ['multi'], level: opts.logLevel }
}
})
const create = (appender: string) => {
const methods: LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'mark']
const context = {
} as LoggerContext
const logger = log4js.getLogger(appender)
// 重写log4js方法,生成变量,用来生成不同的文件
methods.forEach((method) => {
context[method] = (message: string) => {
logger.addContext('dir', `/${
appender}/${
method}/${
dayjs().format('YYYY-MM-DD')}`)
logger[method](message)
}
})
return context
}
return {
http: create('http'),
api: create('api'),
external: create('external')
}
}
export default createLogger
// loggerMiddleware
import createLogger, {
LogOptions } from '@server/utils/log'
// 所有请求打印
const loggerMiddleware = (options = {
} as LogOptions) => {
const logger = createLogger(options)
return async (ctx: Koa.Context, next: Next) => {
const start = Date.now()
ctx.log = logger
try {
await next()
const end = Date.now() - start
// 正常请求日志打印
logger.http.info(
logInfo(ctx, {
responseTime: `${
end}ms`
})
)
} catch (e) {
const message = ErrorUtils.getErrorMsg(e)
const end = Date.now() - start
// 错误请求日志打印
logger