写完意识到这是第一篇,算是前言吧。。。
以后在zhihu写点东西...就当是博客好了,个人观点和体会而已,不求高大上,但求回头看的时候能够让以后的自己瞧不起。
背景:写React也一年多了,从一开始的单React到React+redux也累计写过几十上百号的基础组件和业务基础组件了...然后很不幸的是作为一个数据产品开发,不可能天天跟最繁琐的用户管理相关页面(我主要负责这部分...)打交道,产品表示以后的重心放在报表部分(数据可视化)。
目标:
之前做的时候都是直接自己封装的HighchartsAPI,在ComponentWillRecieveProps里面isEqual和valid检查手动调highcharts的套路,比较无聊。前天我另一个主要负责图形报表的FEer跟我说,产品要在报表上加水柱特效了...就是那种是试管里水位上升的那种感觉...
现状:
之前做的时候,都是自己用canvas写的,主要是觉得canvas确实有一种自己拿笔在画的感觉,性能确实不错,但是这次表示要用SVG...我之前使用SVG的经验仅限于iconfont和几个简单的静态图(比如多层漏斗什么的);另一方面,SVG把图形数据直接写入DOM里了,对React组件来说是非常友好的,从状态和结果来说都比canvas更加契合React组件的控制...所以就迫不及待的想试试了...觉得这是个好机会,于是要了两天排期来搞这个...
想到SVG的水波,首先想到的的是什么呢,就是SVG中的贝塞尔曲线...查了一下SVG的文档,跟设想的差不多,应该是用路径来实现。
前期准备:
看了一圈MDN和W3C文档,基本弄懂了path的贝塞尔曲线的写法,简单的就是二次贝塞尔曲线,Q(q),一个控制点和一个终点,追加S(s)可以在结束点另一侧创建对称控制点,只需要传一个结束点就行了,非常简便易懂。(倒是mdn里面对空格和逗号的运用让我有点晕23333);
水波嘛,抽象和简化,其实就是一个个波峰和波谷连起来,左右摆动,高度变小而已...
由于我不太懂如何做到一个path里实现两种fillStyle(波峰是前景色,波谷是背景色,这一点canvas就灵活多了...sigh),所以就只能波峰一条,波谷一条。
波峰的path实际上是一段凸起,接着一段等宽的线段,再凸起...波谷那一条path正好是上下左右错开的...
分析:
随着动画progress的变化,我总结了几个基本的状态参数柱子的高度姑且称为baseLineTop(也就是水平面的高度)
水面运动的translateX,记为waveLeft
水面晃动产生的波浪的高度,记为waveHeight
然后其余的的d其实都是从这三个基本参数里计算出来的。
由于之前直接写canvas的缘故,比较倾向于命名式的写法..于是对SVG的path做了一点简单的封装,好让我写起来感觉舒服点...
先对基本的点做了等于没有的封装,就是为了好看,当然也是为了用flow做类型检查...
class Point {
constructor(x:number, y:number) {
this.x = x;
this.y = y;
return this;
}
}
然后做一点简单的封装,实际上就是去把数据结构最终转成字符串键值对提供给React
import {camelCase} from 'lodash' //react的组件传统,htmlDOM Attribute name都是camelCase的...
class Path {
constructor(context = {d: []}) {
this.ctx = context;
return this;
}
strokeStyle(style:typeof Object):Path {
const {color, ...other} = style;
this.ctx.stroke = color;
for (var i in other) {
if (other.hasOwnProperty(i)) {
this.ctx[camelCase(`stroke-${i}`)] = other[i];
}
}
return this;
}
fillStyle(style:Object):Path {
const {color, ...other} = style;
this.ctx.fill = color;
for (var i in other) {
if (other.hasOwnProperty(i)) {
this.ctx[camelCase(`fill-${i}`)] = other[i];
}
}
return this;
}
moveTo(p:Point):Path {
this.addPoint(p, 'M');
return this;
}
lineTo(p:Point):Path {
this.addPoint(p, 'L');
return this;
}
fill():Path {
this.addPoint(null, 'z');
return this;
}
addPoint(p:?Point, type:string):Path {
if (type == 'z' || type == 'Z') {
this.ctx.d.push({type});
} else {
this.ctx.d.push({
type,
x: p.x,
y: p.y
});
}
return this;
}
dataToString():string {
var str = this.ctx.d.map((p)=> {
if (p.x != void(0)) {
return `${p.type}${p.x},${p.y}`;
} else if (!p.type) {
return `${p.x},${p.y}`;
}
else {
return `${p.type}`;
}
});
return str.join(' ');
}
CubicBezier(start:Point, controllPoints:Array, endPoint:Point):Path {
this.moveTo(start);
controllPoints.forEach((p:Point, i:number)=> {
if (i == 0) {
this.addPoint(p, 'C');//起手 } else {
this.addPoint(p, '');
}
});
this.addPoint(endPoint, '');
return this;
}
continueCubic(controlPoint:Point, endPoint:Point):Path {
var last = this.lastNthPoint(1);
this.addPoint(controlPoint, 'S');
this.addPoint(endPoint, '');
return this;
}
lastNthPoint(n = 1) {
var d = this.ctx.d;
var len = d.length;
try {
return d[len - n];
} catch (e) {
console.error(e);
}
}
end() {
var props = {};
const {d, ...attrs} = this.ctx;
props.d = this.dataToString();
for (var i in attrs) {
props[i] = String(attrs[i]);
}
return props;
}
}
这里简单提一句,因为之前没做过太多性能测试,害怕new Class对于查找原型链什么的会有或多或少的开销,于是拿function call生成structor和new Class做了对比
//函数调用生成const point = (x,y)=>({x,y})
//new class调用生成class Point {
constructor(x:number, y:number) {
this.x = x;
this.y = y;
return this;
}
}
function test1() {
const d1 = Date.now();
var temp;
for(let i = 0; i < 100000; i++) {
temp = point(0,0)
}
const d2 = Date.now();
return (d2 - d1)
}
function test2() {
const d1 = Date.now();
var temp;
for(let i = 0; i < 100000; i++) {
temp = new Point(0,0)
}
const d2 = Date.now();
return (d2 - d1)
}
测试出来的结果其实还蛮符合预计的,都比较快,但是new 要稍慢一丢丢...
test1()// 19test2()// 20
这样下来就不用担心会在这里发生性能问题了...
接下来,开始写真正的组件,也不好给全部源码,摘了一部分,改改注释凑合看吧=_=。
首先是构建,没什么好说的,主要讲一两点吧,一个是提前计算的问题,就是不要把所有计算都在动画期间进行,能提前运算的话尽量提前好一些,这里用calculateData()方法来提前计算所有帧的状态。
估算了一下,比较好的动画时间是3-5秒,按照60fps算,最多300次循环,每条计算大概会循环调用20次不到的Path函数,总体估算是个O(n^2)的复杂度,没有复杂运算,都是很朴实的基本js运算,没什么耗时的外部API调用...还是比较乐观的。
class Wave extends Component {
static displayName = "Wave"
static proptype = {
beforeColor: PropTypes.string,
backgroundColor: PropTypes.string,
height: PropTypes.number,
width: PropTypes.number,
};
constructor(props) {
super(props);
this.mseconds = 3000;
this.totalFrames = 60 * this.mseconds / 1000;
this.animateFramesLeft = 60 * this.mseconds / 1000;
this.heightNotChangeRate = 0.4; //高度停止增长的动画完成比例
this.waveWidthRatio = 0.5; //波的宽度占宽度比
this.maxWaveHeightRatio = 0.12; //波的最大高度占柱子高度比
this.waveWidthTotal = 10; //波浪总长度
this.waveLeftAnimation = waveLeftAnimation(this.heightNotChangeRate); //根据设置生成的缓动函数
this.state = this._getInitialState();
this.data = new Array(this.animateFramesLeft + 1);
this.animate = this.animate.bind(this);
}
calculateData() {
for (var i = this.animateFramesLeft; i >= 0; i--) {
var finishedRatio = 1 - (i / this.totalFrames);//完成度
var lastState = (i == 0) ? this.state : this.data[i - 1];
this.data[i] = this._getState(lastState, finishedRatio);
}
}
componentDidMount() {
this.calculateData();
window.requestAnimationFrame(this.animate);
}
animate(time){
if (this.animateFramesLeft >= 0) {
var newState = this.data[this.animateFramesLeft];
this.setState(newState);
this.animateFramesLeft--;
window.requestAnimationFrame(this.animate)
}
}
_getState(state = this.state, finishedRatio:number) {
//这个函数从上一帧状态中根据当前帧的完成率去计算当前帧的所有状态。
const heightNotChangeRate = this.heightNotChangeRate;
var waveLeft = -(this.waveWidthTotal - 1) * this.props.width * this.waveLeftAnimation(finishedRatio);
if (finishedRatio > heightNotChangeRate) {
var waveHeight = state.maxWaveHeight * waveAnimation(finishedRatio);
var baseLineTop = +waveHeight;
} else {
var waveHeight = state.maxWaveHeight;
var baseLineTop = this.props.height * baseLintHeightAnimation(finishedRatio / heightNotChangeRate) + waveHeight;
}
//取整是为了保持浮点数渲染粒度不够一致,导致会出现不能严丝合缝的情况,辣眼睛。
baseLineTop = parseInt(baseLineTop);
var newState = Object.assign({}, state, {
baseLineTop,
waveLeft,
waveHeight,
});
this.getLines(newState);
return newState;
}
getLines(state = this.state) {
//计算之前说的两条路径,一条前景色填充,一条背景色(白色)填充。
const {width, height, beforeColor:color} = this.props;
const waveWidthRatio = this.waveWidthRatio;
const _times = Math.ceil(this.waveWidthTotal / waveWidthRatio);
const times = _times + _times % 2;
const _w = width * this.waveWidthRatio;
const {baseLineTop:bt, waveHeight:wh, waveLeft:wl} = state;
const data = {bt, wh, wl, _w, color};
state.line1 = this.getDarkLine(times, data);
state.line2 = this.getWhiteLine(times, data);
return state;
}
getDarkLine(times, {color, bt, wl, wh, _w}) {
var path = new Path();
path.fillStyle({color}).strokeStyle({color: 'transparent'})
.moveTo(new Point(wl, bt)).addPoint(new Point(wl + _w, bt - wh), 'Q').addPoint(new Point(wl + _w * 2, bt), ''); //波峰
for (var i = 4; i <= times; i += 4) {
path.lineTo(new Point(wl + _w * (i + 2), bt));
path.addPoint(new Point(wl + _w * (i + 3), bt - wh), 'Q').addPoint(new Point(wl + _w * (i + 4), bt), '')
}
path.lineTo(new Point(wl, bt)).fill();
return path.end();
}
getWhiteLine(){//...差不多就不写了}
render(){//...同上}
}
这样处理下来,基本没有什么动画时React需要额外做的计算了~
测试一下性能吧~
还不错,JS都集中在了在了DidMount那一段时间,其余时间一直在安心渲染2333...
就这样,花两天,从零开始完成一个60fps的SVG动画组件,一个rect,两条path。
今天一小步,明天一小步,只要不扯蛋,总会有进步。
未经授权,能看不能拿,啪你呦(比爱心nico)。