问题概述
某工作日,线上某用户向客服专员反馈没法失常拜访“查看报价页面”,页面内容没有出现。客服专员收到反馈后,将问题转交给SRE
解决。很奇怪的是,SRE
拜访生产环境“查看报价页面”显示失常,为了进一步剖析定位问题,SRE
向用户申请了近程操作,将将一些具备价值的信息记录下来,次要有以下两个方面:
用户拜访“查看报价页面”存在款式和字体文件没有加载胜利;
- 没有加载胜利的字体和款式文件的申请域名并不是公司的,而是公网收费的域名(
at.alicdn.com、g.alicdn.com
);
剖析与定位
通过上述信息,能够晓得用户与SRE
拜访页面的差别,SRE
拜访“查看报价页面”能够失常获取所有资源,而用户无奈获取局部字体和款式文件。依据浏览器加载渲染原理,局部字体和款式加载失败大概率不会导致页面DOM
无奈出现,无奈下结论之时,无妨先假如字体和款式文件影响到了DOM
渲染。
当无奈从表象剖析出线上问题起因时,第一步须要在开发环境或者测试环境复现问题场景,而后排查从申请资源到页面渲染的执行过程。
问题的引入点:域名解析
在复现场景之前,须要先晓得拜访胜利和失败之间的差别。通过收集到的信息来看,申请域名解析的IP
有显著不同:
- 失常拜访资源,
DNS
域名解析
Request URL | Remote Address |
---|---|
https://at.alicdn.com/t/font_1353866_klyxwbettba.css | 121.31.31.251:443 |
https://g.alicdn.com/de/prismplayer/2.9.21/skins/default/aliplayer-min.css | 119.96.90.252:443 |
https://at.alicdn.com/t/font_2296011_yhl1znqn0gp.woff2 | 121.31.31.251:443 |
https://at.alicdn.com/t/font_1353866_klyxwbettba.woff2?t=1639626666505 | 121.31.31.251:443 |
生产环境申请资源失败,
DNS
域名解析at.alicdn.com
:116.153.65.231
g.alicdn.com
:211.91.241.230
用户和SRE
所处地区不同,拜访资源时域名解析命中的边缘节点服务也会不同,而at.alicdn.com
与g.alicdn.com
是公网收费的CDN
域名,某些边缘节点服务稳定性不够,拉取不到资源也是可能产生的。
问题根本原因:模块加载
开发环境与测试环境复现差别
批改本地hosts
,增加用户域名解析的地址映射,在测试环境和开发环境尝试复现。两个环境均不能获取到字体和款式文件,测试环境(https://ec-hwbeta.casstime.com
)页面内容没有出现(复现胜利),开发环境页面内容失常出现(复现失败),剖析开始陷入胡同。
开发环境:
测试环境:
这时候就要开始剖析了,两个环境复现问题的差别点在哪里?
不难发现,两个环境最次要的区别在于yarn start
与yarn build
的区别,也就是构建配置的区别。
开发环境
1、create-react-app
要害构建配置
- 启用
style-loader
,默认通过style
标签将款式注入到html
中; - 不启用
MiniCssExtractPlugin.loader
拆散款式和OptimizeCSSAssetsPlugin
压缩款式; - 启用
optimization.splitChunks
代码宰割; - 启用
optimization.runtimeChunk
抽离webpack
运行时代码;
const getStyleLoaders = (cssOptions, preProcessor) => { const loaders = [ isEnvDevelopment && require.resolve('style-loader') isEnvProduction && { loader: MiniCssExtractPlugin.loader, // css is located in `static/css`, use '../../' to locate index.html folder // in production `paths.publicUrlOrPath` can be a relative path options: paths.publicUrlOrPath.startsWith('.') ? { publicPath: '../../' } : {}, }, ].filter(Boolean); return loaders;}module: { rules: [ { oneof: [ { test: cssModuleRegex, use: getStyleLoaders({ importLoaders: 1, sourceMap: isEnvProduction && shouldUseSourceMap, modules: { getLocalIdent: getCSSModuleLocalIdent, }, }), }, { test: sassModuleRegex, use: getStyleLoaders( { importLoaders: 3, sourceMap: isEnvProduction && shouldUseSourceMap, modules: { getLocalIdent: getCSSModuleLocalIdent, }, }, 'sass-loader' ), }, ] } ]} optimization: { minimize: isEnvProduction, minimizer: [ // 压缩css new OptimizeCSSAssetsPlugin({ cssProcessorOptions: { parser: safePostCssParser, map: shouldUseSourceMap ? { // `inline: false` forces the sourcemap to be output into a // separate file inline: false, // `annotation: true` appends the sourceMappingURL to the end of // the css file, helping the browser find the sourcemap annotation: true, } : false, }, }) ], // Automatically split vendor and commons // https://twitter.com/wSokra/status/969633336732905474 splitChunks: { chunks: 'all', name: false, }, // Keep the runtime chunk separated to enable long term caching runtimeChunk: { name: entrypoint => `runtime-${entrypoint.name}`, }, }
css-loader
在解析样式表中@import
和url()
过程中,如果index.module.scss
中应用@import
引入第三方款式库aliplayer-min.css
,@import aliplayer-min.css
局部和index.module.scss
中其余部分将会被拆散成两个module
,而后别离追加到款式数组中,数组中的每个”款式项“将被style-loader
解决应用style
标签注入到html
中
2、执行链路
开发环境的构建配置根本分明,再来看看执行流程。执行yarn start
启用本地服务,localhost:3000
拜访“查看报价页面”。首先会通过匹配路由,而后react-loadable
调用webpack runtime
中加载chunk
的函数__webpack_require__.e
,该函数会依据入参chunkId
应用基于promise
实现的script
申请对应chunk
,返回Promise<pending>
。如果Promise.all()
存在一个Promise<pending>
转变成Promise<rejected>
,那么Promise.all
的执行后果就是Promise<rejected>
。因为css chunk
是通过style
标签注入到html
中,所以__webpack_require__.e
只须要加载js chunk
,当所有的js chunk
都申请胜利时,Promise.all
的执行后果就是Promise<fulfilled>
,fulfilled
状态会被react-loadable
中的then
捕捉,更新组件外部状态值,触发从新渲染,执行render
函数返回jsx element
对象。因而,内容区域失常显示。
生产环境
1、create-react-app
要害构建配置
- 不启用
style-loader
,默认动态创建link
标签注入款式; - 启用了
MiniCssExtractPlugin.loader
拆散款式; - 启用
optimization.splitChunks
代码宰割; - 为了更好的利用浏览器强缓存,设置
optimization.runtimeChunk
,拆散webpack runtime
;
const getStyleLoaders = (cssOptions, preProcessor) => { const loaders = [ isEnvDevelopment && require.resolve('style-loader') isEnvProduction && { loader: MiniCssExtractPlugin.loader, // css is located in `static/css`, use '../../' to locate index.html folder // in production `paths.publicUrlOrPath` can be a relative path options: paths.publicUrlOrPath.startsWith('.') ? { publicPath: '../../' } : {}, }, ].filter(Boolean); return loaders;}module: { rules: [ { oneof: [ { test: cssModuleRegex, use: getStyleLoaders({ importLoaders: 1, sourceMap: isEnvProduction && shouldUseSourceMap, modules: { getLocalIdent: getCSSModuleLocalIdent, }, }), }, { test: sassModuleRegex, use: getStyleLoaders( { importLoaders: 3, sourceMap: isEnvProduction && shouldUseSourceMap, modules: { getLocalIdent: getCSSModuleLocalIdent, }, }, 'sass-loader' ), }, ] } ]}optimization: { minimize: isEnvProduction, minimizer: [], // Automatically split vendor and commons // https://twitter.com/wSokra/status/969633336732905474 splitChunks: { chunks: 'all', name: false, }, // Keep the runtime chunk separated to enable long term caching runtimeChunk: { name: entrypoint => `runtime-${entrypoint.name}`, },},plugins: [ // Generates an `index.html` file with the <script> injected. new HtmlWebpackPlugin( Object.assign( {}, { inject: true, template: paths.appHtml, }, isEnvProduction ? { minify: { removeComments: true, collapseWhitespace: true, removeRedundantAttributes: true, useShortDoctype: true, removeEmptyAttributes: true, removeStyleLinkTypeAttributes: true, keepClosingSlash: true, minifyJS: true, minifyCSS: true, minifyURLs: true, }, } : undefined ) ), // Inlines the webpack runtime script. This script is too small to warrant // a network request. // https://github.com/facebook/create-react-app/issues/5358 isEnvProduction && shouldInlineRuntimeChunk && // 将运行时代码内联注入到html中 new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),]
设置optimization.runtimeChunk
,将webpack runtime
(运行时代码,治理chunk
依赖关系和加载)独自打包进去,这样就不会因为某个chunk
的变更导致依赖该chunk
的chunk
也变更(文件名hash
扭转),从而导致浏览器缓存生效。
因为启用了MiniCssExtractPlugin.loader
拆散款式,@import "aliplayer-min.css"
将被拆散到一个css chunk
中,所以aliplayer-min.css
申请链有三级
)
2、执行链路
在剖析执行链路之前,先将生产环境构建配置中的代码压缩性能正文掉,不便浏览和调试源代码
optimization: { minimize: false, // 改成false,禁用压缩 minimizer: [], // Automatically split vendor and commons // https://twitter.com/wSokra/status/969633336732905474 splitChunks: { chunks: 'all', name: false, }, // Keep the runtime chunk separated to enable long term caching runtimeChunk: { name: entrypoint => `runtime-${entrypoint.name}`, },},plugins: [ // Generates an `index.html` file with the <script> injected. new HtmlWebpackPlugin( Object.assign( {}, { inject: true, template: paths.appHtml, }, isEnvProduction ? { minify: { removeComments: true, // collapseWhitespace: true, // removeRedundantAttributes: true, // useShortDoctype: true, // removeEmptyAttributes: true, // removeStyleLinkTypeAttributes: true, // keepClosingSlash: true, // minifyJS: true, // 不压缩注入到html中的js // minifyCSS: true, // 不压缩注入到html中的css // minifyURLs: true, }, } : undefined ) ),]
执行yarn build
,失去构建产物,在build
目录下启用服务http-server -p 3000
。为了跨域拜访测试环境服务,本地装置nginx
配置反向代理,localhost:4444
端口拜访“查看报价页面”即可在本地拜访,跟测试环境一样。
server { listen 4444; server_name localhost; location /maindata { proxy_pass https://ec-hwbeta.casstime.com; } location /market { proxy_pass https://ec-hwbeta.casstime.com; } location /agentBuy { proxy_pass https://ec-hwbeta.casstime.com; } location /mall { proxy_pass https://ec-hwbeta.casstime.com; } location /inquiryWeb { proxy_pass https://ec-hwbeta.casstime.com; } location /cart { proxy_pass https://ec-hwbeta.casstime.com; } location /msg { proxy_pass https://ec-hwbeta.casstime.com; } location /webim { proxy_pass https://ec-hwbeta.casstime.com; } location /pointshop { proxy_pass https://ec-hwbeta.casstime.com; } location /partycredit { proxy_pass https://ec-hwbeta.casstime.com; } location / { proxy_pass http://127.0.0.1:3000; }}
当用户拜访“查看报价页面”时,首先通过匹配路由,而后react-loadable
调用webpack
运行时加载chunk
的函数__webpack_require__.e
,该函数会依据入参chunkId
应用基于promise
实现的link
和script
申请对应chunk
,返回Promise<pending>
。如果Promise.all()
中存在一个Promise<pending>
转变成Promise<rejected>
,那么Promise.all
的执行后果就是Promise<rejected>
。因为其中有一个蕴含@import "aliplayer-min.css"
的css chunk
申请失败了,所以Promise.all
的执行后果就是Promise<rejected>
,rejected
状态会被react-loadable
中的catch
捕捉,更新组件外部状态值,触发从新渲染,执行render
函数返回null
。因而,内容区域显示空白。
注:应用link
加载css chunk
,如果css chunk
中@import url()
申请失败,那么会触发$link.onerror
回调函数
起因
至此,问题的根本原因曾经明了了。因为生产环境构建将css
和js
拆分成一个个chunk
,运行时函数在依据chunkId
加载资源时,其中存在一个含@import "aliplayer-min.css"
的css chunk
加载失败,导致整个Promise.all
执行后果为Promise<rejected>
,以致react-loadable
高阶组件中catch
捕捉到rejected
后,更新state
,从新渲染,执行render
函数返回null
,页面内容显示空白。
解决方案
在解决该问题之前,须要先摸清楚问题批改的范畴有多大,毕竟援用alicdn
动态资源的工程可能不止一个。在gitlab
全局搜寻发现,波及工程有十几个。如果每一个援用的链接手动去改,很容易改漏,因而我筹备写一个命令行工具,敲一个命令就能够搞定全副链接替换。
初始化命令行我的项目
创立一个构造,如下所示:
+ kennel-cli + cmds + dowmload-alicdn.js - index.js
而后,在根文件夹中初始化:
$ npm init -y # This will create a package.json file
配置bin
关上你的package.json
并定义将在可执行文件和终点文件上应用的名称:
"bin": { "kennel-cli": "index.js"},
而后,应用以下命令通知 npm
这index.js
是一个 Node.js
可执行文件 #!/usr/bin/env node
(必须指定执行环境,不然执行会报错):
#!/usr/bin/env node'use strict'// The rest of the code will be here...console.log("Hello world!")
调试应用程序
咱们能够对 NPM
说,您以后开发的应用程序是一个全局应用程序,因而咱们能够在咱们的文件系统中的任何中央测试它:
$ npm link # Inside the root of your project
而后,您曾经能够从计算机上的任何门路执行您的应用程序:
$ kennel-cli # Should print "Hello world" on your screen
加载所有命令
批改index.js
文件,应用yargs.commandDir
函数加载此文件夹中的每个命令(上面的示例)。
#!/usr/bin/env node"use strict";const { join } = require("path");require("yargs") .usage("Usage: $0 <command> [options]") .commandDir(join(__dirname, "cmds")) .demandCommand(1) .example("kennel-cli download-alicdn") .help() .alias("h", "help").argv; // 最初肯定要.argv,不然命令执行不会有任何反馈
实现一个命令
在文件夹 cmds
中的一个文件中指定了一个命令。它须要导出一些命令配置。例如:
const { join } = require("path");const fs = require("fs");exports.command = "download-alicdn";exports.desc = "将引入的阿里云动态资源文件下载到本地我的项目";exports.builder = {};exports.handler = (argv) => { // 执行命令的回调 downloadAlicdn();};/** * @description 读取public/index.html * @returns */function readHtml() { // 不能应用__dirname,因为__dirname示意以后执行文件所在的目录,如果在某工程执行该命令,__dirname指的就是download-alicdn.js寄存的目录 const htmlURL = join(process.cwd(), "public/index.html"); // 同步读取,本地读取会很快 return fs.readFileSync(htmlURL).toString();}/** * @description 替换alicdn动态资源 * @param {*} source */async function replaceAlicdn(source) { // node-fetch@3是ESM标准的库,不能应用require,因而这儿应用import()动静引入 const fetch = (...args) => import("node-fetch").then(({ default: fetch }) => fetch(...args)); const reg = /(https|http):\/\/(at|g).alicdn.com\/.*\/(.*\.css|.*\.js)/; const fontReg = /\/\/(at|g).alicdn.com\/.*\/(.*\.woff2|.*\.woff|.*\.ttf)/; const fontDir = join(process.cwd(), "public/fonts"); const staticDir = (suffix) => join(process.cwd(), `public/${suffix}`); let regRet = source.match(reg); while (regRet) { const [assetURL, , , file] = regRet; // 申请资源 let content = await fetch(assetURL).then((res) => res.text()); let fontRet = content.match(fontReg); while (fontRet) { const [curl, , cfile] = fontRet; // @font-face { // font-family: "cassmall"; /* Project id 1353866 */ // src: url('//at.alicdn.com/t/font_1353866_klyxwbettba.woff2?t=1639626666505') format('woff2'), // url('//at.alicdn.com/t/font_1353866_klyxwbettba.woff?t=1639626666505') format('woff'), // url('//at.alicdn.com/t/font_1353866_klyxwbettba.ttf?t=1639626666505') format('truetype'); // } const childContent = await fetch("https:" + curl).then((res) => res.text() ); if (fs.existsSync(fontDir)) { fs.writeFileSync(join(fontDir, cfile), childContent); } else { fs.mkdirSync(fontDir); fs.writeFileSync(join(fontDir, cfile), childContent); } content = content.replace(fontReg, "../fonts/" + cfile); fontRet = content.match(fontReg); } const suffix = file.split(".")[1]; const dir = staticDir(suffix); if (fs.existsSync(dir)) { fs.writeFileSync(join(dir, file), content); } else { fs.mkdirSync(dir); fs.writeFileSync(join(dir, file), content); } source = source.replace(reg, `./${suffix}/${file}`); regRet = source.match(reg); } fs.writeFileSync(join(process.cwd(), "public/index.html"), source);}async function downloadAlicdn() { // 1、获取public/index.html模板字符串 // 2、正则匹配alicdn动态资源链接,并获取链接内容写入到本地,援用链接替换老本地引入 // 3、如果alicdn css资源外部还有引入alicdn的资源,也须要下载替换引入链接 // https://at.alicdn.com/t/font_1353866_klyxwbettba.css // https://g.alicdn.com/de/prismplayer/2.9.21/skins/default/aliplayer-min.css const retHtml = readHtml(); await replaceAlicdn(retHtml);}