- Omi 6.0 - Store 的设计哲学
写在前面 Store 是 Omi 内置的中心化数据仓库,他解决了下面两个问题: 组件树数据共享 数据变更按需更新依赖的组件 [图片] 而这一切都是在运行时搞定。 一段代码完全上手 Store [代码]import { render, WeElement, define } from 'omi' define('my-counter', class extends WeElement { static use = [ { count: 'count' } ] add = () => this.store.add() sub = () => this.store.sub() addIfOdd = () => { if (this.use.count % 2 !== 0) { this.store.add() } } addAsync = () => { setTimeout(() => this.store.add(), 1000) } render() { return ( <p> Clicked: {this.use.count} times {' '} <button onClick={this.add}>+</button> {' '} <button onClick={this.sub}>-</button> {' '} <button onClick={this.addIfOdd}> Add if odd </button> {' '} <button onClick={this.addAsync}> Add async </button> </p> ) } }) render(<my-counter />, 'body', { data: { count: 0 }, sub() { this.data.count-- }, add() { this.data.count++ }, }) [代码] 这是一个简单的例子,说明了 store 体系的基本用法: 通过 [代码]static use[代码] 声明依赖的 path [代码]store[代码] 通过 render 的第三个参数从根节点注入到所有组件。 调用组件的方法或者直接改变组件的 data 进行视图更新 这里在书写过程中会出现两种方式,一种是将所有数据和逻辑放在 store 里,一种是将部分共享数据放在 store 里,这里没有强制要求使用哪种方式,omi 这两种能力都有,开发者偏爱哪种方式就使用哪种方式。 复杂的例子 Store 里的 data: [代码]{ count: 0, arr: ['china', 'tencent'], motto: 'I love omi.', userInfo: { firstName: 'dnt', lastName: 'zhang', age: 18 } } [代码] Static use: [代码]static use = [ 'count', //直接字符串,可通过 this.use[0] 访问 'arr[0]', //也支持 path,可通过 this.use[1] 访问 //支持 json { //alias,可通过 this.use.reverseMotto 访问 reverseMotto: [ 'motto', //path target => target.split('').reverse().join('') //computed ] }, { name: 'arr[1]' }, //{ alias: path },可通过 this.use.name 访问 { //alias,可通过 this.use.fullName 访问 fullName: [ ['userInfo.firstName', 'userInfo.lastName'], //path array (firstName, lastName) => firstName + lastName //computed ] }, ] [代码] 下面看看 JSX 中使用: [代码]... ... render() { return ( <div> <button onClick={this.sub}>-</button> <span>{this.use[0]}</span> <button onClick={this.add}>+</button> <div> <span>{this.use[1]}</span> <button onClick={this.rename}>rename</button> </div> <div>{this.use.reverseMotto}</div><button onClick={this.changeMotto}>change motto</button> <div>{this.use.name}</div> <div>{this.use[3]}</div> <div> {this.use.fullName} <button onClick={this.changeFirstName}>change first name</button> </div> </div> ) } ... ... [代码] 如果不带有 alias ,你也可以直接通过 [代码]this.store.data.xxx[代码] 访问。 Path 命中规则 当 [代码]store.data[代码] 发生变化,依赖变更数据的组件会进行更新,举例说明 Path 命中规则: Proxy Path(由数据更改产生) static use 中的 path 是否更新 abc abc 更新 abc[1] abc 更新 abc.a abc 更新 abc abc.a 不更新 abc abc[1] 不更新 abc abc[1].c 不更新 abc.b abc.b 更新 以上只要命中一个条件就可以进行更新! 总结: 只要注入组件的 path 等于 use 里声明 或者在 use 里声明的其中 path 子节点下就会进行更新! 解构赋值 [代码]import { define, WeElement } from 'omi' import '../my-list' define('my-sidebar', class extends WeElement { static css = require('./_index.css') static use = [ 'menus', 'sideBarShow', 'lan' ] render() { const [menus, sideBarShow, lan] = this.use return ( <div class={`list${sideBarShow ? ' show' : ''}`}> {menus[lan].map((menu, index) => ( <my-list menu={menu} index={index} /> ))} </div> ) } }) [代码] 这里举了个例子使用 ES2015+ 语法 [代码]const [xx, xxx] = xxxx[代码] 的语法快速赋值。上面是从 omi docs 的源码里截取的部分。感兴趣的可以看看源码。omi 官网已经使用 omi 6.0 重写了。 设计哲学 回顾 Omi 从 1.0 到 6.0: Omi 1.0 运行时动态模板引擎 Omi 2.0 拥抱虚拟 DOM 和运行时 scoped style Omi 3.0 提供 native 模块调用 bridge Omi 4.0 拥抱 Web Components Omi 5.0 纠正社区对 MVVM 误解 Omi 6.0 拥抱多端统一,迎来全新 path updating 的 store 体系 1.0 使用的动态模板引擎,是图灵完备的,可以表达一切你想表达的结构。由于是运行时,没法转虚拟 DOM,一定要转也可以,开销大,所以缺点很明显,视图更新开销大,依赖真实 DOM 之间的的 diff,另外一个缺点就是动态模板引擎(指令、模板语法都可以动态拼接)需要在脑海里二次转换,书写起来不够直观、智能提示也没有,不如 JSX 直接干净和智能。而为什么要这么设计,从整个发展历程来看离不开三个字: 运行时。 Omi 的设计 1.0 败也败在运行时,成也成在运行时。从 2.0 开始,除了 JSX 的部分(当然可以直接 hyperscript),其余全部 运行时 搞定: 运行时的 scoped style 运行时的 path updating 局部刷新 而到了 9210 年,JSX 也出现了运行时的替代方案:htm 。 为何如此偏爱运行时?而不交给编译器去做?这个仁者见仁,智者见智,而且有个权衡在里面。 当运行时的开销对于用户体验可以忽略不计,那么就选择运行时去做 运行时的好处非常明显,不需要任何构建工具、编译工具,就可以在浏览器、node、javascript core 或者任何 javascript 环境直接运行。凭什么让我学那么多构建工具、凭什么和一堆工具耦合在一起,我就是纯粹的 js,想在哪里跑都可以轻松复制粘贴或者直接 import/require 过去,而不强制带上任何工具。当然这里不是反对编译工具对前端带来的价值,omi-cli、omip、omi-mp 都大量使用了编译工具,只是没有编译工具,omi 也能运行良好,简单移植,比如 es module,比如 deno,直接 import 直接使用。 开始使用 omijs.org https://github.com/Tencent/omi
2019-03-25 - Lottie-前端实现AE动效
项目背景 在海外项目中,为了优化用户体验加入了几处微交互动画,实现方式是设计输出合成的雪碧图,前端通过序列帧实现动画效果: [图片] 序列帧: [图片] 动画效果: [图片] 序列帧: [图片] 帧动画的缺点和局限性比较明显,合成的雪碧图文件大,且在不同屏幕分辨率下可能会失真。经调研发现,Lottie是个简单、高效且性能高的动画方案。 Lottie是可应用于Android, iOS, Web和Windows的库,通过Bodymovin解析AE动画,并导出可在移动端和web端渲染动画的json文件。换言之,设计师用AE把动画效果做出来,再用Bodymovin导出相应地json文件给到前端,前端使用Lottie库就可以实现动画效果。 [图片] Bodymovin插件的安装与使用 关闭AE 下载并安装ZXP installer https://aescripts.com/learn/zxp-installer/ 下载最新版bodymovin插件 https://github.com/airbnb/lottie-web/blob/master/build/extension/bodymovin.zxp 把下载好的bodymovin.zxp拖到ZXP installer [图片] 打开AE,在菜单首选项->常规中勾选☑️允许脚本写入文件和访问网络(否则输出JSON文件时会失败) [图片] 在AE中制作动画,打开菜单窗口->拓展->Bodymovin,勾选要输出的动画,并设置输出文件目录,点击render [图片] 打开输出目录会看到生成的JSON文件,若动画里导入了外部图片,则会在images中存放JSON中引用的图片 前端使用lottie 静态URL https://cdnjs.com/libraries/lottie-web NPM [代码]npm install lottie-web [代码] 调用loadAnimation [代码]lottie.loadAnimation({ container: element, // 容器节点 renderer: 'svg', loop: true, autoplay: true, path: 'data.json' // JSON文件路径 }); [代码] vue-lottie 也可以在vue中使用lottie [代码] import lottie from '../lib/lottie'; import * as favAnmData from '../../raw/fav.json'; export default { props: { options: { type: Object, required: true }, height: Number, width: Number, }, data () { return { style: { width: this.width ? `${this.width}px` : '100%', height: this.height ? `${this.height}px` : '100%', overflow: 'hidden', margin: '0 auto' } } }, mounted () { this.anim = lottie.loadAnimation({ container: this.$refs.lavContainer, renderer: 'svg', loop: this.options.loop !== false, autoplay: this.options.autoplay !== false, animationData: favAnmData, assetsPath: this.options.assetsPath, rendererSettings: this.options.rendererSettings } ); this.$emit('animCreated', this.anim) } } [代码] loadAnimation参数 参数名 描述 container 用于渲染动画的HTML元素,需确保在调用loadAnimation时该元素已存在 renderer 渲染器,可选值为’svg’(默认值)/‘canvas’/‘html’。svg支持的功能最多,但html的性能更好且支持3d图层。各选项值支持的功能列表在此 loop 默认值为true。可传递需要循环的特定次数 autoplay 自动播放 path JSON文件路径 animationData JSON数据,与path互斥 name 传递该参数后,可在之后通过lottie命令引用该动画实例 rendererSettings 可传递给renderer实例的特定设置,具体可看 Lottie动画监听 Lottie提供了用于监听动画执行情况的事件: complete loopComplete enterFrame segmentStart config_ready(初始配置完成) data_ready(所有动画数据加载完成) DOMLoaded(元素已添加到DOM节点) destroy 可使用addEventListener监听事件 [代码]// 动画播放完成触发 anm.addEventListener('complete', anmLoaded); // 当前循环播放完成触发 anm.addEventListener('loopComplete', anmComplete); // 播放一帧动画的时候触发 anm.addEventListener('enterFrame', enterFrame); [代码] 控制动画播放速度和进度 可使用anm.pause和anm.play暂停和播放动画,调用anm.stop则会停止动画播放并回到动画第一帧的画面。 使用anm.setSpeed(speed)可调节动画速度,而anm.goToAndStop(value, isFrame)和anm.goToAndPlay可控制播放特定帧数,也可结合anm.totalFrames控制进度百分比,比如可传anm.totalFrames - 1跳到最后一帧。 [代码]anm.goToAndStop(anm.totalFrames - 1, 1); [代码] 这样的好处是可以把相关联的JSON文件合并,通过anm.goToAndPlay控制动画状态的切换,如下图例中一个JSON文件包含了2个动画状态的数据: [图片] 图片资源 JSON文件里assets设置了对图片的引用: [图片] 若想统一修改静态资源路径或者设置成绝对路径,可在调用loadAnimation时传入assetsPath参数: [代码]lottie.loadAnimation({ container: element, renderer: 'svg', path: 'data.json', assetsPath: 'URL' // 静态资源绝对路径 }); [代码] 功能支持列表 即使用bodymovin成功输出了JSON文件(没有报错),也会出现动效不如预期的情况,比如这是在AE中构建的形象图: [图片] 但在页面中渲染效果是这样的: [图片] 这是因为使用了不支持的Merge Paths功能 [图片] 因此对设计师而言,创建Lottie动画和往常制作AE动画有所不同,此文档记录了Bodymovin支持输出的AE功能列表,动画制作前需跟设计师沟通好,根据动画加载平台来确认可使用的AE功能。 除此之外,尽量遵循官方文档里对设计过程的指导和建议: 动画简单化。创建动画时需时刻记着保持JSON文件的精简,比如尽可能地绑定父子关系,在相似的图层上复制相同的关键帧会增加额外的代码,尽量不使用占用空间最多的路径关键帧动画。诸如自动跟踪描绘、颤动之类的技术会使得JSON文件变得非常大且耗性能。 建立形状图层。将AI、EPS、SVG和PDF等资源转换成形状图层否则无法在Lottie中正常使用,转换好后注意删除该资源以防被导出到JSON文件。 设置尺寸。在AE中可设置合成尺寸为任意大小,但需确保导出时合成尺寸和资源尺寸大小保持一致。 不使用表达式和特效。Lottie暂不支持。 注意遮罩尺寸。若使用alpha遮罩,遮照的大小会对性能产生很大的影响。尽可能地把遮罩尺寸维持到最小。 动画调试。若输出动画破损,通过每次导出特定图层来调试出哪些图层出了问题。然后在github中附上该图层文件提交问题,选择用其他方式重构该图层。 不使用混合模式和亮度蒙版。 不添加图层样式。 全屏动画。设置比想要支持的最宽屏幕更宽的导出尺寸。 设置空白对象。若使用空白对象,需确保勾选可见并设置透明度为0%否则不会被导出到JSON文件。 预览效果 由于以上所说的功能支持问题会导致输出动画效果不确定性,设计师和前端之间有个动画效果联调的过程,为了提高联调效率,设计师可先进行初步的效果预览,再把文件交付给前端。 方法1:输出预览HTML文件 渲染前设置所要渲染的文件 [图片] 勾选☑️Demo选项 [图片] 在输出的文件目录中就可找到可预览的demo.html文件 方法2:LottieFiles分享平台 把生成的JSON文件传到LottieFiles平台,可播放、暂停生成文件的动画效果,可设置图层颜色、动画速度,也可以下载lottie preview客户端在iOS或Android机子上预览。 [图片] LottieFiles平台还提供了很多线上公开的Lottie动画效果,可直接下载JSON文件使用 [图片] 交互hack Lottie的不足之处是没有对应的API操纵动画层,若想做更细化的动画处理,只能直接操作节点来实现。比如当播放完左图动画进入惊讶状态后,若想实现右图随鼠标移动而控制动画层的简单效果: [图片][图片] 开启调试面板可以看到,lottie-web通过使用<g>标签的transform属性来控制动画: [图片] 当元素已添加到DOM节点,找到想要控制的<g>标签,提取其transform属性的矩阵值,并使用rematrix解析矩阵值。 [代码]onIntroDone() { const Gs = this.refs.svg.querySelectorAll('svg > g > g > g'); Gs.forEach((node, i) => { // 过滤需要修改的节点 ... // 获取transform属性值 const styleArr = node.getAttribute('transform').split(','); styleArr[0] = styleArr[0].replace('matrix(', ''); styleArr[5] = styleArr[5].replace(')', ''); const style = `matrix(${styleArr[0]}, ${styleArr[1]}, ${styleArr[2]}, ${styleArr[3]}, ${styleArr[4]}, ${styleArr[5]})`; // 使用Rematrix解析 const transform = Rematrix.parse(style); this.matrices.push({ node, transform, prevTransform: transform }); } } [代码] 监听鼠标移动,设置新的transform属性值。 [代码]onMouseMove = (e) => { this.mouseCoords.x = e.clientX || e.pageX; this.mouseCoords.y = e.clientY || e.pageY; let x = this.mouseCoords.x - (this.props.browser.width / 2); let y = this.mouseCoords.y - (this.props.browser.height / 2); const diffX = (this.mouseCoords.prevX - x); const diffY = (this.mouseCoords.prevY - y); this.mouseCoords.prevX = x; this.mouseCoords.prevY = y; this.matrices.forEach((matrix, i) => { let translate = Rematrix.translate(diffX, diffY); const product = [matrix.prevTransform, translate].reduce(Rematrix.multiply); const css = `matrix(${product[0]}, ${product[1]}, ${product[4]}, ${product[5]}, ${product[12]}, ${product[13]})`; matrix.prevTransform = product; matrix.node.setAttribute('transform', css); }) } [代码] 进一步优化 看到一个方法,在AE中将图层命名为[代码]#id[代码]格式,生成的SVG相应的图层id会被设置为id,命名为[代码].class[代码]格式,相应的图层class会被设置为class [图片] 试了下的确可以,如下图,因此可通过这个方法快速找到需要操作的动画层,进一步简化代码: [图片] 小结 Lottie的缺点在于若在AE动画制作的过程不注意规范,会导致数据文件大、耗内存和性能的问题;Lottie-web的官方文档不够详尽,例如assetsPath参数是在看源码的时候发现的;开放的API不够齐全,无法很灵活地控制动画层。 而优点也很明显,Lottie能帮助提高开发效率,精简代码,易于调试和维护;资源文件小,输出动画效果保真;跨平台——Android, iOS, Web和Windows通用。 总的来说,Lottie的引用可以替代传统的GIF和帧动画,灵活利用好提供的属性和方法可以控制动画的播放,但需注意规范设计和开发的流程,才可以更高效地完成动画的制作与调试。
2019-03-25