问题概述
某工作日,线上某用户向客服专员反馈没法正常访问“查看报价页面”,页面内容没有呈现。客服专员收到反馈后,将问题转交给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);
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。