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