问题概述

某工作日,线上某用户向客服专员反馈没法失常拜访“查看报价页面”,页面内容没有出现。客服专员收到反馈后,将问题转交给SRE解决。很奇怪的是,SRE拜访生产环境“查看报价页面”显示失常,为了进一步剖析定位问题,SRE向用户申请了近程操作,将将一些具备价值的信息记录下来,次要有以下两个方面:

  • 用户拜访“查看报价页面”存在款式和字体文件没有加载胜利;

  • 没有加载胜利的字体和款式文件的申请域名并不是公司的,而是公网收费的域名(at.alicdn.com、g.alicdn.com);

剖析与定位

通过上述信息,能够晓得用户与SRE拜访页面的差别,SRE拜访“查看报价页面”能够失常获取所有资源,而用户无奈获取局部字体和款式文件。依据浏览器加载渲染原理,局部字体和款式加载失败大概率不会导致页面DOM无奈出现,无奈下结论之时,无妨先假如字体和款式文件影响到了DOM渲染。

当无奈从表象剖析出线上问题起因时,第一步须要在开发环境或者测试环境复现问题场景,而后排查从申请资源到页面渲染的执行过程。

问题的引入点:域名解析

在复现场景之前,须要先晓得拜访胜利和失败之间的差别。通过收集到的信息来看,申请域名解析的IP有显著不同:

  • 失常拜访资源,DNS域名解析
Request URLRemote Address
https://at.alicdn.com/t/font_1353866_klyxwbettba.css121.31.31.251:443
https://g.alicdn.com/de/prismplayer/2.9.21/skins/default/aliplayer-min.css119.96.90.252:443
https://at.alicdn.com/t/font_2296011_yhl1znqn0gp.woff2121.31.31.251:443
https://at.alicdn.com/t/font_1353866_klyxwbettba.woff2?t=1639626666505121.31.31.251:443
  • 生产环境申请资源失败,DNS域名解析

    • at.alicdn.com116.153.65.231
    • g.alicdn.com211.91.241.230

用户和SRE所处地区不同,拜访资源时域名解析命中的边缘节点服务也会不同,而at.alicdn.comg.alicdn.com是公网收费的CDN域名,某些边缘节点服务稳定性不够,拉取不到资源也是可能产生的。

问题根本原因:模块加载

开发环境与测试环境复现差别

批改本地hosts,增加用户域名解析的地址映射,在测试环境和开发环境尝试复现。两个环境均不能获取到字体和款式文件,测试环境(https://ec-hwbeta.casstime.com)页面内容没有出现(复现胜利),开发环境页面内容失常出现(复现失败),剖析开始陷入胡同。

开发环境:

测试环境:

这时候就要开始剖析了,两个环境复现问题的差别点在哪里?

不难发现,两个环境最次要的区别在于yarn startyarn 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在解析样式表中@importurl()过程中,如果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的变更导致依赖该chunkchunk也变更(文件名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实现的linkscript申请对应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回调函数

起因

至此,问题的根本原因曾经明了了。因为生产环境构建将cssjs拆分成一个个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"},

而后,应用以下命令通知 npmindex.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);}

理论我的项目测试