Open otakustay opened 8 years ago
看起来除了一些细节可能要斟酌,复用是很容易。我唯一担心的是,如果采用这个组件构架,组件开发者是否能贯彻这个思路不玩脱
这正是我在最后一段里想表达的……
在我看来,未来的2-3年间,趋向于纯函数和函数式会是一个大方向,然现阶段在我厂做实施确实有点前卫
但如果现在这个点不做一些激进的事,过了2-3年我们无非又是背着“落后、老旧、跟不上社区理念”这样的抱怨罢了……
@errorrik 我想的是,我们之前沟通的那个vm核心,是否有可能再提取一个更小的核心出来(估计就是一个模板Parser和data -> repaint的工具),然后我们可以尝试在这个核心的基础上尝试各种不同的方案,哪个死哪个活让TC或者更广泛的大家来评判和选择,之后我们再集中精力于活的那个上面
看起来跟 react + redux 比较像
看起来跟 react + redux 比较像
是的,看上去就是把一个redux架构放进了一个组件里面去,但省掉了action creator和action这些概念,用了更简单粗暴直接是了的绑定逻辑
核心的理念更为相似,我认为一个组件无非:
这是一个很纯粹的东西,根本没有这么多的别的事参杂在里面,整体就是状态的迁移,也不会产生乱七八糟的的中间状态
顺便说一下,把这个设计出来的组件,在外面再用一个统一的逻辑包一下,就能变成一个真正的VM型的组件……
其实小朋友们并不care你怎么玩的,能糊业务出来就行了, 至于架构扩展神马的还不是我们这帮人要写代码。
所以我想先做个能run起来的东西,暴露较少application必须接口,然后我们内部结构调整,为扩展暴露更多核心东西。
我对「父子组件之间数据交互」有几个困惑
比如 <Tweet><Like/></Tweet>
这样
Tweet
数据变化时,根据绑定关系将部分传给Like
,Like
拿到数据后看引用是否相同,不同就说明要刷视图,就用vdom还是啥的刷呗。这个过程没有reduce
,Like
的reduce
用来处理Like
上发生的事件,Tweet
的reduce
用来处理Tweet
上发生的事件,而datachange
不在这里的狭义的“事件”的范围里(不然死循环了啊)fire('like-click')
,因为B知道这个数据是来自外部的,不是自己创建出来的,所以不是这个数据的owner,自然不能改;快捷的玩法就是上双绑因为B知道这个数据是来自外部的,不是自己创建出来的
其实这还是state
和prop
的区别,但我并不采用react的这种明确区分的方案,我的思路是绑定,所以就会变成:
也就是说,一个数据是prop还是state,其实是由使用它的人决定的“是不是有双绑”,而不是这个组件开发者就作了决定
对于所有东西都是 prop 这个事情有一个困惑。
比如一个 Calendar 组件,这个组件自己有一个浮层,展开/收起状态一个和业务数据无关的数据,它应该保存在哪里?如果没有 state 只有 prop 那也就只能和业务数据一起、放到的 model / store 里边么?
那我有多个 Calendar 呢?
另外,这个组件应该如何应对单向绑定、双向绑定、单次绑定,是外部声明时指定的?这几种类型的绑定和 reduce 是什么关系?
我们抛开react和它的state
、prop
这些概念,然后让我详细的解释一下这个事情,下文所说的“状态”不指react中的state
,是一个纯粹的概念
在UI的领域,其核心始终是一个状态表达一个视图界面,用函数的话来说,就是F(state) => view
,这个状态我们称为视图状态
同样的,在业务的领域,我们有一个状态表达业务的上下文,我们称为应用状态
事实上,我们真正关心的,并不是“视图状态分为公开的和私有的”这回事,对于整个应用系统,我们关心的是一件事:视图状态中感兴趣的部分与应用状态是同步的
那么这里就产生了一个问题,这个感兴趣的部分到底是什么,是谁感兴趣,谁来决定?我的论断就是:由实际的业务开发者决定
因此,视图或者说组件并没有资格自己决定哪部分可以与应用状态同步,哪部分不能同步。或者换一个更现实的说法,视图应该能做到状态完全同步的情况下,随时刷新都保持和刷新前像素级的相同。为此,视图不可能有所谓的内部状态,因为持有内部状态意味着“视图自行决定了可保持同步的部分”也意味着“刷新后像素级同步”不再可能
针对上面说的实例:
比如一个 Calendar 组件,这个组件自己有一个浮层,展开/收起状态一个和业务数据无关的数据,它应该保存在哪里?
这个浮层展开/收起的状态恰恰不应该是一个内部状态,因为Calendar
完全没有理由去假定外部不想保持这个状态的同步,而在现实中我们也很容易举出一个反例:
作为一个酒店预订系统,当用户填写了半个表单,正在选择离店日期时,不小心手贱按到了刷新按钮。在所有状态保持同步的情况下,刷新后的页面会自动滚动到离店日期这个
Calendar
所在的位置,Calendar
的浮层处于打开状态,让用户可以很自然地回忆起“我刚刚正在填写离店日期”这个事实
显然在这个场景下,Calendar
的使用者对“浮层展开/收起”这个状态是有同步的需求的,它希望可以存储这个状态以在一定情况下恢复视图。那么自然Calendar
不能把这个状态藏在自己内部硬说你就不能这么干,不爽不要用我……
因此,在一个绑定为基础的环境下,有以下的特点:
这样一个组件是“自治”的,没了谁他都能活。而如果你对这个组件的部分或全部状态感兴趣希望保持同步,就可以使用双向绑定来进行这些状态的同步,如value <-> checkOutDate
,那么Calendar
的值发生变化时,checkOutDate
也相应同步其值
如果你对组件的另一些状态不感兴趣,也不想对其进行操作,那么使用单次绑定、常量绑定甚至不进行绑定(使用组件默认初始值)即可,之后这个组件的状态你无法获知,这种组件自生自灭,用react的话来说,uncontrolled
如果你并不对一个组件的某个状态当前的值感兴趣,但是在需要的时候要命令组件进行这个状态的改变,那么使用单向绑定即可。单向绑定会导致被绑定的属性和绑定源属性在某些时候是不同步的(组件自己修改了状态,因没有双向绑定未进行同步),但这也是使用者自己的选择(事实上很少会出现这情况)
当然用这种全开放的设计,相比react允许开发者保持一些私有状态,一定会增加一些复杂度,比如说多个属性之间有相互的约束,那么它们一部分被绑定一部分未被绑定,就容易产生混乱,这比较考验组件开发者的能力
以上说的是与视图相关的,即决定最终组件的外观的状态。当然我也明白组件会在一些情况下真的想要私有的状态:
Set
来辅助某些查找逻辑这种情况下,我给的解决方案是:使用Symbol
,绑定并不会读取放在状态上的各个Symbol
所以可以创造出私有的内容来,但也同时要注意这样做的代价是:
Symbol
的reduce
函数不能被复用(除非你把Symbol
也一起分享出去)再来说reduce
这东西,我想是我没解释清楚reduce
是个啥……
用最直观的话来说,reduce
是event handler,用于处理模板上写的on-click="updateName"
,此时就对应一个叫updateName
的reduce
,这个元素被点击时就调用这个函数,计算出新的data
,用新的data
刷新视图
reduce
和绑定没有任何关系(除非作死写on-data-change="theFuckingReducer"
,请务必不要作死)
绑定则是在模板中声明的,这个和vue或者ng很相似,我也不作特别详细的描述了,对于组件来说,绑定是透明的,它不需要去应对或者实现绑定,当reduce
产生一个新的data
时,绑定层会自动将新的和老的data
进行对比(因为是immutable,对比非常快速),取出相应的变化,通过从模板上解析到的绑定关系将这些变化分发出去
双向绑定是标准的一个单向绑定加一个匿名的reduce
(目标组件发生data
变化事件时更新自己的data
)
在之前的概念中组件自身是个状态机,自身维护其状态;但是在新的框架下组件并不是一个状态机,而是一个渲染函数F(state) => view。状态的维护通过 vm/store 来完成,vm/store 的状态迁移是通过 function/immutable 完成的。这个我理解了。
对于各种绑定,我梳理一下,不知道是不是正确:
如果是上边这样描述的情况,Like 在点击的时候为了保证自己正确,应该怎么处理?类似这样?
function reduce(data, fire) {
// 单向绑定
if (this.vm.isDanbang(data.like)) {
fire('like-click');
// 自己不+1,坐等 Tweet 同步我
return data;
}
// 双绑 or 单次 or uncontrolled
return set(data, 'like', data.like + 1);
}
或者乐观更新?
function reduce(data, fire) {
// 告诉上边被赞了
fire('like-click');
// 我先更新了啊,上边你要是觉得不对,你再 reduce 一下吧
return set('data', 'like', data.like + 1);
}
是乐观更新,且仅仅要同步数据的话,不需要触发任何事件,绑定层会帮你做掉:
Like
确保自己正确始终是一个标准的reduce
:
import {set} from 'diffy-update';
class Like {
events = {
likeIt(data) {
set(data, 'like', data.like + 1)
}
}
}
模板里:
<button type="button" on-click="likeIt">👍</button>
然后Tweet
的模板:
<!-- :foo 表示双绑 -->
<Like ui-like=":likeCount" />
之后绑定性会有这个逻辑:
reduce
后得到新的数据like
值有变化,从100变成了101Like
的视图,这里应该是vdom或者其它机制Like
组件的like
与Tweet
组件的likeCount
是双绑关系Tweet
的数据的likeCount
变为101(Immutable更新,变成一个新的数据对象)Tweet
的updateData
(类似react的setState
吧)给新的数据更新视图likeCount
这个101送给Like
的like
属性,但Like
会判断值是一样的所以就不会再更新自己了Tweet
的likeCount
也有更外层的双绑,回第4步继续走所以说数据的绑定同步是隐式的,并不需要任何的事件的触发和监听(内部其实有一个类似data-change
之类的事件吧,应该)
事件只是用来干类似click
啊drag
啊这种不会影响自身数据变化的事情的
简单来说,一个组件始终保持自己的状态和视图是同步的,组件外面的同步就交给绑定来走了,有双向绑定的情况下,这个状态会一路向上同步直到应用状态里,没有双向绑定那就脱节了也没办法,但组件自己还是和视图一致的
那么我是不是可以这样理解:组件状态中的所有数据,组件不必关注是不是与应用状态绑定的。如果与应用状态脱节的,那么组件不需要去管『脱节数据』要不要与上层组件or应用状态的同步。这些『脱节数据』就相当于是一个『私有』的状态,自己玩好就行了。
还有另外一个问题,子组件得到的数据是 computed,那么 reduce 时应当如何处理?
例如:<Like ui-like=":likeCount * 1000" />
;
那么我是不是可以这样理解:组件状态中的所有数据,组件不必关注是不是与应用状态绑定的。如果与应用状态脱节的,那么组件不需要去管『脱节数据』要不要与上层组件or应用状态的同步。这些『脱节数据』就相当于是一个『私有』的状态,自己玩好就行了
是的,是不是“私有”的就是决定于外部怎么看你
还有另外一个问题,子组件得到的数据是 computed,那么 reduce 时应当如何处理?
:likeCount * 1000
是个绑定表达式,解析这个表达式后,绑定层会关心likeCount
的变化,发生变化时调用like.updateData({like: likeCount * 1000})
有表达式的属性是不能双向绑定的,我可不负责给你从乘法变成除法反着算……
这个 computed 一定是很常用的。那对于 Like 来讲,它是没有办法知道这个 like 是不是 computed 出来的。那它还怎么 reduce 呢?
对于Like
来说,它根本不知道,它只知道自己有like
这个属性,外面给的值是23000(其实外面是通过23 * 1000算的,但它不知道)
这个compute逻辑是在绑定层做的,任何一个组件都不感知这个逻辑
对于Like
来说,他只知道自己现在是23000,点击后变成23001,所以他的reduce
逻辑还是一样的,依旧是like = like + 1
然后23000变成23001后,绑定层发现这个值变了,再去看这个值是怎么绑定的,发现绑定的关系是like -> :likeCount * 1000
,然后这是个单向绑定,所以就不同步给上面的Tweet
组件了,此时Tweet
组件依旧是23000,这就是状态脱节了,没办法
如果你既要这个compute功能,又要双向绑定保证不脱节怎么办呢,那偷懒的可以自己定义get和set了,在Tweet
里定义
{
get likeCount() { return this.realLikeCount * 1000; }
set likeCount(value) { this.realLikeCount = value / 1000; }
}
因为有get和set,所以对其它流程也是透明的(看上去都是普通属性而已)
不过这里引入了几个问题:
reduce
更新的时候,还能不能保持get和set,确实是个问题总的来说我就不大推荐这样做,另一个复杂点但稳健的办法是自己管这个双向绑定。因为双向绑定其实是一个单向绑定+一个事件绑定,所以还是能直接手写的,把Tweet
这么写
import {set} from 'diffy-update';
class Tweet {
events = {
syncLike(data, fire, newLike) {
return set(data, 'likeCount', newLike / 1000);
}
}
}
而模板里这么写:
<Like ui-like=":likeCount * 1000" on-like-change="syncLike($event.newValue)" />
模板定义了一个属性的单向绑定,一个事件like-change
的绑定,js用一个reduce
更新自己的数据进行同步
这里的on-like-change
将是框架提供的一个语法糖,效果等于on-data-change
加一个if判断修改了like
并计算出newValue
这个双绑其实和ng2一样,ng2的[(ngModel)]
其实就是ngModel
的单向绑定和onNgModelChange
的事件绑定
个人喜好来说:
reduce也就我们内部说说了,真对外的话就叫event handler得了
errorrik notifications@github.com于2016年6月24日 周五下午5:55写道:
个人喜好来说:
- 我并不喜欢区分prop和state,原因灰大讲的太清楚了
- 我也不喜欢reduce这个词,原因是对ui的使用者来说会难以理解
— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/ecomfe/efe/issues/2#issuecomment-228305943, or mute the thread https://github.com/notifications/unsubscribe/AAnCPQFQcZjS6KI_2qwUGy08K-JoZuRXks5qO6mCgaJpZM4I7t1g .
<Like ui-like=":likeCount * 1000" on-like-change="syncLike($event.newValue)" />
表达式这样处理没问题,就是有点麻烦。
OK 我的困惑都搞清楚了,多谢灰大。
基于 https://www.zhihu.com/question/48099761/answer/109486302 的内容,我觉得引入Optimistic UI也是很好的,在我的这个体系下,每个event函数的返回从Promise<newState>
修改为[Promise<newState>, optimisticState]
就行了,其中optimisticState
可选(即可以返回长度只有1的数组)
比较头疼的就是返回个数组看着难受就是了……
当然也可以从Action的角度入手,一个event应该返回一个Action,这个Action才是生成新的newState
的实际函数,这就是Action Creator -> Action -> State的路子,是不是有实际比较大的收益(如newState
和optimisticState
真的可以是同一个Action弄出来的,我很怀疑这点)得实际实施的时候仔细看看
咦怎么就future了……我有这么牛逼吗- -
这一套有不少地方是借(抄)鉴(袭)react的,其实是把redux这个应用结构放到了组件里面来,不过整个组件的模式还是MVVM式绑定而不是react的那种模式
就是这么牛逼...
@otakustay 我觉得 redux 也没多少意义来着。实际开发中,我觉得数据同步才是重点,实际上我们需要一套智能的数据模型(缓存和同步)。抛开性能问题不说,目前在项目中有尝试使用 falcor(智能的数据模型) 和 async-props(基于 react-router 异步更新视图)来实现前端的 M(falcor)V(view)C(react-router)。对于 restful API 接口,我们可以在前端实现一套抽象的 falcor 路由(data-model),然后再抽象出一个 ui-model。这样就很简单啦,action 实际上就是执行 ui-model set 和 data-model call 这样的操作,然后刷新整个视图。路由变化和 action 最终的动作都是刷新视图,有了 async-props,我们可以根据 router 从 model 中异步获取数据(自动缓存和同步)。
@ustccjw 我厂毕竟有不少奇怪的应用场景,比如各种广告要兼容IE6,所以不少东西还是自己折腾做新的
你的这个图其实我蛮理解的,不过model要再过controller送到view(感觉类似MVP了)有没有可能相对复杂
另一个问题是,其实一个架构真正麻烦的可能在view的内部这棵component tree怎么组织,外部反而还好
一次把 router 对应 view 需要的数据都从 model 中取出(router component 挂载 loadProps)。这样虽然暴力,但传统 MVC是实际上也是以 router 为粒度加载页面的。这样做的另一个好处是能够真正做到声明式构建页面,因为不存在命令式获取数据的问题。
我又细化思考了一下,现在觉得把event做成一个纯函数并不是很现实,我们不得不承认一部分event是需要side effect的,比如拖拽的当前位置保存(如果完全走数据 => 视图更新的逻辑,可能会掉帧),又比如一些特定情况下才注册的事件(scroll这种很耗性能的)
所以我觉得可以这样:
events
对象里面data, fire, ...eventArgs
,返回一个data
,但允许使用this
放一些额外的私有state(比如当前鼠标位置之类的每次都更新性能吃不消的),但推荐尽量少放这种东西@sideeffect
标记(暂时我也不知道这个标记啥用,但总之先加着)return this.data
,绑定系统会判断没变化就退出
还请 @errorrik 过目……
Inspired by react, redux, mvvm and many many more...
介绍
一个组件分为“数据”和“事件”两部分,其中数据进一步分为“数据的声明”、“数据的初始值”和“当前数据”
到这一步为止基本就是一个React和Vue的合体,区别在于下面。
当组件实际渲染后,触发了一定事件时,会调用事件对应的函数,而这个函数是一个“纯函数”,它接受当前的数据,返回新的数据(Redux的
reducer
):你说为什么偏偏是
currentData
和fire
两个参数,因为一个组件的输入是数据,输出是事件,所以这相当于input
和output
。实际的流程是触发事件后,用函数计算出新的数据,把新的数据补丁到原有的数据上,进一步触发视图的更新。
优势
不需要Model的实现
永别了,我自己写的
emc
……更加纯粹
所有的事件处理函数都是纯函数,本身是可单独测着玩的。
更强制地引入Immutable
我不相再赘述Immutable的作用和优势……反正我认为在我的
diffy-update
的支持下更新Immutable数据并不是那么困难的事。对绑定同样友好
绑定是发生在
data
之下的,并不受任何影响,且因为一个reduce
返回的新对象可能包含多个属性的修改,相当于默认实现了一定程度上change
的合并,不必在纠结所谓的合并不合并的问题。可组合复用
这点很重要。
在这个模式下,一个组件其实被明确地分为几个部分,除了“当前数据”之外,其它部分其实可以灵活的组合,我的一个组件可以写成这样:
此后我有了自己的一个组件,虽然长得不一样,也有一堆逻辑不一样,明显地不能继承
Label
,但正好也要个duplicate
的功能,正好点了以后要触发click
事件,那就方便了:当然我们可以更细化,让
Label
变成3个文件:这样我可以只复用其中的
duplicate
部分,而不需要click
,相当方便。当然我们更多的时候,面对的是数据结构不尽可同,但是逻辑相似的情况,这时就可以上高阶函数:
以上仅仅是说明了
events
这一块如何通过组合来达到比继承更强大的复用性,其实dataTypes
和initialData
同样可以复用,但考虑到这只是普通的数据结构,并不特别适合去复用,就不在此展示。劣势
很显然,这需要一定的技术素养才能掌握。
过强的复用性可能让代码的组织变乱,各个组件间对
events
的依赖显得比较奇怪。events
本身是组件内部的一种实现,这样放出去给人复用并不适合大部分情况,通常只是在共享逻辑但定制外观的时候才有用。