简介
当我在几年前推出这个博客时,我想为通讯订阅按钮添加一点亮点。我的想法是:在背景中加入动画的彩虹渐变。
我喜欢渐变。在经历了这么多年的纯色和平面设计之后,我很高兴看到它们卷土重来
事实证明,制作CSS渐变的动画比我想象的要麻烦得多,而且结果也有点不尽人意。
我没有直接制作渐变的动画,而是创建了一个非常高的渐变,并在按钮内将其转化,一旦接近底部就将其重置。我信赖的朋友overflow: hidden
,确保用户看不到多余的部分。
这种方法还算有效,但也有问题。
-
循环并不是完全无缝的。不同设备之间性能的微妙差异意味着当位置重置时,它可能是明显的。
-
它看起来并不那么好;我想要的是一种有机的流动质量,而这只是感觉到静态和无生命。
在过去的几年里,我对这个按钮进行了大量的思考。这是一个漫长的过程,但在发现了一种疯狂的新技术之后,我终于能够想出我喜欢的东西了。
辐射梯度的拯救!
这个新模型使用了radial-gradient
:颜色从左上角渗出,在彩虹中慢慢转变,层层叠叠地覆盖在按钮的表面。
更准确地说,在左上角有一个3色的径向梯度。这些颜色在彩虹中都是相邻的,而动画的每一个 "刻度 "都会使颜色向下移动。
这里最大的区别是,实际上没有任何东西在移动。在一个二维平面上不再有平移发生。相反,我从一个10色的彩虹调色板中抽取3种颜色,梯度中的每一个点都在缓慢地移动,以继承前一个点的颜色。C3
点在调色板中总是比C2
点落后一种颜色。
这创造了运动的幻觉,类似于那些赌场或会场的灯光。
动画梯度
因此,游戏计划正在形成。
-
我创建了一个有10种彩虹颜色的调色板。
-
我设置一个梯度,以保持3种颜色的移动窗口。
-
我将运行一个间隔,每秒钟更新一次梯度,每种颜色移动一个点。
-
我在每个点的颜色之间进行切换。在每一帧中,颜色都应该朝着下一个值移动。
最后一步是最棘手的。不幸的是,你不能使用transition
,在背景渐变之间进行插值。下面的片段不起作用。
.gradient {
background: radial-gradient(...);
/* 🙅♀️ Doesn't work */
transition: background 1000ms;
}
我可以在JS中完成这一切。我可以设置一个requestAnimationFrame
循环,将每个颜色过渡分割成~60个递增步骤。我不喜欢这个想法,它感觉过于复杂。另外,因为这一切都发生在JavaScript的主线程上,动画在繁忙时期可能会变得不稳定。
我想在CSS中进行插值。很高兴地,我找到了一个方法😊
自定义属性(又称 CSS 变量)
一段时间以来,CSS已经有了变量。乍一看,它们很像你在SASS或LESS中看到的变量,但与预处理器不同,变量在运行时仍在代码中。这使得它们的功能更加强大,我们很快就会看到!
下面是如何在梯度中使用CSS自定义属性的。
.gradient {
/*
Variables are defined right along style declarations.
They're indicated by the double-hyphen prefix.
*/
--color-1: deepskyblue;
--color-2: navy;
/*
You can access variables using the 'var()' function:
*/
background: linear-gradient(170deg, var(--color-1), var(--color-2));
}
我们可以使用内联样式在React元素上设置,像这样。
<div
style={{
'--color-1': 'deepskyblue',
'--color-2': 'navy',
background: `
linear-gradient(
170deg,
var(--color-1),
var(--color-2) 80%
)
`,
// Unrelated styles:
color: 'white',
textAlign: 'center',
padding: 30,
borderRadius: 12,
}}
>
Hello World
</div>
就其本身而言,这实际上并不能帮助我们。transition
我们仍然不能直接在background
。但它让我们更近一步🕵🏻♂️
🎩 CSS Houdini
CSS Houdini是一套广泛的即将推出的CSS增强功能,其前提是一个想法:开发人员应该能够创建自己的CSS功能。
例如,CSS没有任何内置的方法来做砖石结构的布局。如果你能建立它,直接插入CSS机制,然后用display: masonry
,那不是很酷吗?
另一个例子:像Babel这样的项目允许我们在JS中 "polyfill"(大部分)缺失的功能,因为我们可以用较早版本的语言来模仿这些新功能。但是,我们不能聚填(大多数)CSS功能。通过让我们访问CSS引擎的内部线路,Houdini将允许我们对缺失的CSS进行聚填。
CSS Houdini是一个巨大的项目,已经进行了多年的研究和开发,我预计它将以令人兴奋和不可预知的方式塑造网络开发的未来。不过,今天我想把重点放在其中一个相对较小但令人难以置信的酷的部分:动画的自定义属性。
动画的自定义属性
在CSS中,"属性 "是你可以赋值的东西。display
、transform
、color
,都是属性的例子。那么,为什么CSS中的变量被称为自定义属性?它们不是一个完全不同的概念吗?
实际上,它们比我意识到的要相似得多。最好把CSS变量看作是你自己的属性,就像显示和转换一样。
.gradient {
/* Create a new custom property, and give it a value: */
--color: navy;
/* Access that value using the `var` function: */
background-color: var(--color);
border: 2px dashed var(--color);
}
这里有一个疯狂的、令人震惊的部分:你可以过渡自定义属性。
.gradient {
--magic-rainbow-color-1: hsl(0deg, 96%, 55%);
--magic-rainbow-color-2: hsl(25deg, 100%, 50%);
--magic-rainbow-color-1: hsl(40deg, 100%, 50%);
background: linear-gradient(
170deg,
var(--magic-rainbow-color-1),
var(--magic-rainbow-color-2)
);
/* 🤯 */
transition: --magic-rainbow-color-1 1000ms linear, --magic-rainbow-color-2
1000ms linear, --magic-rainbow-color-3 1000ms linear;
}
我们不是告诉浏览器对背景属性进行动画处理,而是告诉浏览器对我们的自定义属性进行动画处理。然后我们在背景渐变中使用该自定义属性。令人惊讶的是,var()
关键字是反应式的,只要数值发生变化,背景就会重新绘制,即使该数值是由transition
。
我的脑子里还在嗡嗡作响地想着各种可能性。CSS自定义属性比我意识到的要酷得多,而Houdini给了我们彻头彻尾的魔力✨🧙✨
还有一件事:注册属性
在实际工作之前,我们还需要做一件事。我们需要告诉浏览器我们的自定义属性的类型是什么。
浏览器应该把它当作一种颜色?一个长度?还是一个角度?我们需要明确说明这一点,这样浏览器才知道如何插值变化。
我们在JS中用下面的方法来做这件事。
CSS.registerProperty({
// The name of our property, should match what we use in our CSS:
name: '--color-1',
// How we want to interpolate that value, when it changes:
scale: '<color>',
// Whether it should inherit its value from the cascade
// (like `font-size` does), or not (like `position` doesn't)
inherits: false,
initialValue: 'hsl(0deg, 96%, 55%)',
});
一个普通的JS演示
稍后,我们将看到React钩子如何让我们很好地将其打包。但首先,我想分享原始JS代码,供使用不同框架或根本没有框架的人参考。
const rainbowColors = [
'hsl(1deg, 100%, 55%)', // red
'hsl(25deg, 100%, 50%)', // orange
'hsl(40deg, 100%, 50%)', // yellow
'hsl(130deg, 100%, 40%)', // green
'hsl(230deg, 100%, 45%)', // blue
'hsl(240deg, 100%, 45%)', // indigo
'hsl(260deg, 100%, 55%)', // violet
];
const paletteSize = rainbowColors.length;
// Number of milliseconds for each update
const intervalDelay = 1000;
const colorNames = [
'--magic-rainbow-color-0',
'--magic-rainbow-color-1',
'--magic-rainbow-color-2',
];
// Register properties
colorNames.forEach((name, index) => {
CSS.registerProperty({
name,
syntax: '<color>',
inherits: false,
initialValue: rainbowColors[index],
});
});
const buttomElem = document.querySelector('#rainbow-button');
let cycleIndex = 0;
window.setInterval(() => {
// Shift every color up by one position.
//
// `% paletteSize` is a handy trick to ensure
// that values "wrap around"; if we've exceeded
// the number of items in the array, it loops
// back to 0.
const nextColors = [
rainbowColors[(cycleIndex + 1) % paletteSize],
rainbowColors[(cycleIndex + 2) % paletteSize],
rainbowColors[(cycleIndex + 3) % paletteSize],
];
// Apply these new colors, update the DOM.
colorNames.forEach((name, index) => {
buttonElem.style.setProperty(name, nextColors[index]);
});
// increment the cycle count, so that we advance
// the colors in the next loop.
cycleIndex++;
}, intervalDelay);
挂上它 ⚛️
关于React钩子的一个好处是,它们给了开发者更多的控制权,让他们能够表达不同的想法。自定义钩子让我们把一堆东西塞进一个盒子里,而由我们来画出这些盒子。我们可以选择是否要优化可重用性,或清晰度,或其他任何东西。
在这种情况下,我想保持事物的友好性。我可以牺牲一点权力或灵活性来换取一个不费吹灰之力的useRainbow
钩。
状态和API
最初,我在想我将在状态中保留当前的颜色,但我想到颜色是派生数据;真正的状态是当前的间隔计数。
例如,如果我在第5个周期,我知道我的颜色将是10色调色板中的第5、6、7种颜色。因为调色板是静态的,我可以直接跟踪这个数字,用它来推导颜色。
我想弄清楚的下一件事是钩子的界面。我首先写了一个将消费这个钩子的组件。我喜欢为使用它的组件编造任何看起来很理想的API。消费者驱动的开发。
import useRainbow from './useRainbow.hook';
const MagicRainbowButton = ({ children, intervalDelay = 1000 }) => {
// The hook should take 1 argument, `intervalDelay`.
// it should return an object in this shape:
/*
{
'--magic-rainbow-color-0': hsl(...),
'--magic-rainbow-color-1': hsl(...),
'--magic-rainbow-color-2': hsl(...),
}
*/
const colors = useRainbow({ intervalDelay });
const colorKeys = Object.keys(colors);
return (
<ButtonElem
style={{
// Spread the colors to define them as custom properties
// on this element
...colors,
// Use the keys to set the same transition on all props.
transition: `
${colorKeys[0]} ${transitionDelay}ms linear,
${colorKeys[1]} ${transitionDelay}ms linear,
${colorKeys[2]} ${transitionDelay}ms linear
`,
// Use those property values in our gradient.
// Values go from 2 to 0 so that colors radiate
// outwards from the top-left circle, not inwards.
background: `
radial-gradient(
circle at top left,
var(${colorKeys[2]}),
var(${colorKeys[1]}),
var(${colorKeys[0]})
)
`,
}}
>
{children}
</ButtonElem>
);
};
考虑到这一点,这里是这个钩子的初始版本。
const rainbowColors = [
/* colors here */
];
const paletteSize = rainbowColors.length;
const useRainbow = ({ intervalDelay = 2000 }) => {
// Register all custom properties.
// This only ever needs to be done once, so there are no dependencies.
React.useEffect(() => {
for (let i = 0; i < 3; i++) {
try {
CSS.registerProperty({
name: `--magic-rainbow-color-${i}`,
initialValue: rainbowColors[i],
syntax: '<color>',
inherits: false,
});
} catch (err) {
console.log(err);
}
}
}, []);
// Get an ever-incrementing number from another custom hook*
const intervalCount = useIncrementingNumber(intervalDelay);
// Using that interval count, derive each current color value
return {
'--magic-rainbow-color-0': rainbowColors[(intervalCount + 1) % paletteSize],
'--magic-rainbow-color-1': rainbowColors[(intervalCount + 2) % paletteSize],
'--magic-rainbow-color-2': rainbowColors[(intervalCount + 3) % paletteSize],
};
};
export default useRainbow;
*
useIncrementingNumber
是一个自定义的钩子,根据提供的间隔延迟,吐出一个新的、不断增加的数字。它是基于Dan Abramov的setInterval钩子。
我喜欢这种方法,因为它有明确的职责分工。
-
useRainbow
负责生成和管理颜色,但对它们的用途没有投票权。 -
组件,
MagicRainbowButton
,对这些颜色的来源和更新时间一无所知,但决定如何处理它们。
有一件事让我的蜘蛛感有点刺痛;useRainbow
,秘密地注册全局的CSS自定义属性,这相当令人吃惊。事实上,从一个实例化的组件中注册一个全局值将会是一个问题我们将在下一节中解决这个问题,以及其他一些挥之不去的问题。
为生产做好准备
在你开始在你的律师事务所的网站或你的会计软件上到处发送彩虹按钮之前,有几件事情我们需要考虑一下。
全局属性和重复组件
我们目前实现的最大问题是它违反了React的核心原则:一个组件的每个实例都应该是独立的。我们应该能够按照我们的意愿渲染它的许多副本,而不会让它们相互干扰。
如果我们试图在同一个页面上渲染两份我们的MagicRainbowButton
,我们会得到这个错误。
InvalidModificationError。在'CSS'上执行'registerProperty'失败了。提供的名称已经被注册了。
这是因为CSS自定义属性注册表是一个全局对象;我们所有的组件实例都共享同一个全局命名空间而现在,它们都在试图注册相同的名字。
我通过为每个React组件创建一个唯一的ID,并将其存储在useRef
。
const useRainbow = ({ windowSize = 3, intervalDelay = 2000 }) => {
// Generate a permanent unique ID for this component instance
const { current: uniqueId } = React.useRef(generateId());
CSS.registerProperty({
// Use that ID in the custom property name, to avoid conflicts:
name: `--magic-rainbow-color-${uniqueId}-${index}`,
initialValue,
syntax: '<color>',
inherits: false,
});
};
这也让我对 "钩子中的秘密副作用 "这件事感觉好些。一点随机性排除了名称冲突的风险,让我们假装它实际上不是全局的。
浏览器支持
Houdini是超级出血的,这反映在它的浏览器支持上。在撰写本文时,CSS.registerProperty
,只有Chrome 78+和Opera 65+支持。
我的解决方案?如果没有找到window.CSS
或CSS.registerProperty
,就提前跳出钩子,并返回前三种颜色。其他浏览器不会得到动画,但他们仍然会得到一个漂亮的梯度而我们的React组件根本不需要改变💯。
注意:IE11完全不支持自定义属性,所以如果你需要支持它,你需要设置一个后备背景渐变,使用硬编码颜色值而不是自定义属性。
性能
去年,我做了一个关于动画/交互性能的演讲。在那次演讲中,我提到有两个 "黄金标准 "属性:不透明度和变换。这两个属性的性能要比其他属性好得多,因为它们不必在每一帧上画,它们可以直接被显卡作为纹理来操作,晃来晃去,不需要CPU做任何工作。
在那次谈话中,我也主张打破这个规则,只要你在测量。在我的CPU上设置了6倍的节流阀,我启动了分析器。
的确,这种技术涉及到每一帧的重绘,而重绘可能很慢......但在这种情况下,重绘的量很小。重绘需要~0.3毫秒,如果我们想达到60fps,这大约是我们预算的2%。
像height
这样的动画属性通常非常慢,因为它涉及到布局和绘制步骤,而且涉及的像素数量可能非常大。在这种情况下,没有布局步骤,绘画步骤是快速和有针对性的💫。
可访问性
异想天开的点缀是很好的,但当它们以牺牲可用性为代价时,就不是了。
某些类型的动画对于有前庭障碍的人来说是有问题的--它们可能会引发眩晕、恶心、头痛和其他讨厌的症状。
浏览器一直在努力实现对"prefers-reduced-motion "媒体查询的支持。这种查询依赖于Windows/MacOS的设置,并让用户表达他们希望禁用动画的意愿。
浏览器对这种媒体查询的支持已经大大改善,并支持Chrome、Firefox、Safari和(很快)Edge。我们将设置它,以便我们只为那些选择了 "无偏好 "运动的人启用动画,这是默认值。
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: no-preference)'
);
// `true` if we should enable animations:
console.log(prefersReducedMotion.matches);
这种方法可能有点违反直觉--我们不是要为那些表达了偏好的人禁用动画吗?--但在大多数情况下,它的效果是一样的。例外情况是使用Internet Explorer等老式浏览器的人;在他们的情况下,媒体查询并不存在。这样做意味着在这些浏览器中的人不会看到动画。最好的办法是采取更安全的假设。
除了动作之外,我们还需要考虑色彩对比。有视力障碍的人能够读懂按钮上的文字吗?我添加了一点文字阴影,并将暖色系的一端变暗。说实话,在动画的某些时段,它的对比度可能还是太低了,但我相信它在大多数时候都是可读的,而且动画的转变也很快。
总结
如果你热衷于建立自己的彩虹按钮,这个按钮的源代码可能会很有用。