高考加油头像生成器 制作总结

前言

过 10d 就要高考了嘛…作为去年刚经历过高考的老年人,和团队商量了一下,打算仿照之前“给头像加国旗”的活动,做一个为自己的头像一键添加高考加油外框的工具。

考虑到数据不多,在上线后应该也不需要变化,就没有做 api 和后台,直接把模板数据写在一个 js 文件里完事。

项目地址:https://github.com/i5zWolf/NceeCheerAvatarMaker

UI 设计

就两个页面,背景图也得用 PS 画,就懒得再开 XD 了。PS 里的多画板真香。

设计完以后,用蓝湖的 PS 插件把背景图、LOGO 和 按钮做了个切图,然后一键上传,真香。

蓝湖截图

代码编写

小型项目,依然无脑用 Vue 来做,画头像的时候用了 fabric.js 来操作 canvas。

设计图还原

还原设计稿的主要的问题还是在于不同尺寸的屏幕下,怎样定位设计稿里的元素。目前主流方案不外乎固定宽度或固定高度,这里我选择了固定宽度。考虑到 vw/vh 的兼容性已经非常不错了,就不再采用动态设置 font-size 并用 em/rem 做属性的方案,而是直接上 vw。具体方法不外乎三种 :

  • 手算尺寸,然后写进 css 里。假设设计稿的宽度是 750 像素,那就把 css 里的尺寸属性计算后替换,例如: n = x / 750 * 100
  • 使用 css 预处理器,编写函数来计算,本质上还是第一种方案,不过省了点事,不再赘述
  • 在编译过程自动计算并替换

我选择了第三种方法,使用了 postcss-px-to-viewport 这个插件。

postcss.config.js 里配置一下插件:

module.exports = {
  plugins: {
    "postcss-px-to-viewport": {
      unitToConvert: "px",
 // 被转换的单位
      viewportWidth: 750,
 // 设计稿宽度
      unitPrecision: 5,
 // 保留的小数点位数
      propList: ["*"],
 // 开启替换的 css 属性
      viewportUnit: "vw",
 // 转换后的单位
      fontViewportUnit: "vw",
      selectorBlackList: ["crop"],
 // 需要忽略的 css class 关键词,后文会提到
      minPixelValue: 1,
      mediaQuery: false,
      replace: true,
      exclude: undefined,
      include: undefined,
      landscape: false,
      landscapeUnit: "vw",
      landscapeWidth: 1134
    }
  }
};

之后愉快地在 css 里按照 UI 稿的尺寸写 px 即可,编译的时候会被自动替换成 vw。

图片选择 && 裁剪

图片选择

文件选择必须由 input 标签触发,但直接在 input 标签上写逻辑不是很灵活。于是创建一个隐藏的 input 标签,在需要的时候手动触发一下。

<input
    ref="filElem"
    type="file"
    accept="image/*"
    style="display: none"
    @change="setCropImage"
/>
this.$refs.filElem.dispatchEvent(new MouseEvent("click")); // 手动触发 input 标签的点击事件

图片裁剪

图片裁剪组件,我选择了 simple-crop 。它在移动端上的表现比较好,媲美原生。

注意到我们引入了 px 转 vw 的插件,因此需要对它的 css 类进行排除。我们把 "crop" 加到 postcss-px-to-viewport 插件设置的 selectorBlackList 里就 ok,这样插件会自动排除含有 "crop" 关键词的 class。

图片裁剪界面

由于每个模板有不同的图片裁切比例,用户在选择模板后,需要先读取模板信息里的图片尺寸,对图片裁剪组件进行设置。

裁剪后,会回调一个裁剪后的图片的 canvas 对象。使用 toDataURL 可以将其转为 base64 编码的图片字符串,将其存入 store 以便后续使用。

// methods:
    // 用户选中模板,触发 input 调起文件选择器。在这里设置需要裁剪的宽高
    handleChooseTemplate(template) {
      // console.log(template);
      this.$store.dispatch("setTemplate", template);
      this.cropParams = JSON.parse(JSON.stringify(this.cropParams)); //改变对象引用
      this.cropParams.visible = false;
      this.cropParams.size = {
        width: template.avatar_width,
        height: template.avatar_height
      };
      this.$refs.filElem.dispatchEvent(new MouseEvent("click"));
    },
    // 选中文件后的回调,设置待裁剪图片
    setCropImage(evt) {
      this.cropParams = JSON.parse(JSON.stringify(this.cropParams)); //改变对象引用
      var files = evt.target.files;
      if (files.length > 0) {
        this.cropParams.src = files[0];
        this.cropParams.visible = true;
      }
      evt.target.value = "";
    },
    // 图片裁剪后的回调
    async cropCallback($resultCanvas) {
      console.log("cropCallback");
      await this.$store.dispatch("setAvatar", $resultCanvas.toDataURL());
      this.$router.push({ name: "Generate" });
    }

图片渲染

头像渲染示意图

最后渲染出的头像会有两个图层。底部图层是用户裁剪后的原头像,顶部图层是模板图像。模板图像中的透明像素区域就是头像显示的区域。

于是,我们以上图左上角的顶点为坐标原点,对每个模板,分别定义模板的宽高(红框部分)、头像显示区域的宽高(蓝框部分)、头像显示区域的偏移量(left、top)

{
    id: 1,
    author: "xixi",
    preview_image_url:
      "https://storage-common-upyun.qz5z.ren/ncee-avatar-templates/001/example.jpg",
    template_image_url:
      "https://storage-common-upyun.qz5z.ren/ncee-avatar-templates/001/template.png",
    template_width: 1024,
    template_height: 1024,
    avatar_top: 138,
    avatar_left: 66,
    avatar_width: 906,
    avatar_height: 880
  }

再有了这些参数后,渲染图片就很容易了。为了简化对 canvas 的操作,我引入了 fabric.js 以便进行对象化的操作。(也许有些大材小用?)

这时候会碰上一个小问题—— canvas 的宽高往往是固定的,但最终的显示区域尺寸是响应式的。

一开始,我考虑先读取显示区域的宽高,用这个宽高设置 canvas 画布尺寸,然后对 canvas 里的内容按比例缩放,但这样有些麻烦。于是我直接做了个 display: none 的 canvas,在上面画完图,再用 toDataURL 把它转成 base64 的图片。相关代码如下:

// 不喜欢回调套娃,于是封装了一个在画布上添加图片的 Promise 函数
function loadImage(img) {
  return new Promise(resolve => {
    fabric.Image.fromURL(
      img,
      function(oImg) {
        resolve(oImg);
      },
      { crossOrigin: "Anonymous" }
 // 解决 canvas 里有图片时不能用 toDataURL 的问题
    );
  });
}

methods: {
    async paint() {
      // 新建画布
      const canvas = new fabric.StaticCanvas("canvas");
      // 设置宽高
      canvas.setHeight(this.templateHeight);
      canvas.setWidth(this.templateWidth);
      // 先在指定位置画个原头像
      const avatarImg = await loadImage(this.avatarImage);
      avatarImg.set({
        left: this.avatarLeft,
        top: this.avatarTop
      });
      canvas.add(avatarImg);
      // 再叠上模板图像
      const templateImg = await loadImage(this.templateImage);
      canvas.add(templateImg);
      // 转成 base64
      this.imgSrc = canvas.toDataURL({
        format: "png"
      });
    }
  }

需要留意的是,添加图片时需要加上 crossOrigin: "Anonymous" 这一属性,否则导出成图片的时候会因为浏览器的 CORS 策略被禁止。

其他杂项

代码版权注释

效果

首先需要编辑项目里的 /public/index.html ,在头部加上:

<!--
Author: Fly3949
Build time: <%= htmlWebpackPlugin.options.time %>
Environment: <%= htmlWebpackPlugin.options.env %>
Version: <%= htmlWebpackPlugin.options.version %>
===
「君と僕もさ、また明日へ向かっていこう」
-->

然后修改 vue.config.js ,为 webpack 添加相关参数:

const webpack = require("webpack");
const now = require("dayjs")().format("YYYY-M-D HH:mm:ss");
const version = require("./package.json").version;

module.exports = {
  productionSourceMap: false,
  chainWebpack: config => {
    config.plugin("html").tap(args => {
      args[0].minify = { removeComments: false };
 // 保留 index.html 的注释
      args[0].title = require("./package.json").title;
      args[0].time = now;
      args[0].env = process.env.NODE_ENV;
      args[0].version = version;
      return args;
    });
  }
};

访问统计 && 错误上报

// Google Analytics
import VueAnalytics from "vue-analytics";
Vue.use(VueAnalytics, {
  id: "UA-78734040-15",
  router,
  autoTracking: {
    pageviewOnLoad: false
  }
});

// Sentry
import * as Sentry from "@sentry/browser";
import { Vue as VueIntegration } from "@sentry/integrations";
Sentry.init({
  dsn:
    "https://66aab7eb317a48b8a477e51d3236b5be@o406700.ingest.sentry.io/5294656",
  release: process.env.NODE_ENV + "@" + require("../package.json").version,
  environment: process.env.NODE_ENV,
  integrations: [new VueIntegration({ Vue, attachProps: true })]
});

结语

这个项目大概只花了 2 天的时间(一天画 UI,一天写代码),在编写的过程中学习了基本的 canvas 的操作。

之前一直忙着做项目写代码,很少特地花时间写总结。但慢慢发现有些东西总是被重复使用,忘了经验就会重复踩坑。所以,还是得有个简单的就记录和总结吧——不一定会帮到别人,但总能帮到自己。

评论

  1. 4月前
    2020-8-17 21:46:15

    爸爸

    • fly 博主
      4月前
      2020-8-17 21:58:23

      乖儿子

  2. 5月前
    2020-6-28 10:38:43

    换主题了耶 ∠( ᐛ 」∠)_

    • fly 博主
      5月前
      2020-6-28 13:07:33

      ヾ(≧▽≦*)o

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇