共计 11469 个字符,预计需要花费 29 分钟才能阅读完成。
问题概述
某工作日,线上某用户向客服专员反馈没法失常拜访“查看报价页面”,页面内容没有出现。客服专员收到反馈后,将问题转交给 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);
}
理论我的项目测试