問題概述
某工作日,線上某使用者向客服專員反饋沒法正常訪問“檢視報價頁面”,頁面內容沒有呈現。客服專員收到反饋後,將問題轉交給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);
}