记一次 Next.js 样式异常的排查与 pr 修复

问题简述


开发同学反馈 Next.js 项目 next dev 命令启动后浏览器访问页面 css 样式有丢失,表现为 style 标签的内容不是 css 样式而是一个 url 😨

1
<style>/_next/static/media/globals.23a96686.css</style>

面对这个略显奇葩的问题该如何排查了 ?

问题排查

Step1 确认是否配置了错误的 loader

  • 分析: 给 css 文件设置错了 loader 造成结果不符合预期也说得过去
  • 结论: ❌ 没有发现可疑的 loader, 经过查看 webpackConfig 发现会命中红圈 oneOf 数组索引为 6 的规则, 即 css 文件会依次被 postcss-loader > css-loader > next-style-loader 来依次处理, 看上去没有任何问题

image

Rule.oneOf : An array of Rules from which only the first matching Rule is used when the Rule matches.

同时根据 Rule.oneOf 的定义确认了只会从 oneOf 数组中找到一个匹配到的规则就会停止, 那么 loader 应该是都被正确设置了

Step2 处理 css 文件的 loader 有 bug ?

  • 分析: 确认了没有奇怪的 loader 掺杂进来, 那么是不是命中的 loader 有 bug 了
  • 结论: ✅ 果然有 bug

loader 正常是按从下到上, 从右到左的顺序执行, 即如上配置会按从 postcss-loader > css-loader > next-style-loader 来依次处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
// packages/next/build/webpack/loaders/next-style-loader/index.js

import path from 'path'
import isEqualLocals from './runtime/isEqualLocals'
import { stringifyRequest } from '../../stringify-request'

const loaderApi = () => {}

loaderApi.pitch = function loader(request) {
// ...
}

module.exports = loaderApi

但是由于 next-style-loader 导出了 pitch 属性, loader 的顺序将会变成首先运行 pitch 函数, 相关文档见 Pitching Loader

1
2
3
4
5
|- next-style-loader `pitch`
|- requested module is picked up as a dependency
|- postcss-loader normal execution
|- css-loader normal execution
|- next-style-loader normal execution

那么我们先从 next-style-loader 的 pitch 函数开始排查, 然后发现了如下语句

1
2
3
// packages/next/build/webpack/loaders/next-style-loader/index.js

var content = require(${stringifyRequest(this, `!!${request}`)});

上面语句补充上变量的值后相当于如下

1
var content = require('!!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css')

♻️ 这里的依赖关系先简单理一下, 比如 _app.tsx 引用了 globals.css

1
2
3
// pages/_app.tsx

import '../styles/globals.css'

globals.css 模块的内容被 next-style-loader 处理后的内容类似于下面这样

1
2
3
4
5
6
7
// styles/globals.css

var content = require('!!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css')

const style = document.createElement('style');
style.appendChild(document.createTextNode(content));
document.head.append(style)

所以 style 标签里面的 content 的源头其实是 require 的 !!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css 模块提供

1
2
3
// !!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css

module.exports = ?

那么对于 !!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css 这个路径上带有 loader 的模块其文件后缀依然为 .css, 那么不还得命中 oneOf 数组索引为 6 的规则么 🤔 ?

经过 debug webpack 的代码发现结果是意外的 ❌, 它命中的是 oneOf 数组索引为 8 的规则
image

Rule.issuer: A Condition to match against the module that issued the request. In the following example, the issuer for the a.js request would be the path to the index.js file.

关于 Rule.issuer: 比如 pages/_app.tsx 文件里面 import 或者 require 了 globals.css, 那么对于 globals.css 而言, 它的 issuer 为 pages/_app.tsx

让我们回头查看一下为什么没有命中索引为 6 的规则, 发现索引为 6 规则非常重要的一点是要求引用该文件的 issuer 必须只能是 pages/_app.tsx, 见下图红圈的 issuer.and 字段

image

而 !!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css 模块其实是被 next-style-loader 代理后的 globals.css 所 require, 所以它的 issuer 竟然为 styles/globals.css 🤯

至此 !!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css 模块经过 postcss-loader > css-loader 处理后, 最后还要被 webpack 内置的文件类型 asset/resource 处理(相当于 file-loader), 最终处理完成它的 module.exports 其实为如下👇

1
2
3
// '!!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css'

module.exports = "/_next/static/media/globals.23a96686.css"

所以被 next-style-loader 处理后的 styles/globals.css 模块最后 append 了一个 url 字符串到了 style 标签中造成了本次 css 样式的异常 !!!

1
2
3
4
5
6
7
// styles/globals.css

var content = require('!!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css')

const style = document.createElement('style');
style.appendChild(document.createTextNode(content));
document.head.append(style)

疑惑解答

为什么 Rule.oneOf 貌似 pick 了多次规则?

对于 styles/globals.css 模块而言只选择了一次即 oneOf 数组索引为 6 的规则, 而经过 next-style-loader 的代理修改后的 require 语句又产生了新的模块 !!xxx/css-loader/src/index.js??xxx/postcss-loader/src/index.js??ruleSet[1].rules[3].oneOf[6].use[2]!./globals.css, 于是新的模块又进行了一次规则 pick, 所以并不算违背了 Rule.oneOf 的定义
image

即便新的模块又命中了 oneOf 数组索引为 6 的规则, 由于新的模块路径前缀已经包含了 postcss-loader 与 css-loader 也会因为如上图红圈的 if 条件判断不满足而不会被添加重复的 loader

Next.js 的 oneOf 数组索引为 8 的规则真实意图是干什么的?

根据如下 Next.js 对应的代码可知, Next.js 的预期是 issuer 为 *.css 的模块将要被 asset/resource(类似于 file-loader) 给处理, 比如 globals.css 有一行代码为 background-image: url(“./xxx.png”), 那么此时 xxx.png 就要被 asset/resource 给处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// next/dist/build/webpack/config/blocks/css/index.js

markRemovable({
// This should only be applied to CSS files
issuer: regexLikeCss,
// Exclude extensions that webpack handles by default
exclude: [
/\.(js|mjs|jsx|ts|tsx)$/,
/\.html$/,
/\.json$/,
/\.webpack\[[^\]]+\]$/,
],
// `asset/resource` always emits a URL reference, where `asset`
// might inline the asset as a data URI
type: 'asset/resource'
}),

问题解决

Next.js 没想到被 next-style-loader 修改后还存在 .css 文件 require .css 文件的情况, .css 文件被 asset/resource 处理后返回一个 url 就导致了本次的 bug 🐛

其实 .css 中 require 的图片、字体等文件被 asset/resource 处理是符合预期, 如果被 require 的是 .css 文件就得排除, 更多见next.js/pull/42283webpack/pull/16477

1
2
3
4
5
6
7
            issuer: regexLikeCss,
// Exclude extensions that webpack handles by default
exclude: [
+ /\.(css|sass|scss)$/,
/\.(js|mjs|jsx|ts|tsx)$/,
/\.html$/,
/\.json$/,