用 Babel 插件优化 CLS:构建期注入图片尺寸的实践探索

在浏览器中,图片在加载完成之前没有宽高,加载完成之后立即显示,会对布局造成影响,从而影响CLS分数,有没有什么办法可以避免呢?

MDN关于img标签有下面的建议:

Use both width and height to set the intrinsic size of the image, allowing it to take up space before it loads, to mitigate content layout shifts.

参考: developer.mozilla.org/en-US/docs/...

给图片提前设置 width height 属性可以解决这一问题。因此思路很明确,你可以在项目里全局搜索引入的静态文件,然后手动设置宽高。

有没有更好的方案呢?

next/image 文档里提到了

For local images (imported), Next.js will automatically determine the width and height based on the imported file. These values are used to prevent Content Layout Shift.

对于静态导入的场景,会自动提取宽高信息, 因此我们按照这个思路来实现。

从nextjs的代码里,我推测他使用webpack 的 loader来实现的

javascript 复制代码
const staticImageData = isStaticRequire(src) ? src.default : src

但是问题是,不想改变现有项目的file-loader url-loader的用法,如下

javascript 复制代码
import logo from 'ASSETS/xxx.png'

<img src={logo} />

这里导入logo,根据路径匹配到webpack config中配置的loader,对于url-loader会把png转换成base64,因此这里logo就是一个纯字符串, file-loader会把这个png保存到dist下,然后返回一个路径,这里logo也是一个字符串。所以像上面这样使用没问题。

如果再写个loader,让这里logo返回格式是 {src, width, height} 的话,所有使用img的地方都得改,使用起来有心智负担。

使用babel plugin实现 构建时注入宽高信息

参考next/image的思路,构建时获取width、height,然后传给img标签。

方案:

  1. 写个babel插件,遍历到Img标签时,判断src是否是静态导入
  2. 使用 image-size 读取图片信息得到宽高,然后把这些参数传递给Img
  3. 如果Img标签本身传入了宽高 则不添加,防止覆盖掉

也就是要实现这样的效果:

javascript 复制代码
import logo from './assets/logo.png'
import { Img } from '@/components/Img'

<Img src={logo} />

// 转换成
<Img src={logo} width={300} height={200} />

完整代码实现:

javascript 复制代码
const fs = require('fs')
const path = require('path')
const t = require('@babel/types')
const rawSizeOf = require('image-size')
const sizeOf = rawSizeOf.default || rawSizeOf

console.log('img meta inject plugin loaded')

module.exports = function () {
  return {
    visitor: {
      JSXOpeningElement(nodePath, state) {
        const tag = nodePath.node.name // Img 标签名
        if (!t.isJSXIdentifier(tag) || tag.name !== 'Img') return
        const srcAttr = nodePath.node.attributes.find(attr => attr.name?.name === 'src')
        if (!srcAttr || !t.isJSXExpressionContainer(srcAttr.value)) return // 没有src属性

        const expr = srcAttr.value.expression
        if (!t.isIdentifier(expr)) return

        const varName = expr.name // "logo"
        const binding = nodePath.scope.getBinding(varName)
        if (!binding) return

        let importPath = null

        // case 1: import logo from './logo.png'
        if (t.isImportDeclaration(binding.path.parent)) {
          importPath = binding.path.parent.source.value
        }

        // case 2: const logo = require('./logo.png')
        if (t.isVariableDeclarator(binding.path.node)) {
          const init = binding.path.node.init
          if (
            t.isCallExpression(init) &&
            t.isIdentifier(init.callee, { name: 'require' }) &&
            init.arguments.length === 1 &&
            t.isStringLiteral(init.arguments[0])
          ) {
            importPath = init.arguments[0].value
          }
        }

        if (!importPath) return

        // 如果有ASSETS 替换成 assets
        importPath = importPath.replace(/^ASSETS//, 'assets/')
        const key = importPath.replace(/^.?/*/, '') // 去掉前面的 ./ 或 /,得到相对路径
        // 尝试读取文件,判断是否存在
        if (!fs.existsSync(path.resolve(__dirname, 'src', key))) {
          console.log(`Not Found image: ${key}`)
          return
        }

        const { width, height } = sizeOf(path.resolve(__dirname, 'src', key))
        if (!width || !height) return
        // 注入 width 和 height 属性
        nodePath.node.attributes.push(
          t.jsxAttribute(t.jsxIdentifier('width'), t.stringLiteral(String(width))),
          t.jsxAttribute(t.jsxIdentifier('height'), t.stringLiteral(String(height)))
        )
      },
    },
  }
}

问题:是否会影响通过className、style等方式设置的宽高?

不会,设置width、height属性的优先级最低,因此这种方式不会覆盖其他方式设置的宽高

demo:github

相关推荐
蓝莓味的口香糖2 分钟前
【企业微信】VUE项目在企微中自定义转发内容
前端·vue.js·企业微信
IT_陈寒2 分钟前
告别低效!用这5个Python技巧让你的数据处理速度提升300% 🚀
前端·人工智能·后端
—Qeyser4 分钟前
Laravel + UniApp AES加密/解密
前端·uni-app·laravel
C++chaofan7 分钟前
游标查询在对话历史场景下的独特优势
java·前端·javascript·数据库·spring boot
cg.family9 分钟前
Vue3 v-slot 详解与示例
前端·javascript·vue.js
FreeBuf_23 分钟前
新型域名前置攻击利用Google Meet、YouTube、Chrome及GCP构建流量隧道
前端·chrome
c0detrend28 分钟前
技术架构设计:如何打造一个高性能的Chrome截图插件
前端·chrome
幽络源小助理34 分钟前
8、幽络源微服务项目实战:前端登录跨域同源策略处理+axios封装+权限的递归查询增删改+鉴权测试
前端·微服务·架构
API开发38 分钟前
apiSQL+GoView:一个API接口开发数据大屏
前端·后端·api·数据可视化·数据大屏·apisql
运维开发王义杰40 分钟前
nodejs:揭秘 npm 脚本参数 -- 的妙用与规范
前端·npm·node.js