V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
kaiduo
V2EX  ›  Node.js

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

  •  
  •   kaiduo · 2022-11-02 23:15:52 +08:00 · 4641 次点击
    这是一个创建于 750 天前的主题,其中的信息可能已经有所发展或是发生改变。

    原文地址: https://github.com/xiaoxiaojx/blog/issues/46

    问题简述

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

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

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

    问题排查

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

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

    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 来依次处理。

    // 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

    |- 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 函数开始排查, 然后发现了如下语句

    // packages/next/build/webpack/loaders/next-style-loader/index.js
    
    var content = require(${stringifyRequest(this, `!!${request}`)});
    

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

    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

    // pages/_app.tsx
    
    import '../styles/globals.css'
    

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

    // 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 模块提供

    // !!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 的规则

    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 字段

    而 !!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 其实为如下👇

    // '!!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 样式的异常 !!!

    // 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 的定义

    即便新的模块又命中了 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 给处理

    // 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 文件就得排除, 更多见 pr

                issuer: regexLikeCss,
                // Exclude extensions that webpack handles by default
                exclude: [
    +              /\.(css|sass|scss)$/,
                  /\.(js|mjs|jsx|ts|tsx)$/,
                  /\.html$/,
                  /\.json$/,
    
    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2428 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 16:06 · PVG 00:06 · LAX 08:06 · JFK 11:06
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.