要问2018最让人兴奋的CSS技术是什么,CSS Houdini当之无愧,甚至能够去掉2018这个限定。其实这个技术在2016年就出来了,可是在今年3月发布的Chrome 65才正式支持。javascript
CSS Houdini能够作些什么?谷歌开发者文档列了几个demo,咱们先来看一下这几个demo:css
(1)给textarea加一个方格背景(demo)html
使用如下CSS代码:前端
textarea {
background-image: paint(checkerboard);
}复制代码
(2)给div添加一个钻石形状背景(demo)java
使用如下CSS:node
div {
--top-width: 80;
--top-height: 20;
-webkit-mask-image: paint(demo);
}复制代码
(3)点击圆圈扩散动画(demo)web
这3个例子都是用了Houdini里面的CSS Paint API。chrome
第1个例子若是使用传统的CSS属性,咱们最多可能就是使用渐变来作颜色的变化,可是作不到这种一个格子一个格子的颜色变化的,而第2个例子也是没有办法直接用CSS画个钻石的形状。这个时候你可能会想到会SVG/Canvas的方法,SVG和Canvas的特点是矢量路径,能够画出各类各样的矢量图形,而Canvas还能控制任意像素点,因此用这两种方式也是能够画出来的。npm
可是Canvas和html相结合的时候就会显得有点笨拙,就像第2个例子画一个钻石的形状,用Canvas你须要利用相似于BFC定位的方式,把Cavans调到合适的定位,还要注意z-index的覆盖关系,而使用SVG可能会更简单一点,能够设置background-image为一张钻石的svg图片,可是没法像Canavas同样很方便地作一些变量控制,例如随时改一下钻石边框的颜色粗细等。canvas
而第1个例子给textarea加格子背景,只能使用background-image + svg的方式,可是你不知道这个textarea有多大,svg的格子须要准备多少个呢?固然你可能会说谁会给textarea加一个这样的背景呢。但这只是一个示例,其它的场景可能也会遇到相似的问题。
第3个例子点击圆圈扩散动画,这个也能够在div里面absolute定位一个canvas元素,可是咱们又遇到另一个问题:没法很方便复用,假设这种圈圈扩散效果在其它地方也要用到,那就得在每一个地方都写一个canvas元素并初始化。
因此传统的方式存在如下问题:
(1)须要调好和其它html元素的定位和z-index关系等
(2)编辑框等不能方便地改背景,不能方便地作变量控制
(3)不能方便地进行复用
其实还有另一个更重要的问题就是性能问题,用Cavans画这种效果时须要本身控制好帧率,一不当心电脑CPU风扇可能就要呼啸起来,特别是不能把握重绘的时机,若是元素大小没有变化是不须要重绘,若是元素被拉大了,那么须要进行重绘,或者当鼠标hover的时候作动画才须要重绘。
CSS Houdini在解决这种自定义图形图像绘制的问题提供了很好的解决方案,能够用Canvas画一个你想要的图形,而后注册到CSS系统里面,就能在CSS属性里面使用这个图形了。以画一个星空为例,一步步说明这个过程。
CSS Houdini只能工做在localhost域名或者是https的环境,不然的话相关API是不可见(undefined)的。若是没有https环境的话,能够装一个http-server的npm包,而后在本地启动,访问localhost:8080就能够了,新建一个index.html,写入:
<!DOCType> <html> <head> <meta charset="utf-8"> <style> body { background-image: paint(starry-sky); } </style> </head> <body> <script> CSS.paintWorklet.addModule('starry-sky.js'); </script> </body> </html>复制代码
经过在JS调用CSS.paintWorklet.addModule注册一个CSS图形starry-sky,而后在CSS里面就可使用这个图形,写在background-image、border-image或者mask-image等属性里面。如上面代码的:
body {
background-image: paint(starry-sky);
}复制代码
注册paint worket的时候须要给它一个独立的js,做为这个worklet的工做环境,这个环境里面是没有window/document等对象的和web worker同样。若是你不想写管理太多js文件,能够借助blob,blob是能够存听任何类型的数据的,包括JS文件。
Worklet须要的starry-sky.js的代码以下所示:
class StarrySky {
paint (ctx, paintSize, properties) {
// 使用Canvas的API进行绘制
ctx.fillRect(0, 0, paintSize.width, paintSize.height);
}
}
// 注册这个属性
registerPaint('starry-sky', StarrySky);复制代码
写一个类,实现paint接口,这个接口会传一个canvas的context变量、当前画布的大小即当前dom元素的大小,以及当前dom元素的css属性properties.
在paint函数里面调用canvas的绘制函数fillRect进行填充,默认填充色为黑色。访问index.html,就会看到整个页面变成黑色了。咱们的Hello World的CSS Houdini Painter就跑起来了,没错,就是这么简单。
可是有一点须要强调的是,浏览器实现并非给那个dom元素添加一个Canvas而后隐藏起来,这个Paint Worket其实是直接影响了当前dom元素重绘过程,至关于咱们给它添加了一个重绘的步骤,下文会继续说起。
若是不想独立写一个js,用blob能够这样:
let blobURL = URL.createObjectURL( new Blob([ '(',
function(){
class StarrySky {
paint (ctx, paintSize, properties) {
ctx.fillRect(0, 0, paintSize.width, paintSize.height);
}
}
registerPaint('starry-sky', StarrySky);
}.toString(),
')()' ], { type: 'application/javascript' } )
);
CSS.paintWorklet.addModule(blobURL);复制代码
Cavans星星效果网上找一个就行了,例如这个Codepen,代码以下:
paint (ctx, paintSize, poperties) {
let xMax= paintSize.width;
let yMax = paintSize.height;
// 黑色夜空
ctx.fillRect(0, 0, xMax, yMax);
// 星星的数量
let hmTimes = xMax + yMax;
for (let i = 0; i <= hmTimes; i++) {
// 星星的xy坐标,随机
let x = Math.floor((Math.random() * xMax) + 1);
let y = Math.floor((Math.random() * yMax) + 1);
// 星星的大小
let size = Math.floor((Math.random() * 2) + 1);
// 星星的亮暗
let opacityOne = Math.floor((Math.random() * 9) + 1);
let opacityTwo = Math.floor((Math.random() * 9) + 1);
let hue = Math.floor((Math.random() * 360) + 1);
ctx.fillStyle = `hsla(${hue}, 30%, 80%, .${opacityOne + opacityTwo})`; ctx.fillRect(x, y, size, size); } }复制代码
效果以下:
为何它要用fillRect来画星星呢,星星不该该是圆的么?由于若是用arc的话性能会明显下降。因为星星比较小,因此使用了这种方式,固然改为arc也是能够的,由于咱们只是画一次就行了。
如今要作一个可配参数控制星星的密度,就好像border-radius能够控制同样。借助CSS变量,给body添加一个自定义属性--star-density:
body {
--star-density: 0.8;
background-image: paint(starry-sky);
}复制代码
规定密度系数从0到1变化,经过paint函数的propertis参数获取到属性。可是咱们发现body/html的自定义属性没法获取,能够继承给body的子元素,但没法在body上获取,因此改为画在body:before上面:
body:before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
--star-density: 0.5;
background-image: paint(starry-sky);
}复制代码
而后给class StarrySky添加一个静态方法:
class StarrySky {
static get inputProperties() {
return ['--star-density'];
}
}复制代码
告知咱们须要获取哪些CSS属性,能够是自定义的,也能够是常规的CSS属性。而后在paint方法的properties里面就能够拿到属性值:
class StarrySky {
paint (ctx, paintSize, properties) {
// 获取自定义属性值
let starDensity = +properties.get('--star-density').toString() || 1;
// 最大只能为1
starDensity > 1 && (starDensity = 1);
// 星星的数量剩以这个系数
let hmTimes = Math.round((xMax + yMax) * starDensity);
}
}复制代码
让星星的数量剩以传进来的系数进而达控制密度的目的。上面设置星星的数量为最大值的一半,效果以下:
当拉页面的时候会发现全部星星的位置都发生了变化,这是由于触发了重绘。
在paint函数里面添加一个console.log,拉动页面的时候就能够观察到浏览器在不断地执行paint函数。由于这个CSS属性是写在body:befoer上面的,占满了body,body大小改变就会触发重绘。而若是写在一个宽度固定的div里面,拉动页面不会触发重绘,观察到paint函数没有执行。若是改了div或者body的任何一个CSS属性也会触发重绘。因此这个很方便,不须要咱们本身去监听resize之类的DOM变化。
页面拉大时,右边新拉出来的空间星星没有画大,因此自己须要重绘。而重绘给咱们形成的问题是星星的位置发生变化,正常状况下应该是页面拉大拉小,星星的位置应该是要不变的。因此须要记录一下星星的一些相关信息。
能够在SkyStarry这个类里面添加一个成员变量stars,保存全部star的信息,包括位置和透明度等,在paint的时候判断一下stars的长度,若是为0则进行初始化,不然使用直接上一次初始化过的星星,这样就能保证每次重绘都是用的一样的星星了。可是在实际的操做过程当中,发现一个问题,它会初始化两次starry-sky.js,在paint的时候也会随机切换,以下图所示:
这样就形成了有两个stars的数据,在重绘过程当中来回切换。缘由多是由于CSS Houdini的本意并不想让你保存实例数据,可是既然它设计成一个类,使用类的实例数据应该也是合情合理的。这个问题我想到的一个解决方法是把random函数变成可控的,只要随机化种子同样,那么生成的random系列就是同样的,而这个随机化种子由CSS变量传进来。因此就不能用Math.random了,本身实现一个random,以下代码所示:
random () {
let x = Math.sin(this.seed++) * 10000;
return x - Math.floor(x);
}复制代码
只要初始化seed同样,那么就会生成同样的random系列。seed和星星密度相似,由CSS变量控制:
body:before {
--starry-sky-seed: 1;
--star-density: 0.5;
background-image: paint(starry-sky);
}复制代码
而后在paint函数里面经过properties拿到seed:
paint (ctx, paintSize, properties) {
if (!this.stars) {
let starOpacity = +properties.get('--star-opacity').toString();
// 获得随机化种子,能够不传,默认为0
this.seed = +(properties.get('--starry-sky-seed').toString() || 0);
this.addStars(paintSize.width, paintSize.height, starDensity);
}
}复制代码
经过addStars函数添加星星,这个函数调用上面自定义的random函数:
random () {
let x = Math.sin(this.seed++) * 10000;
return x - Math.floor(x);
}
addStars (xMax, yMax, starDensity = 1) {
starDensity > 1 && (starDensity = 1);
// 星星的数量
let hmTimes = Math.round((xMax + yMax) * starDensity);
this.stars = new Array(hmTimes);
for (let i = 0; i < hmTimes; i++) {
this.stars[i] = {
x: Math.floor((this.random() * xMax) + 1),
y: Math.floor((this.random() * yMax) + 1),
size: Math.floor((this.random() * 2) + 1),
// 星星的亮暗
opacityOne: Math.floor((this.random() * 9) + 1),
opacityTwo: Math.floor((this.random() * 9) + 1),
hue: Math.floor((this.random() * 360) + 1)
};
}
}复制代码
这段代码由Math.random改为this.random保证只要随机化种子同样,生成的全部数据也都是同样的。这样就能解决上面提到的初始化两次数据的问题,由于种子是同样的,因此两次的数据也是同样的。
可是这样有点单调,每次刷新页面星星都是固定的,少了点灵气。能够给这个随机化种子作下优化,例如实现单个小时内是同样的,过了一个小时后刷新页面就会变。经过如下代码能够实现:
const ONE_HOUR = 36000 * 1000;
this.seed = +(properties.get('--starry-sky-seed').toString() || 0)
+ Date.now() / ONE_HOUR >> 0;复制代码
这样拉动页面的时候星星就不会变了。
可是在从小拉大的时候,右边会没有星星:
由于第一次的画布没那么大,之后又没有更新星星的数据,因此右边就空了。
不能所有更新星星的数据,否则第4步就白作了。只能把右边没有的给它补上。因此须要记录一下两次画布的大小,若是第二次的画布大了,则增长星星,不然删掉边界外的星星。
因此须要有一个变量记录上一次画布的大小:
class StarrySky {
constructor () {
// 初始化
this.lastPaintSize = this.paintSize = {
width: 0,
height: 0
};
this.stars = [];
}
}复制代码
把相关的操做抽成一个函数,包括从CSS变量获取设置,增量更新星星等,这样可让主逻辑变得清晰一点:
paint (ctx, paintSize, properties) {
// 更新当前paintSize
this.paintSize = paintSize;
// 获取CSS变量设置,把密度、seed等存放到类的实例数据
this.updateControl(properties);
// 增量更新星星
this.updateStars();
// 黑色夜空
for (let star of this.stars) {
// 画星星,略
}
}复制代码
增量更新星星须要作两个判断,一个为是否须要删除掉一些星星,另外一个为是否须要添加,根据画布的变化:
updateStars () {
// 若是当前的画布比上一次的要小,则删掉一些星星
if (this.lastPaintSize.width > this.paintSize.width ||
this.lastPaintSize.height > this.paintSize.height) {
this.removeStars();
}
// 若是当前画布变大了,则增长一些星星
if (this.lastPaintSize.width < this.paintSize.width ||
this.lastPaintSize.height < this.paintSize.height) {
this.addStars();
}
this.lastPaintSize = this.paintSize;
}复制代码
删除星星removeStar的实现很简单,只要判断x, y坐标是否在当前画布内,若是是的话则保留:
removeStars () {
let stars = []
for (let star of stars) {
if (star.x <= this.paintSize.width &&
star.y <= this.paintSize.height) {
stars.push(star);
}
}
this.stars = stars;
}复制代码
添加星星的实现也是相似的道理,判断x, y坐标是否在上一次的画布内,若是是的话则不添加:
addStars () {
let xMax = this.paintSize.width,
yMax = this.paintSize.height;
// 星星的数量
let hmTimes = Math.round((xMax + yMax) * this.starDensity);
for (let i = 0; i < hmTimes; i++) {
let x = Math.floor((this.random() * xMax) + 1),
y = Math.floor((this.random() * yMax) + 1);
// 若是星星落在上一次的画布内,则跳过
if (x < this.lastPaintSize.width && y < this.lastPaintSize.height) {
continue;
}
this.stars.push({
x: x,
y: y,
size: Math.floor((this.random() * 2) + 1),
// 星星的亮暗
});
}
}复制代码
这样当拖动页面的时候就会触发重绘,重绘的时候就会调paint更新星星。
经过作星星透明度的动画,可让星星闪起来。若是用Cavans标签,能够借助window.requestAnimationFrame注册一个函数,而后用当前时间减掉开始的时间模以一个值就获得当前的透明度系数。使用Houdini也可使用这种方式,区别是咱们能够把动态变化透明度系数看成当前元素的CSS变量或者叫自定义属性,而后用JS动态改变这个自定义属性,就可以触发重绘,这个已在第3点重绘部分提到。
给元素添加一个--star-opacity的属性:
body:before {
--star-opacity: 1;
--star-density: 0.5;
--starry-sky-seed: 1;
background-image: paint(starry-sky);
}复制代码
在星星的时候,每一个星星的透明度再乘以这个系数:
// 获取透明度系数
this.starOpacity = +properties.get('--star-opacity').toString();
for (let star of this.stars) {
// 每一个星星的透明度都乘以这个系数
let opacity = +('.' + (star.opacityOne + star.opacityTwo)) * this.starOpacity;
ctx.fillStyle = `hsla(${star.hue}, 30%, 80%, ${opacity})`;
ctx.fillRect(star.x, star.y, star.size, star.size);
}复制代码
而后在requestAnimationFrame动态改变这个CSS属性:
let start = Date.now();
// before没法获取,因此须要改为正常元素
let node = document.querySelector('.starry-sky');
window.requestAnimationFrame(function changeOpacity () {
let now = Date.now();
// 每隔一1s,透明度从0.5变到1
node.style.setProperty('--star-opacity', (now - start) % 1000 / 2 + 0.5);
window.requestAnimationFrame(changeOpacity);
});复制代码
这样就能从新触发paint函数从新渲染了,可是这个效果实际上是有问题的,由于得有一个alternate轮流交替的效果,即0.5变到1,再从1变到0.5,而不是每次都是0.5到1. 模拟CSS animation的alternate这个也好解决,能够规定奇数秒就是变大,而偶数秒就是变小,这个好实现,略。
但实际上能够不用这么麻烦,由于改变CSS属性直接用animation就能够了,以下代码所示:
body:before {
--star-opacity: 1;
--star-density: 0.5;
--starry-sky-seed: 1;
background-image: paint(starry-sky);
animation: shine 1s linear alternate infinite;
}
@keyframes shine {
from {
--star-opacity: 1;
}
to {
--star-opacity: 0.6;
}
}复制代码
这样也能触发重绘,可是咱们发现它只有在from和to这两个点触发了重绘,没有中间过渡的过程。能够推测由于它认为--star-opacity的属性值不是一个数字,而是一个字符串,因此这两关键帧就没有中间的过渡效果了。所以咱们得告诉它这是一个整型,不是一个字符串。类型化CSS对象模型(Typed CSSOM)提供了这个API。
类型化CSS对象模型一个很大的做用就是把全部的CSS单位都用一个相应的对象来表示,提供加减乘除等运算,如:
// 10 px
let length = CSS.px(10);
// 在循环里面改length的值,不用本身去拼字符串
div.attributeStyleMap.set('width', length.add(CSS.px(1)))复制代码
这样的好处是不用本身去拼字符串,另外还提供了转换,如transform的值转成matrix,度数转成rad的形式等等。
它还提供了注册自定义类型属性的能力,使用如下API:
CSS.registerProperty({
name: '--star-opacity',
// 指明它是一个数字类型
syntax: '<number>',
inherits: false,
initialValue: 1
});复制代码
这样注册以后,CSS系统就知道--star-opacity是一个number类型,在关键帧动画里面就会有一个渐变的过渡效果。
类型CSS对象模型在Chrome 66已经正式支持,可是registerProperty API仍然没有开放,须要打开chrome://flags,搜索web platform,从disabled改为enabled就可使用。
这个给咱们提供了作动画新思路,CSS animation + Canvas的模式,CSS animation负责改变属性数据并触发重绘,而Canvas去获取动态变化的数据更新视图。因此它是一个数据驱动的动画模式,这也是当前作动画的一个流行方式。
在咱们这个例子里面,因为星星数太多,1s有60帧,每帧都要计算和绘制1000个星星,CPU使用率达到90%多,因此这个性能有问题,若是用Cavans标签可使用双缓冲技术,CSS Houdini好像没有这个东西。可是能够换一个思路,改为作总体的透明度动画,不用每一个星星都算一下。
以下代码所示:
body {
background-color: #000;
}
body:before {
background-image: paint(starry-sky);
animation: shine 1s linear alternate infinite;
}
@keyframes shine {
from {
opacity: 1;
}
to {
opacity: 0.6;
}
}复制代码
这个的效果和每一个星星都单独算是同样的,CPU消耗12%左右,这个应该仍是能够接受的。
效果以下图所示:
若是用Canvas标签,能够设置globalAlpha全局透明度属性,而使用CSS Houdini咱们直接使用opacity就好了。
一个完整的Demo:CSS Houdini Starry Sky,须要使用Chrome,由于目前只有Chrome支持。
总的来讲,CSS Houdini的Paint Worket提供了CSS和Canvas的粘合,让咱们能够用Canvas画出想要的CSS效果,并借助CSS自定义属性进行控制,经过使用JS或者CSS的animation/transition改变自定义属性的值触发重绘,从而产生动画效果,这也是数据驱动的开发思想。并讨论了在画这个星空的过程当中遇到的一些问题,以及相关的解决方案。
本文只是介绍了CSS Houdini里面的Paint Worket和Typed CSSOM,它还有另一个Layout Worklet,利用它能够自行实现一个flex布局或者其它自定义布局,这样的好处是:一方面当有新的布局出现的时候能够借助这个API进行polyfill就不用担忧没有实现的浏览器不兼容,另外一方面能够发挥想象力实现本身想要的布局,这样在布局上可能会百花齐放了,而不只仅使用W3C给的那几种布局。
【再一次强推书】高效前端已上市,京东、亚马逊、淘宝等均有售