前言
自转前端后更新频率低了很多,主要还是为了产出高质量的文章进行输出。此篇文章主要讲诉当你使用三方框架中遇到问题该如何定位及解决。
涉及到的框架
踩坑指南
俗话说前人种树后人乘凉,那么踩坑的人恐怕就是在种树的过程。公司今年准备切换传统的开发方式,由开发静态页面切换到云端一体的开放方式,从而提升用户体验(为了追求卓越呀)。
1.踩坑之框架选型
框架选型一定要着重选择经过市场千锤百炼的框架。这样就不用你去种树了。直接拿来主义即可。除非你的试错成本很大。
市场的框架很多,今年的QCon不论是阿里还是美团都推出了自己的云端一体框架,我们选的则是阿里的云端一体(SSR服务端渲染),模版则是基于SSR官方提供的midway-vue-ssr。那么填坑也是来源于此。浏览其ssr的github仓库,框架本身开始涨星也就是今年年初,应该内部开始向外推广。看样子内测是已经通过了。但是并没有经过千锤百炼的打磨,肯定是有些边边角角的问题。这时我们去选用这样的框架需要慎重考虑的。这是拿我们的线上用户来测bug啊。资损了可咋办。
2.踩坑之框架启动方式
本地测试预发生产的启动部署方式要一致。
问题是这样的,产生环境A用户打开页面发现是B用户的数据。但是ssr模式的数据是别人的,csr渲染的数据则是自己的。上线没多久就全量切回到csr模式(这也是框架的一个好处吧,无缝切换渲染模式)。对于这个问题其实是非常严重的。还好是在内部上线后发现的。那么问题又来了为什么开发和测试环境没有发现呢?框架开发、生产环境部署的启动方式上略有不同。这也是我们上线之后才发现问题的原因之一。
3.踩坑之框架反馈及沟通性
使用的框架一定要有个线下沟通反馈群,提issue实效性太差。
其实我的这个问题持续了几个月,刚开始扫码入群进行反馈时,根本没有回复。可能之前他们没有遇到过这样的问题。或者说我表诉的内容有误。其实框架的问题对于使用者来说只能看到表面问题。内部完全是个黑盒。所以想要反馈问题还是得看懂源码,定位问题,然后才能让作者给你解决问题。这也是非常非常坑的原因(吐槽下作者,不要认为我们提bug的都是无病呻吟)
填坑指南
1.填坑之分析问题
- 代码有问题,尝试Review代码
- 使用方式不对,先多看看官方提供的文档
- 通过ssr的方式请求接口,在网关层面我们cookie错乱导致的
- 公司基础框架有问题(开始怀疑人生,因为框架官方文档贴出,阿里内部很多部门都在使用,不敢质疑)
- 框架有问题
1.1 最初问题的解决思路来源于前三条,尝试通过这三个路径定位问题所在,但是日子一天一天的过去,这三条路看了下,并没有发现什么问题,代码层面的Review也都ok,后来以为是我们自己封装得axios有问题,在ssr渲染得时候多次被初始化导致得,尝试切换了官方提供的axios实例,但是发现也是不行,同时根据官方文档把该配置的配置都配置上,也不行。第三条路则没有前两条路这么好排除,一直在尝试,本地debugger,测试打日志,看到当前用户的cookie确实也是自己的。这就让人头疼。
1.2 写了几天业务代码,换了个思路。怀疑是不是我们公司基础框架层的多环境容器有问题。那怎么办呢,不可能在线上环境进行调试吧。因为测试环境和线上环境还是有些区别的,测试环境是只有一台容器运行,但是线上环境有多台容器在并发运行。是不是这个问题导致的。那么最接近生产环境的环境就是预发环境。那就把预发也申请和生产一样多的容器进行场景还原。
2.填坑之复现问题
根据出现问题的状态,可罗列出3种必备条件:
- 多个用户并发访问
- 同线上一致的环境
- 同线上一致的代码
2.1 A/B test
排除不是公司基础架构多容器导致的
在分析问题的第四步中,我们已经把预发环境配置的和线上一致。多个用户同时刷新页面,没几次其实就复现了问题。A用户出现了B用户的数据,那么对于这个结果,对比测试环境,最让人怀疑的是多台容器。是不是这个原因导致的。继续向下调查,每次刷新,A/B用户切换刷新,复现问题后观察用户日志。确实很多次A/B用户都是分发到了不同的容器,但是也有几次出现在同一个容器里。就可以排除不是多容器导致的。
2.2 框架问题
通过排除的方式确认应该是框架问题
那么继续细化新增日志,把用户请求-接口发起-接口响应-存store-取数据渲染串起来。当发生A/B用户数据异常时观察日志,竟然发现这时B用户请求响应的回来的stream里面存在着A用户的store数据。直到这里我们才开始怀疑框架问题。之所以一直没有怀疑框架问题是因为作者认为我们代码有问题,他们并没有收到相关issue。
由于ssr的vue实例router/store都是新创建的实例。不可能出现store复用。那会不会是node服务层导致的,分发流分发错误,导致用户看到了非自己的数据,这时我们又开始怀疑node层。换个node服务试试,把eggjs换成nestjs。但是想想这个不可能吧。这两个库可是在被很多人所认可的。我们继续怀疑是不是ssr问题。
3.填坑之源码阅读
择其善者而从之,其不善者而改之,阅读源码是填坑指南的必经之路,也是提升代码能力的渠道。
只能通过看源码来尝试定位问题,对于看源码肯定很多同学是拒绝的,不知道从何看起。同时又看到有多个module互相关联就很头疼。作者这个就是多个module互相调用,但是撸清逻辑还是很清晰的
源码三剑客:简单过一遍,仔细看一遍,整体撸一遍(串流程不是让你撸代码)
3.1 简单过一遍
这里我用的是midway-vue-ssr模版,基于这个模块进行展开。其他的模块有兴趣的可以自行看看
- cli(主要为了是生成可执行命令ssr start/ssr build)
- core-vue(主要是对外提供render函数)
- plugin-midway(启动midwayjs)
- plugin-vue(这里是主要提供cli进行start/build的具体细节)
- server-utils(给其他module提供工具类)
- types(定义接口ts各种类的type类型)
- webpack(打包编译启动服务)
其实这就是第一步,每个module都过一下。简单的看下做什么的。接下来就是关联module之间的关系,其实你可能已经注意到在plugin-vue 和 server-utils里面被其他module调用了很多次。
3.2 仔细看一遍
Take the essence and discard the dregs 去其糟粕 取其精华
通过第一步已经了解了每个module的主次关系,那么如何取其精华呢?请看下述代码
import { render } from 'ssr-core-vue'
@Get('/')
@Get('/detail/:id')
async handler (): Promise<void> {
try {
this.ctx.apiService = this.apiService
this.ctx.apiDeatilservice = this.apiDeatilservice
const stream = await render<Readable>(this.ctx, {
stream: true
})
this.ctx.body = stream
} catch (error) {
console.log(error)
this.ctx.body = error
}
}
所有的页面请求都是来源于此,通过ssr-core-vue module提供的render函数进行具体页面的渲染。我们继续深入这个render函数。
async function render (ctx: ISSRContext, options?: UserConfig) {
const config = Object.assign({}, defaultConfig, options ?? {})
const { isDev, chunkName, stream } = config
const isLocal = isDev || process.env.NODE_ENV !== 'production'
const serverFile = resolve(cwd, `./build/server/${chunkName}.server.js`)
if (isLocal) {
// clear cache in development environment
delete require.cache[serverFile]
}
if (!ctx.response.type && typeof ctx.response.type !== 'function') {
// midway/koa 场景设置默认 content-type
ctx.response.type = 'text/html;charset=utf-8'
} else if (!(ctx as ExpressContext).response.hasHeader?.('content-type')) {
// express 场景
(ctx as ExpressContext).response.setHeader?.('Content-type', 'text/html;charset=utf-8')
}
const { serverRender } = require(serverFile)
const serverRes = await serverRender(ctx, config)
if (stream) {
const stream = mergeStream2(new StringToStream('<!DOCTYPE html>'), renderToStream(serverRes))
stream.on('error', (e: any) => {
console.log(e)
})
return stream
} else {
return `<!DOCTYPE html>${await renderToString(serverRes)}`
}
}
可以看到它的主要内容就是调用打包后build/server目录下的page.server.js,然后把node层的context和配置传入。最终生导出一个vue实例。通过vue官方提供的vue-server-renderer进行渲染页面。
其实细看到这里后续可能已经无法深入了。这里拿取的是build文件。里面全是压缩后的js。但是源码处调用了这个js的serverRender方法。我们又可以深入了。有印象的同学可能在进行第一步前戏的时候已经注意到了这个。ssr-vue-plugin这个module entry下面的server-entry.ts,这就是我们上方打包后的js里调用的方法。
const serverRender = async (ctx: ISSRContext, config: IConfig): Promise<Vue.Component> => {
const { cssOrder, jsOrder, dynamic, mode, customeHeadScript, customeFooterScript, chunkName, parallelFetch, disableClientRender, prefix } = config
const router = createRouter() // 创建新的router
const store = createStore() // 创建新的store
const base = prefix ?? PrefixRouterBase // 以开发者实际传入的为最高优先级
const viteMode = process.env.BUILD_TOOL === 'vite'
sync(store, router)
let { path, url } = ctx.request
if (base) {
path = normalizePath(path, base)
url = normalizePath(url, base)
}
const routeItem = findRoute<IFeRouteItem>(FeRoutes, path) // 根据request信息获取需要展示的routeitem
if (!routeItem) {
throw new Error(`
查找组件失败,请确认当前 path: ${path} 对应前端组件是否存在
若创建了新的页面文件夹,请重新执行 npm start 重启服务
`)
}
let dynamicCssOrder = cssOrder
if (dynamic && !viteMode) {
dynamicCssOrder = cssOrder.concat([`${routeItem.webpackChunkName}.css`])
dynamicCssOrder = await addAsyncChunk(dynamicCssOrder, routeItem.webpackChunkName)
}
const manifest = viteMode ? {} : await getManifest()
const isCsr = !!(mode === 'csr' || ctx.request.query?.csr)
let layoutFetchData = {}
let fetchData = {}
if (!isCsr) {
const { fetch } = routeItem
const currentFetch = fetch ? (await fetch()).default : null // 通过axios请求fetch.js api内容
router.push(url)
// csr 下不需要服务端获取数据
if (parallelFetch) {
[layoutFetchData, fetchData] = await Promise.all([
layoutFetch ? layoutFetch({ store, router: router.currentRoute }, ctx) : Promise.resolve({}),
currentFetch ? currentFetch({ store, router: router.currentRoute }, ctx) : Promise.resolve({})
])
} else {
layoutFetchData = layoutFetch ? await layoutFetch({ store, router: router.currentRoute }, ctx) : {}
fetchData = currentFetch ? await currentFetch({ store, router: router.currentRoute }, ctx) : {}
}
} else {
logGreen(`Current path ${path} use csr render mode`)
}
const combineAysncData = Object.assign({}, layoutFetchData ?? {}, fetchData ?? {})
const state = Object.assign({}, store.state ?? {}, combineAysncData)
// @ts-expect-error
const app = new Vue({
// 创建vue实例 部分源码这里就省略了
})
return app
}
上诉源码已经添加了部分注解。那么到这里其实可以进行第三步了,整体撸一遍串起整个流程
3.3 整体撸一遍
这也是最关键的一环,你以为你站在终点原来你站在原点。
这里只是个开始,那么我们是如何从ssr start启动服务➡️用户发起页面请求➡️egg调用render函数➡️调用build后的page.server.js
展示用户访问的页面。
那么起点就是ssr start时,上诉第一步简单的代码分析里面已经说到了ssr-cli这个module,他提供了ssr start 和 ssr build 这两个构建命令。通过这里就可以循序渐近的明白整体流程。
yargs
.command('start', 'Start Server', {}, async (argv: Argv) => {
spinner.start() // 控制台loading
await handleEnv(argv, spinner) // 初始化环境变量
const { parseFeRoutes, loadPlugin } = await import('ssr-server-utils') // 获取工具类
await parseFeRoutes() // 获取pages下面的页面路又信息 对应build目录下面的ssr-temporary-routes.js文件
debug(`require ssr-server-utils time: ${Date.now() - start} ms`)
const plugin = loadPlugin() // 读取plugin文件
debug(`loadPlugin time: ${Date.now() - start} ms`)
spinner.stop()
debug(`parseFeRoutes ending time: ${Date.now() - start} ms`)
await plugin.clientPlugin?.start?.(argv) // 启动客户端打包及部署
debug(`clientPlugin ending time: ${Date.now() - start} ms`)
await cleanOutDir()
await plugin.serverPlugin?.start?.(argv) // 启动服务端打包部署
debug(`serverPlugin ending time: ${Date.now() - start} ms`)
})
这里我做了一些简单的注释。可以知道ssr start 背后的逻辑。
其实可以跳过前两步,直接到第三步parseFeRoutes, loadPlugin这里,通过server-utils来获取这两个函数。那么parseFeRoutes到底干了些什么呢?请看源码!!!
const parseFeRoutes = async () => {
const isVue = require(join(cwd, './package.json')).dependencies.vue
const viteMode = process.env.BUILD_TOOL === 'vite'
if (viteMode && !dynamic) {
console.log('vite模式禁止关闭 dynamic ')
return
}
let routes = ''
const declaretiveRoutes = await accessFile(join(getFeDir(), './route.ts')) // 是否存在自定义路由
if (!declaretiveRoutes) {
// 根据目录结构生成前端路由表
const pathRecord = [''] // 路径记录
// @ts-expect-error
const route: ParseFeRouteItem = {}
let arr = await renderRoutes(pageDir, pathRecord, route) // 递归页面目录生产路由数组
if (routerPriority) {
// 路由优先级排序
......
}
if (routerOptimize) {
// 路由过滤
......
}
debug('Before the result that parse web folder to routes is: ', arr)
if (isVue) {
const layoutPath = '@/components/layout/index.vue'
const accessVueApp = await accessFile(join(getFeDir(), './components/layout/App.vue'))
const layoutFetch = await accessFile(join(getFeDir(), './components/layout/fetch.ts'))
const store = await accessFile(join(getFeDir(), './store/index.ts'))
const AppPath = `@/components/layout/App.${accessVueApp ? 'vue' : 'tsx'}`
// 处理路由表信息数据结构
....
} else {
// React 场景
......
}
} else {
// 使用了声明式路由
routes = (await fs.readFile(join(getFeDir(), './route.ts'))).toString()
}
debug('After the result that parse web folder to routes is: ', routes)
await writeRoutes(routes)
然后写入/build/ssr-temporary-routes.js下
}
可以看出最终目的就是为了根据目录生成路由表信息结构
我们继续,看看生成路由表后还要干什么!解析来就是加载plugin,他是通过读取我们项目结构中的plugin.js来分别做打包部署工作,先对客户端进行打包启动webpack-serve,然后启动midway服务
const { midwayPlugin } = require('ssr-plugin-midway')
const { vuePlugin } = require('ssr-plugin-vue')
module.exports = {
serverPlugin: midwayPlugin(),
clientPlugin: vuePlugin()
}
接下来先来看midwayPlugin到底在做什么,然后再看vuePlugin。
import { exec } from 'child_process'
import { loadConfig } from 'ssr-server-utils'
import { Argv } from 'ssr-types'
const { cliFun } = require('@midwayjs/cli/bin/cli')
const start = (argv: Argv) => {
const config = loadConfig()
exec('npx cross-env ets', async (err, stdout) => {
if (err) {
console.log(err)
return
}
console.log(stdout)
// 透传参数给 midway-bin
argv._[0] = 'dev'
argv.ts = true
argv.port = config.serverPort
await cliFun(argv)
})
}
export {
start
}
这里可以看到还是比较简单的,读取服务端配置然后调用midwaysjs导出的cliFun进行启动。
接下来是重点,vuePlugin start是在做什么。
export function vuePlugin () {
return {
name: 'plugin-vue',
start: async () => {
// 本地开发的时候要做细致的依赖分离, Vite 场景不需要去加载 Webpack 构建客户端应用所需的模块
const { startServerBuild } = await import('ssr-webpack/cjs/server') // 1.打包ssr server端
const { getServerWebpack } = await import('./config/server') // 2.获取ssr server端cofing
const serverConfigChain = new WebpackChain() // 3.生成一个默认的webpackChain
if (process.env.BUILD_TOOL === 'vite') {
await startServerBuild(getServerWebpack(serverConfigChain))
} else {
const { startClientServer } = await import('ssr-webpack') // 4.打包ssr client 同时启动 webpack-dev-server
const { getClientWebpack } = await import('./config') // 5.获取ssr client端配置
const clientConfigChain = new WebpackChain() // 6.生成一个默认的webpackChain
await Promise.all([startServerBuild(getServerWebpack(serverConfigChain)), startClientServer(getClientWebpack(clientConfigChain))])
}
},
// build 方法
.....
}
}
这里我只贴出了 start的相关内容,在上方也添加了对应代码的相关注释。其实打包只需要把打包配置传入webpack即可。剩下就交给webpack了,对于如何打包,就跳过了。只要知道如何获取配置对应的webpack config就行了。
const startClientServer = async (webpackConfig: webpack.Configuration): Promise<void> => {
const { webpackDevServerConfig, fePort, host } = config
return await new Promise((resolve) => {
const compiler = webpack(webpackConfig)
const server = new WebpackDevServer(compiler, webpackDevServerConfig)
compiler.hooks.done.tap('DonePlugin', () => {
resolve()
})
server.listen(fePort, host)
})
}
可以看到startClientServer源码中 仅仅做打包并启动webpack-server
const startServerBuild = async (webpackConfig: webpack.Configuration) => {
const { webpackStatsOption } = loadConfig()
const stats = await webpackPromisify(webpackConfig)
console.log(stats.toString(webpackStatsOption))
}
startServerBuild源码也是仅仅打包ssr server 端
这里就先跳过如何打包,直接进入关键步骤步骤如何获取配置?
通过源码可以知道在plugin-vue/config module中的目录下有着serve端和 client端相关webpack配置内容,我们可以点开server.ts看下
const getServerWebpack = (chain: WebpackChain) => {
const config = loadConfig() // 获取根目录下config.js配置
const { isDev, cwd, getOutput, chainServerConfig, whiteList, chunkName } = config
getBaseConfig(chain, true) 合并预置基础config配置
chain.devtool(isDev ? 'inline-source-map' : false)
chain.target('node')
chain.entry(chunkName)
.add(loadModule('../entry/server-entry')) //加载entry
.end()
.output
.path(getOutput().serverOutPut)
.filename('[name].server.js')
.libraryTarget('commonjs')
.end()
// 其他配置
.......
chainServerConfig(chain) // 合并用户自定义配置
return chain.toConfig()
}
源码中可以看到,加载了预置webpack配置的同时配置server端的entry。然后返回webpackconfig。具体如何获取webpack的webpackconfig,大家可以细读源码,这里最重要的是entry这个地方,说到entry是不是回到了阅读源码的第二步。这也就串起了整个流程。
读到这里的同学相信已经串起了所有ssr start背后所做的一切。
4.填坑之解决问题
源码告一段落。可以通过读到的源码内容,定位到问题所在了。那么A用户是为何拿到了B用户的数据呢?大家知道了吗!
4.1 定位问题
同样通过还原线上场景通过egg-script进行启动部署服务,把日志打印在plugin-vue/entry/serverRender函数下的每个store后面。结果显然已经出来了,store在node运行中并没有每次初始化,第二个用户登陆后,拿到了第一个用户store的值。这里我们已经确认了是框架问题。
[图片上传失败...(image-26fc62-1636619769150)]
打包成docker镜像,运行起来如上图看到,刚开始console里面打印的log都是null,但是另一个用户进来之后,console里出现了值。
4.2 解决问题
通过上诉已经得到是store复用导致的,(这时把这个bug提上去作者才认是框架问题,吐槽下都到这里那我们自己也可以解决了啊)
框架群友也有反馈是多进程导致的,store是个全局的变量,并发导致store变量污染所致(之前写java的所以不太理解为啥子多进场能共享变量,不应该是进程之前数据隔离,线程之前数据共享吗?就没有纠结难道是node可以这样操作)最终作者的意思是没有对store进行一次deepclone导致的。在createStore的时候进行一次deepclone。确实解决了问题。
虽然提供了这个解决方式,但是到底为什么导致了ssr start和egg-script start 有所不同呢,看代码ssr-core-vue 中render也是可以看到区别的在dev环境进行了 delete require.cache
async function render (ctx: ISSRContext, options?: UserConfig) {
const config = Object.assign({}, defaultConfig, options ?? {})
const { isDev, chunkName, stream } = config
const isLocal = isDev || process.env.NODE_ENV !== 'production'
const serverFile = resolve(cwd, `./build/server/${chunkName}.server.js`)
if (isLocal) { // 就是这里这里这里导致 开发环境和生产环境不一致导致,我们线上发现问题的
// clear cache in development environment
delete require.cache[serverFile]
}
.....省略的代码......
const { serverRender } = require(serverFile)
const serverRes = await serverRender(ctx, config)
.....省略的代码......
}
在node环境中require第一次加载某个模块时, Node会缓存该模块, 后续加载就从缓存中获取。所以导致B用户会取到A用户的值。作者这种方式修改也是ok的,原因是每次用户拿到的store都是一个deepclone下来的,不会操作原始的store,所以每次clone的都是最初的store
那么归结下来大概有三种解决思路:
4.2.1 第一种方式就是放开delete require.cache 这个操作,但是这个也是最low的解决方式
async function render (ctx: ISSRContext, options?: UserConfig) {
const config = Object.assign({}, defaultConfig, options ?? {})
const { isDev, chunkName, stream } = config
const isLocal = isDev || process.env.NODE_ENV !== 'production'
const serverFile = resolve(cwd, `./build/server/${chunkName}.server.js`)
if (isLocal) {
// clear cache in development environment
delete require.cache[serverFile] //放开这一行
}
if (!ctx.response.type && typeof ctx.response.type !== 'function') {
// midway/koa 场景设置默认 content-type
ctx.response.type = 'text/html;charset=utf-8'
} else if (!(ctx as ExpressContext).response.hasHeader?.('content-type')) {
// express 场景
(ctx as ExpressContext).response.setHeader?.('Content-type', 'text/html;charset=utf-8')
}
const { serverRender } = require(serverFile)
const serverRes = await serverRender(ctx, config)
if (stream) {
const stream = mergeStream2(new StringToStream('<!DOCTYPE html>'), renderToStream(serverRes))
stream.on('error', (e: any) => {
console.log(e)
})
return stream
} else {
return `<!DOCTYPE html>${await renderToString(serverRes)}`
}
}
4.2.2 第二种方式其实就是作者的这种操作,通过deepclone来每次创建一个新的,防止原始store被操作
4.2.3 第三种这个也是我自己想出的一个方法,结合第一种来修改的,每次require store之前进行一次delete也是可以操作的。
function createStore () {
delete require.cache[require.resolve("@/store/index.ts")]
const store = require("@/store/index.ts")
return new Vuex.Store(store ?? {})
}
总结
希望对xdm有所帮助。文章如果有不对的地方,望留言区纠正。毕竟是个半路子入门的前端。
网友评论