CSS in JS是一种解决css问题想法的集合,而不是一个指定的库。从CSS in JS的字面意思可以看出,它是将css样式写在JavaScript文件中,而不需要独立出.css、.less之类的文件。将css放在js中使我们更方便的使用js的变量、模块化、tree-shaking。还解决了css中的一些问题,譬如:更方便解决基于状态的样式,更容易追溯依赖关系,生成唯一的选择器来锁定作用域。尽管CSS in JS不是一个很新的技术,但国内的普及程度并不高。由于Vue和Angular都有属于他们自己的一套定义样式的方案,React本身也没有管用户怎样定义组件的样式,所以CSS in JS在React社区的热度比较高。
目前为止实现CSS in JS的第三方库有很多:点击这里。像JSS、styled-components等。在这里我们就不展开赘述了,这篇文章的重点是JS in CSS。
在上面我们提到CSS in JS就是把CSS写在JavaScript中,那么JS in CSS我们可以推断出就是可以在CSS中使用JavaScript脚本,如下所示。可以在CSS中编写Paint API的功能。还可以访问:ctx,geom。甚至我们还可以编写自己的css自定义属性等。这些功能的实现都基于CSS Houdini。
.el {
--color: cyan;
--multiplier: 0.24;
--pad: 30;
--slant: 20;
--background-canvas: (ctx, geom) => {
let multiplier = var(--multiplier);
let c = `var(--color)`;
let pad = var(--pad);
let slant = var(--slant);
ctx.moveTo(0, 0);
ctx.lineTo(pad + (geom.width - slant - pad) * multiplier, 0);
ctx.lineTo(pad + (geom.width - slant - pad) * multiplier + slant, geom.height);
ctx.lineTo(0, geom.height);
ctx.fillStyle = c;
ctx.fill();
};
background: paint(background-canvas);
transition: --multiplier .4s;
}
.el:hover {
--multiplier: 1;
}
在如今的Web开发中,JavaScript几乎占据了项目代码的大部分。我们可以在项目开发中使用ES 2020、ES2021、甚至提案中的新特性(如:Decorator),即使浏览器尚未支持,也可以编写Polyfill或使用Babel之类的工具进行转译,让我们可以将最新的特性应用到生产环境中(如下图所示)。
而CSS就不同了,除了制定CSS标准规范所需的时间外,各家浏览器的版本、实战进度差异更是旷日持久(如下图所示),最多利用PostCSS、Sass等工具來帮我们转译出浏览器能接受的CSS。开发者们能操作的就是通过JS去控制DOM与CSSOM来影响页面的变化,但是对于接下來的Layout、Paint与Composite就几乎没有控制权了。为了解决上述问题,为了让CSS的魔力不再受到浏览器的限制,Houdini就此诞生。
我们上文中提到JavaScript中进入提案中的特性我们可以编写Polyfill,只需要很短的时间就可以讲新特性投入到生产环境中。这时,脑海中闪现出的第一个想法就是CSS Polyfill,只要CSS的Polyfill 足够强大,CSS或许也能有JavaScript一样的发展速度,令人可悲的是编写CSS Polyfill异常的困难,并且大多数情况下无法在不破坏性能的情况下进行。这是因为JavaScript是一门动态脚本语言。它带来了极强的扩展性,正是因为这样,我们可以很轻松使用JavaScript做出JavaScript的Polyfill。但是CSS不是动态的,在某些场景下,我们可以在编译时将一种形式的CSS的转换成另一种(如PostCSS)。如果你的Polyfill依赖于DOM结构或者某一个元素的布局、定位等,那么我们的Polyfill就无法编译时执行,而需要在浏览器中运行了。不幸的是,在浏览器中实现这种方案非常不容易。
如上图所示,是从浏览器获取到HTML到渲染在屏幕上的全过程,我们可以看到只有带颜色(粉色、蓝色)的部分是JavaScript可以控制的环节。首先我们根本无法控制浏览器解析HTML与CSS并将其转化为DOM与CSSOM的过程,以及Cascade,Layout,Paint,Composite我们也无能为力。整个过程中我们唯一完全可控制的就是DOM,另外CSSOM部分可控。
CSS Houdini草案中提到,这种程度的暴露是不确定的、兼容性不稳定的以及缺乏对关键特性的支持的。比如,在浏览器中的 CSSOM 是不会告诉我们它是如何处理跨域的样式表,而且对于浏览器无法解析的 CSS 语句它的处理方式就是不解析了,也就是说——如果我们要用 CSS polyfill 让浏览器去支持它尚且不支持的属性,那就不能在 CSSOM 这个环节做,我们只能遍历一遍DOM,找到 <style> 或 <link rel="stylesheet"> 标签,获取其中的 CSS 样式、解析、重写,最后再加回 DOM 树中。令人尴尬的是,这样DOM树全部刷新了,会导致页面的重新渲染(如下如所示)。
即便如此,有的人可能会说:“除了这种方法,我们也别无选择,更何况对网站的性能也不会造成很大的影响”。那么对于部分网站是这样的。但如果我们的Polyfill是需要对可交互的页面呢?例如scroll,resize,mousemove,keyup等等,这些事件随时会被触发,那么意味着随时都会导致页面的重新渲染,交互不会像原本那样丝滑,甚至导致页面崩溃,对用户的体验也极其不好。
综上所述,如果我们想让浏览器解析它不认识的样式(低版本浏览器使用grid布局),然而渲染流程我们无法介入,我们也只能通过手动更新DOM的方式,这样会带来很多问题,Houdini的出现正是致力于解决他们。
Houdini是一组底层API,它公开了CSS引擎的各个部分,如下图所示展示了每个环节对应的新API(灰色部分各大浏览器还未实现),从而使开发人员能够通过加入浏览器渲染引擎的样式和布局过程来扩展CSS。Houdini是一群来自Mozilla,Apple,Opera,Microsoft,HP,Intel和Google的工程师组成的工作小组设计而成的。它们使开发者可以直接访问CSS对象模型(CSSOM),使开发人员可以编写浏览器可以解析为CSS的代码,从而创建新的CSS功能,而无需等待它们在浏览器中本地实现。
尽管当前已经有了CSS变量,可以让开发者控制属性值,但是无法约束类型或者更严格的定义,CSS Houdini新的API,我们可以扩展css的变量,我们可以定义CSS变量的类型,初始值,继承。它是css变量更强大灵活。
CSS变量现状:
.dom {
--my-color: green;
--my-color: url('not-a-color'); // 它并不知道当前的变量类型
color: var(--my-color);
}
Houdini提供了两种自定义属性的注册方式,分别是在js和css中。
CSS.registerProperty({
name: '--my-prop', // String 自定义属性名
syntax: '<color>', // String 如何去解析当前的属性,即属性类型,默认 *
inherits: false, // Boolean 如果是true,子节点将会继承
initialValue: '#c0ffee', // String 属性点初始值
});
我们还可以在css中注册,也可以达到上面的效果
@property --my-prop {
syntax: '<color>';
inherits: false;
initial-value: #c0ffee;
}
这个API中最令人振奋人心的功能是自定义属性上添加动画,像这样:transition: --multiplier 0.4s;,这个功能我们在前面介绍什么是js in css那个demo用使用过。我们还可以使用+使syntax属性支持一个或多个类型,也可以使用|来分割。更多syntax属性值:
属性值 | 描述 |
<length> | 长度值 |
<number> | 数字 |
<percentage> | 百分比 |
<length-percentage> | 长度或百分比,calc将长度和百分比组成的表达式 |
<color> | 颜色 |
<image> | 图像 |
<url> | 网址 |
<integer> | 整数 |
<angle> | 角度 |
<time> | 时间 |
<resolution> | 分辨率 |
<transform-list> | 转换函数 |
<custom-ident> | ident |
Worklets是渲染引擎的扩展,从概念上来讲它类似于Web Workers,但有几个重要的区别:
Worklet是一个JavaScript模块,通过调用worklet的addModule方法(它是个Promise)来添加。比如registerLayout, registerPaint, registerAnimator 我们都需要放在Worklet中
//加载单个
await demoWorklet.addModule('path/to/script.js');
// 一次性加载多个worklet
Promise.all([
demoWorklet1.addModule('script1.js'),
demoWorklet2.addModule('script2.js'),
]).then(results => {});
registerDemoWorklet('name', class {
// 每个Worklet可以定义要使用的不同函数
// 他们将由渲染引擎在需要时调用
process(arg) {
return !arg;
}
});
Worklets的生命周期
Typed OM是对现有的CSSOM的扩展,并实现 Parsing API 和 Properties & Values API相关的特性。它将css值转化为有意义类型的JavaScript的对象,而不是像现在的字符串。如果我们尝试将字符串类型的值转化为有意义的类型并返回可能会有很大的性能开销,因此这个API可以让我们更高效的使用CSS的值。
现在读取CSS值增加了新的基类CSSStyleValue,他有许多的子类可以更加精准的描述css值的类型:
子类 | 描述 |
CSSKeywordValue | CSS关键字和其他标识符(如inherit或grid) |
CSSPositionValue | 位置信息 (x,y) |
CSSImageValue | 表示图像的值属性的对象 |
CSSUnitValue | 表示为具有单个单位的单个值(例如50px),也可以表示为没有单位的单个值或百分比 |
CSSMathValue | 比较复杂的数值,比如有calc,min和max。这包括子类 CSSMathSum, CSSMathProduct, CSSMathMin, CSSMathMax, CSSMathNegate 和 CSSMathInvert |
CSSTransformValue | 由CSS transforms组成的CSSTransformComponent列表,其中包括CSSTranslate, CSSRotate, CSSScale, CSSSkew, CSSSkewX, CSSSkewY, CSSPerspective 和 CSSMatrixComponent |
使用Typed OM主要有两种方法:
使用attributeStyleMap设置并获取
myElement.attributeStyleMap.set('font-size', CSS.em(2));
myElement.attributeStyleMap.get('font-size'); // CSSUnitValue { value: 2, unit: 'em' }
myElement.attributeStyleMap.set('opacity', CSS.number(.5));
myElement.attributeStyleMap.get('opacity'); // CSSUnitValue { value: 0.5, unit: 'number' };
在线demo
使用computedStyleMap
.foo {
transform: translateX(1em) rotate(50deg) skewX(10deg);
vertical-align: baseline;
width: calc(100% - 3em);
}
const cs = document.querySelector('.foo').computedStyleMap();
cs.get('vertical-align');
// CSSKeywordValue {
// value: 'baseline',
// }
cs.get('width');
// CSSMathSum {
// operator: 'sum',
// length: 2,
// values: CSSNumericArray {
// 0: CSSUnitValue { value: -90, unit: 'px' },
// 1: CSSUnitValue { value: 100, unit: 'percent' },
// },
// }
cs.get('transform');
// CSSTransformValue {
// is2d: true,
// length: 3,
// 0: CSSTranslate {
// is2d: true,
// x: CSSUnitValue { value: 20, unit: 'px' },
// y: CSSUnitValue { value: 0, unit: 'px' },
// z: CSSUnitValue { value: 0, unit: 'px' },
// },
// 1: CSSRotate {...},
// 2: CSSSkewX {...},
// }
复制代码
在线demo
开发者可以通过这个API实现自己的布局算法,我们可以像原生css一样使用我们自定义的布局(像display:flex, display:table)。在 Masonry layout library 上我们可以看到开发者们是有多想实现各种各样的复杂布局,其中一些布局光靠 CSS 是不行的。虽然这些布局会让人耳目一新印象深刻,但是它们的页面性能往往都很差,在一些低端设备上性能问题犹为明显。
CSS Layout API 暴露了一个registerLayout方法给开发者,接收一个布局名(layout name)作为后面在 CSS中使用的属性值,还有一个包含有这个布局逻辑的JavaScript类。
my-div {
display: layout(my-layout);
}
// layout-worklet.js
registerLayout('my-layout', class {
static get inputProperties() { return ['--foo']; }
static get childrenInputProperties() { return ['--bar']; }
async intrinsicSizes(children, edges, styleMap) {}
async layout(children, edges, constraints, styleMap) {}
});
await CSS.layoutWorklet.addModule('layout-worklet.js');
在线demo 目前浏览器大部分还不支持
我们可以在CSS background-image中使用它,我们可以使用Canvas 2d上下文,根据元素的大小控制图像,还可以使用自定义属性。
await CSS.paintWorklet.addModule('paint-worklet.js');
registerPaint('sample-paint', class {
static get inputProperties() { return ['--foo']; }
static get inputArguments() { return ['<color>']; }
static get contextOptions() { return {alpha: true}; }
paint(ctx, size, props, args) { }
});
在线demo
这个API让我们可以控制基于用户输入的关键帧动画,并且以非阻塞的方式。还能更改一个 DOM 元素的属性,不过是不会引起渲染引擎重新计算布局或者样式的属性,比如 transform、opacity 或者滚动条位置(scroll offset)。Animation API的使用方式与 Paint API 和 Layout API略有不同我们还需要通过new一个WorkletAnimation来注册worklet。
// animation-worklet.js
registerAnimator('sample-animator', class {
constructor(options) {
}
animate(currentTime, effect) {
effect.localTime = currentTime;
}
});
await CSS.animationWorklet.addModule('animation-worklet.js');
// 需要添加动画的元素
const elem = document.querySelector('#my-elem');
const scrollSource = document.scrollingElement;
const timeRange = 1000;
const scrollTimeline = new ScrollTimeline({
scrollSource,
timeRange,
});
const effectKeyframes = new KeyframeEffect(
elem,
// 动画需要绑定的关键帧
[
{transform: 'scale(1)'},
{transform: 'scale(.25)'},
{transform: 'scale(1)'}
],
{
duration: timeRange,
},
);
new WorkletAnimation(
'sample-animator',
effectKeyframes,
scrollTimeline,
{},
).play();
关于此API的更多内容点击这里
允许开发者自由扩展 CSS 词法分析器。
解析规则:
const background = window.cssParse.rule("background: green");
console.log(background.styleMap.get("background").value) // "green"
const styles = window.cssParse.ruleSet(".foo { background: green; margin: 5px; }");
console.log(styles.length) // 5
console.log(styles[0].styleMap.get("margin-top").value) // 5
console.log(styles[0].styleMap.get("margin-top").type) // "px"
解析CSS:
const style = fetch("style.css")
.then(response => CSS.parseStylesheet(response.body));
style.then(console.log);
它将提供一些方法来测量在屏幕上呈现的文本元素的尺寸,将允许开发者控制文本元素在屏幕上呈现的方式。使用当前功能很难或无法测量这些值,因此该API将使开发者可以更轻松地创建与文本和字体相关的CSS特性。例如:
了解到这里,部分开发者可能会说:“我不需要这些花里胡哨的技术,并不能带收益。我只想简简单单地写几个页面,做做普通的Web App,并不想试图干预浏览器的渲染过程从而实现一些实验性或炫酷的功能。”如果这样想的话,我们不妨退一步再去思考。回忆下最近做过的项目,用于实现页面效果所使用到的技术,grid布局方式在考虑兼容老版本浏览器时也不得不放弃。我们想控制浏览器渲染页面的过程并不是仅仅为了炫技,更多的是为了帮助开发者们解决以下两个问题:
几年过后再回眸,当主流浏览器完全支持Houdini的时候。我们可以在浏览器上随心所欲的使用任何CSS属性,并且他们都能完美支持。像今天的grid布局在旧版本浏览器支持的并不友好的这类问题,那时我们只需要安装对应的Polyfill就能解决类似的问题。
如在文中发现有误之处,欢迎反馈、纠正。
写CSS的常用套路(下篇)...
点击观看——我写CSS的常用套路(上篇)...
为盒子添加阴影,增加盒子的立体感,可以多层叠加,并且会使阴影更加丝滑
本demo地址:Pagination
注意到box-shadow还有个inset,用于盒子内部发光
利用这个特性我们可以在盒子内部的某个范围内设定颜色,做出一个新月形
再加点动画和滤镜效果,“猩红之月”闪亮登场!
注意到它散发着淡淡的红光,其实就是2个伪元素应用了模糊滤镜所产生的效果
本demo地址:Crimson Crescent Loading
文本阴影,本质上和box-shadow相同,只不过是相对于文本而言,常用于文本发光,也可通过多层叠加来制作霓虹文本和伪3D文本等效果
本demo地址:Staggered GlowIn Text
本demo地址:Neon Text
本demo地址:Staggered Bouncing 3D Loading
能将背景裁剪成文字的前景色,常用来和color: transparent配合生成渐变文本
本demo地址:Menu Hover Fill Text
渐变可以作为背景图片的一种,具有很强的色彩效果,甚至可以用来模拟光
线性渐变是笔者最常用的渐变
这个作品用到了HTML的dialog标签,线性渐变背景,动画以及overflow障眼法,细心的你看出来了吗:)
本demo地址:Confirm Modal
径向渐变常用于生成圆形背景,上面例子中Snow的背景就是一个椭圆形的径向渐变
此外,由于背景可以叠加,我们可以叠加多个不同位置大小的径向渐变来生成圆点群,再加上动画就产生了一种微粒效果,无需多余的div元素
本demo地址:Particle Button
圆锥渐变可以用于制作饼图
用一个伪元素叠在饼图上面,并将content设为某个值(这个值通过CSS变量计算出来),就能制作出度量计的效果,障眼法又一次完成了它的使命
本demo地址:Gauge (No SVG)
PS里的滤镜,blur最常用
当blur滤镜和contrast滤镜一起使用时,会产生一种融合(gooey)的奇特效果
本demo地址:Snow Scratch
对背景应用滤镜,产生毛玻璃的效果
本demo地址:Frosted Glass
PS里的混合模式,常用于文本在背景下的特殊效果
以下利用滤色模式(screen)实现文本视频蒙版效果
本demo地址:Video Mask Text
PS里的裁切,可以制作各种不规则形状。如果和动画结合也会相当有意思
本demo地址:Name Card Hover Expand
由于clip-path有裁切功能,因此可以将多个文字叠在一起,并按比例裁切成多分,再应用交错动画,就能制作出酷炫的故障效果(glitch)。
本demo地址:Cross Bar Glitch Text
PS里的遮罩。所谓遮罩,就是原始图片只显示遮罩图片非透明的部分
虽然clip-path能裁切出形状,但它无法镂空,因为形状的里面它管不着
可能有人(包括我)会用伪元素来“模拟”镂空(通过设置同样的背景色),但这样并非真的镂空,换了个背景或浮在图片上就会暴露出来,这时我们就要求助于遮罩了
假设,你想制作一个空心的圆环,那么你只需将一个径向渐变作为元素的遮罩,并且第一个color-stop设置为透明,其他的color-stop设置为其他颜色即可,因为遮罩的定义就是只显示遮罩图片非透明的部分
注意:为了消除锯齿,这个径向渐变的中间需要有一个额外的color-stop用于缓冲,长度设置为原长度加0.5px即可
本demo地址:Circle Arrow Nav
投影效果,不怎么常用,适合立体感强的作品
本demo地址:Card Flip Reflection
虽然这并不是一个CSS特性,但是它经常用于完成那些CSS所做不到的事情
那么何时用它呢?当CSS动画中有属性无法从CSS中获取时,自然就会使用到它了
目前CSS还尚未有获取鼠标位置的API,因此考虑用JS来进行
通过查阅相关的DOM API,发现在监听鼠标事件的API中,可通过e.clientX和e.clientY来获得鼠标当前的位置
既然能够获取鼠标的位置,那么跟踪鼠标的位置也就不是什么难事了:通过监听mouseenter和mouseleave事件,来获取鼠标出入一个元素时的位置,并用此坐标来当作鼠标的位移距离,监听mousemove事件,来获取鼠标在元素上移动时的位置,同样地用此坐标来当作鼠标的位移距离,这样一个跟踪鼠标的效果就实现了
本demo地址:Menu Hover Image
CSS Houdini是CSS的底层API,它使我们能够通过这套接口来扩展CSS的功能
目前来说,我们无法直接给渐变添加动画,因为浏览器不理解要改变的值是什么类型
这时,我们就可以利用CSS.registerProperty()来注册我们的自定义变量,并声明其语法类型(syntax)为颜色类型<color>,这样浏览器就能理解并对颜色应用插值方法来进行动画
还记得上文提到的圆锥渐变conic-gradient()吗?既然它可以用来制作饼图,那么我们能不能让饼图动起来呢?答案是肯定的,定义三个变量:--color1、--color2和--pos,其中--pos的语法类型为长度百分比<length-percentage>,将其从0变为100%,饼图就会顺时针旋转出现
利用绝对定位和层叠上下文,我们可以叠加多个从小到大的饼图,再给它们设置不同的颜色,应用交错动画,就有了下面这个炫丽的效果
本demo地址:Mawaru
将交错动画和伪类伪元素结合起来写出来的慎重勇者风格的菜单
本demo地址:Shinchou Menu
具的选择不是越多人用越好。像Axure这类专业的原型制作软件,在标尺、对齐上面,是iH5远比不上的。但后者在与人沟通以及查看时有着优势。
最近支付宝进行了一次比较大的版本更新,引起了很多人的关注,无论从视觉还是交互体验上都有了很大的改变,但是从原型制作来说,其实比之前更加简单了。
通过临摹这些知名厂商的产品,是快速进步的一种方式,可以更好的借鉴他们的交互与逻辑,比体验观看,更能深刻的理解,从而更好的改进自己的产品。
说到原型,很多人就会想到Axure,这类常用的原型工具,他们使用的都是HTML规范,动效以及交互大部分引入的jQuery库或者封装的JS库,其效果可以在软件预览以及导出HTML文件在浏览器预览,同样类别的还有Justinmind等等。
与其原理类似的也有很多,如定位于开发工具的Animate,其动画效果比Axure要强大的多,以及谷歌的web design,还有向国内线上制作平台的iH5,他们面向的是基础交互,可自由编辑度高,不仅可以做出H5实例,同样也可以用于原型。
这些工具可以分为线上与离线两种制作方式,我个人偏向使用线上的制作工具,因为离线工具生成的原型不方便发给别人那看,而且制作严重依赖于软件安装,不便与即时查看与他人沟通。
对于临摹来说,事先无需过多分析,了解一下大致交互,直接上手就可以。
工具:iH5
材料准备:用手机把新版支付截屏,调到PS用切片把其分为各个模块,如导航栏,对话框等等。
描述:底部导航的切换交互,跳转页面并且图标变为蓝色。可以用点击事件控制两种状态,一是遮罩的移动,二是页面的跳转。
新建时间轴,在其下面新建一个画布,将蓝色与灰色两个底部导航栏,以及一个与图标大小的白色矩形,放入其中,在蓝色导航图片的属性——遮罩,选择白色矩形。然后在矩形下建立轨迹,四个关键帧分别对应四个图标。
在舞台上建四个页面用来存放导航里的内容。
在图标位置添加透明按钮,在其下方添加事件,当轻触时跳转页面,以及跳转到该时间轴的某一帧。
描述:当页面滑动一段距离,顶部导航栏发生变换。通过监测时间轴滑动的时间,来触发菜单栏的更换。
在页面一,新建滑动时间轴,所有素材放入其中,将两个顶部导航叠放在一起,为内容添加轨迹,让其让下滑动,一秒时滑动到底部,为初始默认的导航添加两个事件,当滑动时间轴滑动0.1秒时,置于底层,当时间为0时,至于顶层。
描述:类似图片的轮播效果,将未显示的图片放在屏幕之外或者隐藏,通过手指的左右滑动来控制图片的进出。
在页面二,把准备好三张轮播的图,带有三个镂空圆点的PNG图片,以及一个大小与圆点差不多的矩形,都放入滑动时间轴下面,新建轨迹,创建三个关键帧,通过调整图片坐标来控制在每一帧上显示的图片,当你滑动时,就会出现图片的轮播效果,如果需要在图片上加入链接,直接在该图片添加点击事件,动作设置为跳转页面即可。
描述:点击按钮时弹框出现,然后点击其周围弹框消失。为弹框设置好动画之后,利用事件,来控制其正向与反向播放即可。
在页面三上,准备一张黑背景图片以及设计好的弹框,将其放入时间轴下,利用轨迹制作出想要的效果,在背景上的“+”添加一个透明按钮,增加一个事件,当点击时让该时间轴从头播放,然后在黑色背景下添加同样的事件,把动作改为反向播放。
其他的一些交互无外乎都是页面的跳转,原型制作上并没有多少压力。
制作完成之后,就可发布了,会生成一个网址链接与二维码,可以在线编辑与查看。
工欲善其事,必先利其器,工具的选择不是越多人用越好。像Axure这类专业的原型制作软件,在标尺、对齐上面,是上面所使用的iH5远比不上的(后者得通过x|y坐标等精确控制),而且其还拥有众多有手机图标等模板,可以大大加快原型制作,但后者在与人沟通以及查看时有着优势,并且随着H5营销的流行,其也可以快速制作出相关的营销案例,并迅速在网上传播,不用再依靠别人,或者重新学习其他制作工具,拥有更多的可操作性。
本文由 @iood 原创发布于人人都是产品经理。未经许可,禁止转载。
*请认真填写需求信息,我们会在24小时内与您取得联系。