问题概述

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

  • 用户访问“查看报价页面”存在样式和字体文件没有加载成功;

    img-20221227231329.png

  • 没有加载成功的字体和样式文件的请求域名并不是公司的,而是公网免费的域名(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)页面内容没有呈现(复现成功),开发环境页面内容正常呈现(复现失败),分析开始陷入胡同。

开发环境:

img-20221227231351.png

测试环境:

img-20221227231405.png

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

不难发现,两个环境最主要的区别在于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}`,
  },  
}

img-20221227231424.png

css-loader在解析样式表中@importurl()过程中,如果index.module.scss中使用@import 引入第三方样式库aliplayer-min.css@import aliplayer-min.css部分和index.module.scss中其余部分将会被分离成两个module,然后分别追加到样式数组中,数组中的每个”样式项“将被style-loader处理使用style标签注入到html

img-20221227231434.png

img-20221227231443.png

img-20221227231453.png

img-20221227231504.png

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对象。因此,内容区域正常显示。

img-20221227231514.png

img-20221227235144.png

生产环境

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改变),从而导致浏览器缓存失效。

img-20221227231625.png

因为启用了MiniCssExtractPlugin.loader分离样式,@import "aliplayer-min.css"将被分离到一个css chunk中,所以aliplayer-min.css请求链有三级

img-20221227231650.png)

img-20221227231702.png

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回调函数

img-20221227233554.png

img-20221227233604.png

img-20221227233613.png

img-20221227233628.png

原因

至此,问题的根本原因已经明了了。由于生产环境构建将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);
}

实际项目测试

download-alicdn.gif


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。