项目解决方案 - 雪碧图

4,012 阅读7分钟

一、问题引出

在使用一些图片图标、样例图时,我们往往将其设计为一个具有一定宽高的背景盒子:

width: 20px;
height: 20px;
background-image: url("/icons/home.png");

如果项目中存在大量的这种小图,就会引发一个问题:

大量的小图引起了大量的请求,不仅对服务器造成了比较大的压力,对用户而言,图片的显示效果也不佳(图片会一个一个蹦出来)。

在没有字体图标、SVG图标之前,web网页的图标大多数都是以图片的形式存在。

二、雪碧图概念

为了解决这个问题,我们可以将所有小图放置在一张大图上:

58414897.png

如上图所示,大图上放置了若干个小图,以后在使用这些小图时,直接引用这张大图,然后定位到小图的位置就可以了:

59100920.png

上图中,A区域可以任意移动和改变大小,同时它也是真正的可视区域,现在,让我们移动A区域,并调整其大小,使其选中第二排第一张图:

58675052.png

这样,我们就成功将这个小图给显示出来了。

在CSS中,我们通过宽高来改变A区域的尺寸,如:

width: 30px;
height: 30px;

因为图片是从左上角开始摆放,所以我们实际上只会看到左上角的一小部分图片,现在我们可以让背景发生一定的偏移:

background-position: -30px -120px;

三、问题的解决

雪碧图的概念是非常简单的,就像我们拿放大镜在书本上移动,书本相当于这个大图,而每个文字相当于一个小图,要想看清每个文字,我们必须不断移动放大镜的位置。

要按照上述概念实现雪碧图,我们需要做这些工作:

  1. 准备好所有小图
  2. 准备一张大图,将小图放置在大图上,并且记录好每个图片的尺寸和位于大图的位置
  3. 准备一个与小图尺寸相同的元素,设置背景图为大图,并且根据小图所处的位置进行偏移,直至小图完全显示出来

诚然雪碧图的概念是简单的,但是实际操作起来却是非常繁琐的,当需要增加小图时,需要不断维护这张大图,并且需要仔细定义这些小图的位置、大小,一旦发生变化,引用的地方也要同步修改。

这些重复且繁琐的工作会使开发人员的工作变得乏味、无趣,有没有好的办法来解决这一问题呢?

四、理想的雪碧图使用

因为将小图画到大图上是枯燥且麻烦的,因此,理想状态下,我们最好压根不用维护这些大图,而只用维护小图:

  • sprites
    • page1
      • icon1.png
      • icon2.png
      • icon3.png
    • page2
      • icon1.png
      • icon2.png

如上所示,sprites目录下放置了所有的小图,而page1目录相当于这个大图,未来我们需要将该目录下所有小图组合,生成一个page1.png的大图。 这样,每次新增小图时,我们将准备好的小图直接复制到指定的大图目录下即可,这个步骤是非常简单的。

除了手动维护大图外,手动记录小图的尺寸、位置都是非常麻烦、无趣的事情,因此,在使用雪碧图时,只需要指定两个重要参数即可:

  • 大图
  • 小图

也就是说,开发人员只要按照sprites目录下的结构来写雪碧图就可以,而不用关心其他问题。使用的方法看起来像这样:

smallImage('page1/icon1.png')

这个语法最终会被处理为一串css:

width: 15px;
height: 15px;
background-repeat: no-repeat;
background-image: url("page1.png");
background-position: -100px -200px;

怎么样?是不是很炫酷?接下来,让我们一步一步来实现吧。

五、方案的实现

理想的状态下,开发人员在使用图片时,最好不要有雪碧图的概念,之所以制作雪碧图,纯粹是为了减少页面的请求量,与开发人员是无关的,开发人员不应为此承担多余的工作量。如果不能很好的解决雪碧图的使用,反而会给开发人员造成负担,以及造成后期维护上的困难。

完成步骤四,至少需要解决这些问题:

  • 程序自动读取雪碧图目录下的文件,并将所有图片组合成一张大图
  • 在生成大图的同时,将雪碧图上每个小图的尺寸、位置记录下来
  • 提供一个工具函数,参数为路径如/page1/icon1.png,返回值为一连串的css

有了要解决的问题,我们马上找到了一款与符合上述需求的webpack插件:webpack-spritesmith。该插件可以读取目录下的图片文件并将其组合起来,同时生成一个对应的样式文件,并且支持重写。

核心代码如下:

const { resolve, join, basename } = require('path')
const fs = require('fs')
const SpritesmithPlugin = require('webpack-spritesmith')

const BASE_PATH = resolve('./src/assets/sprite')
const SOURCE_PATH = join(BASE_PATH, 'src')
const plugins = []

// 1. 读取assets/sprite/src下的所有子目录
// 2. 每个子目录生成一张雪碧图
// 3. 生成的雪碧图与变量文件放置在assets/sprite目录下
// 4. 使用时需要按需引入.scss文件

// 读取源目录,每个子目录生成一个插件对象
const files = fs.readdirSync(SOURCE_PATH, {
  withFileTypes: true
})
files.forEach(file => {
  if (!file.isDirectory()) return
  plugins.push(createPlugin(join(SOURCE_PATH, file.name)))
})

function createPlugin(path) {
  const imageName = basename(path)
  const cssPath = join(BASE_PATH, imageName) + '.scss'
  const imagePath = join(BASE_PATH, imageName) + '.png'
  const templateHandler = createTemplateHandler(imageName)
  const plugin = new SpritesmithPlugin({
    src: {
      cwd: path,
      glob: '*.png'
    },
    target: {
      image: imagePath,
      css: [
        [
          cssPath,
          {
            format: 'templateHandler'
          }
        ]
      ]
    },
    customTemplates: {
      templateHandler
    }
  })
  // 样式生成
  function createTemplateHandler(imageName) {
    return function(data) {
      const items = []
      const image = basename(data.spritesheet.image) // 大图名称,如:icon.png
      data.items.forEach(item => {
        const smallImageName = basename(item.source_image) // 小图名称,如:edit.png
        items.push(`
          '${smallImageName}': (
            width: ${item.width}px,
            height: ${item.height}px,
            background-repeat: no-repeat,
            background-image: url("~@/assets/sprite/${image}"),
            background-position: ${item.offset_x}px ${item.offset_y}px
          )`
        )
      })
      return `
        $sprite-${getFileBasename(image)}: (
          ${items.join()}
        );
      `
    }
  }

  return plugin
}

/**
 * 获取文件名
 * @param {String} filename 文件名
 */
function getFileBasename(filename) {
  if (!filename) return null
  const sepIdx = filename.lastIndexOf('.')
  if (sepIdx < 0) return filename
  return filename.substring(0, sepIdx)
}

module.exports = plugins

之所以创建多个插件对象,是因为该插件单个对象只能处理一张雪碧图,因此我们需要实例化多个插件对象用来处理多个雪碧图的情况。 然后,将导出的插件对象列表传到webpack的创建配置中即可:

plugins: [
  ...spritesPlugins
]

以上代码会读取sprite/src下的目录,每个目录视为一张雪碧图,然后将该目录下的所有小图组合成一张大图,并在sprite目录下生成对应的图片和样式文件,最后的结构如下所示:

  • sprite
    • src
      • icons
      • examples
    • icons.png 自动生成的雪碧图
    • icons.scss 自动生成的样式文件,里面包含了每个小图的样式
    • examples.png
    • examples.scss

每个样式文件中存储了一个sass map对象,key为小图的名称,value为样式,如:

$sprite-icons: (
    'home.png': {
        width: 20px,
        height: 20px,
        background-repeat: no-repeat,
        background-image: url("~@/assets/sprite/icons.png"),
        background-position: -40px -120px
    },
    // ...
)

然后,我们编写一个sass mixin,来统一处理雪碧图的使用:

@import "@/assets/sprite/icons.scss";

// FIXME 不得已的处理方案,不知道sass中有没有可以通过变量名直接获取变量的方法
// 如果有,请改善它
$sprites: (
  icons: $sprite-icons
);

@mixin sprite($path) {
  $sep-index: str-index($path, '/');
  @if $sep-index {
    $dir: str-slice($string: $path, $start-at: 0, $end-at: $sep-index - 1);
    $name: str-slice($string: $path, $start-at: $sep-index + 1, $end-at: -1);
    $sprite: map-get($map: $sprites, $key: $dir);
    @if $sprite {
      $css: map-get($sprite, $name);
      @debug $sprite;
      @if $css {
        @each $key, $value in $css {
          #{$key}: $value;
        }
      } @else {
        display: none;
      }
    }
  }
};

使用时,只需要这样就可以:

@import "@/styles/mixins/sprite.scss";
.icon-home {
    @include sprite('icons/home.png');
}

是不是很方便呢?

六、如何维护和使用雪碧图?

增加雪碧图

增加雪碧图需要在sprite/src目录下新建一个目录,并将所有小图放入该目录。

注意:项目需要重新启动才可以触发该目录的雪碧图生成,切记!(后续会考虑解决此问题)

重启项目后,还需要在styles/mixins/sprite.scss中引入,并为$spritesmap新增一项,如:

$sprites: (
    icons: $sprite-icons,
    examples: $sprite-examples
)

注:map的key需要与雪碧图目录的名称相同。

新增小图

新增小图非常方便,将小图直接放入指定的雪碧图目录即可。(会自动重新编译,生成新的雪碧图)

使用

引入雪碧图sass mixin并使用即可:

@import "@/styles/mixins/sprite.scss";
.icon-home {
    @include sprite('examples/bg.png');
}

总结

尽管我们使用一个看似很复杂的过程完成了雪碧图的制作,但是思路是非常清晰的:

要让开发人员像使用普通图片一样使用雪碧图。

从这一点出发,不断倒推,最后得出了上述的解决方案。

对于一个项目的架构人员而言,所有的开发者都是Ta的用户,要想“用户”开发得舒适,必须为他们解决掉最棘手的问题,这也算是一种用户体验方面的设计吧。