shepherdwind

如何实现一个mvvm组件

去年,因为业务需要,实现了一个mvvm组件,bidi。在此,记录一下组件实现的过程与经验。

来源

mvvm是Model-View-ViewModel,主要在于双向绑定。这个概念很早就有了,我一直没有具体研究过这个东西,直到有一天,我遇到一个这样的需求:

needs

业务场景大致是这样的,在淘宝购物,如果需要退款,首先需要填一个表单,这个表单要求,你说明你的投诉原因,投诉原因下面又分一个二级:具体原因。具体原因决定赔偿金额校验,这些区别在于,某些原因是可以申请赔偿运费的,某些原因是不可以的,这个赔偿金额是由原因决定的。

这是一个比较简单地场景,还有更复杂的表单,上面的原因可能有三级。

这个项目,改造的是非常古老淘宝退款项目,累计了8年,现在终于重写了。我当时就想,这么多逻辑规则,需要有一种通用的解决方案。于是,我想到了所谓的mvvm,实在太适合了。mvvm天生适合于复杂的表单逻辑,处理表单联动,主要也就是在于,监控表单改变,然后改变其他表单元素。这样的联动实际上,就是V -> M 然后 M -> V的过程,视图改变,对应的JS改变,改变其他表单视图元素。

约定

然后,我开始研究现有的几种mvvm组件,主要看了knockoutjs, emberjs, angularjs。首先,我看这些已有的组件我能不能直接用,当时做的是pc端业务,不能放弃IE6,emberjs和angularjs都不能用了,只剩下knockout,但是我也不想用jQuery,在淘宝用的都是KISSY。所以,我就想,那么就自己实现一个吧。

然后,我主要关注这些已有的组件,他们的使用方式。至于他们怎么实现的,我不是很关注。mvvm的实现,比较重要的是一个东西是view怎么写,在我看来,主要是有两种不同的形式。

  1. html文档,knockout和angular都是这样的
  2. JS模板,比如handlebars,ember就是基于handlebars来实现的

最后,我使用了一种最简单地方案,把ember和knockout实现的结合。定义了几个基本原则,用这种约定的方式来写mvvm模板的语法:

  1. 数据和dom的绑定,通过写一个JS模板语法,{{watch "action: model.var"}}的方式来绑定
  2. 每个绑定占据一个dom节点
  3. view通过XTemplate模板来写

下面我来解释一下,整个实现过程:

模板

在我做的bidi中,用户首先写的是一段模板:

 <div class="bidi-viewer" data-view="user">
    <script type="text/xtemplate">
      <p>hello, I am <strong {{watch  "text: fullName"}}></strong></p>
      <p>hello, I am <strong {{watch "text: firstName"}}></strong></p>
      First Name: <input {{watch "value: firstName"}}/><br>
      Last Name: <input {{watch "value: lastName"}}/>
      <button {{watch "click: capitalizeLastName"}}>Go caps</button>
    </script>
  </div>

这里描述了一段view视图规则,首先,有一个最完成的容器.bidi-viewer,这个容器还通过data-view定义了view所对应的模型。里面是一段模板,包裹在script里面,在js里面,会自动把模板拿出来,和数据放到一起,然后把运算得到的html放到当前视图中去。

然后,仔细看下这里的绑定是怎么写的:

<strong {{watch  "text: fullName"}}></strong>

这里fullName绑定在strong上了,{{watch}}是XTemplate模板的一个自定义函数,后面的是参数。这里有几个细节需要注意,这里的绑定fullName,同时存在strong元素,也许大家会觉得{{fullName}}这样写,不是要有好得多么?angular是这么实现,非常牛叉。但是这样搞的问题在于,绑定一个变量,你的知道这个变量在什么地方,这样当JS这个变量改变的时候,你知道去改dom中哪个元素。

如果angular那样,你就不能简单的找到这个{{fullName}}具体对应的dom节点了。你得找到这个变量所在的上下文,找到最上层的元素。这样的查找还是非常费事情,所以我选择了最简单的方式,直接一个元素绑定一个变量,通过{{watch}}这样的语法,然后这个自定义函数,返回一个字符串id=bidi-{uid},这样就非常简单地解决了把view和model关联起来的任务了。

当然,这也意味着,id是保留属性,每个绑定都不能有id,这个是通过bidi自动生成的。

这个问题,把view和model联系起来,是mvvm里面,最关健的一步了。

函数

前面,主要谈了view怎么写,继续看js怎么写。

 KISSY.use('gallery/bidi/1.0/index', function (S, Bidi) {

      Bidi.xbind('user', {
        firstName: 'Song',
        lastName: 'Eward',
        fullName: function(){
          return this.get('firstName') + ' ' + this.get('lastName');
        }
      }, {
        capitalizeLastName: function(){
          var lastName = this.get('lastName').toUpperCase();
          this.set('lastName', lastName);
        }
      });

      Bidi.init();
})

对于用户而言,使用this.get()和this.set()来赋值。这里有一个问题,就是fullName是一个函数,fullName如何和firstName和lastName关联起来的。

回到前面的模板,{{watch "text: fullName"}} 这一个运行的时候,我得去取fullName的值,这个时候,会运行执行fullName这个函数,现在,我只需要在fullName执行之前记录一个空数组,然后在fullName执行过程中,所有通过get的变量都记录在那个数组里面就可以了,但fullName执行完毕,我看下这个数组,就知道fullName和哪些变量有关了。

这种方式加缪提到过,其实看起来很炫,其实实现很挫的方式。这里种方式,有一个大坑,如果出现循环关联,可能就悲剧了。另外,这种方式,函数都必须是同步的,如果在模板中处理异步的,就没法搞了。

实现过程

现在,可以把整个流程串起来了。

image

view通过innerHTML获得模板,model通过toJSON方法,获得数据,两者结合,通过XTemplate解析模板。遇到{{watch}}自定义函数的时候,执行绑定,绑定函数同时返回一个dom的id。模板解析后,这些id成为视图上一个个dom节点的id,完成dom渲染,前面的绑定会和dom事件进行绑定。

在dom区域,有一个#bidi-{uid2}的元素,出发,构成一个V - M - V的绑定循环。bidi-{uid2}元素,是一个input,change事件触发,input的value改变,watch.value方法,监听了dom的change事件,在内部执行this.set('var', value),set方法,触发change:var事件,而watch.text方法,监听了来model内部的change事件,watch.text会修改#bidi-{udi1}元素的innerHTML,这样,一个循环完成。

表单元素

前面是一个简单例子,基本的原理,就是这个图片所说的了。实际情况,我得解决业务需求,就前面那种简单地MVVM,还是没法解决表单联动的。这里有一个问题,通常情况,我们是一个变量和一个dom对应,但是对于表单,出了单个变量之外,还有一种集合形式的,这就是radio、checkbox。所以,为了解决这种问题,我把一组radio看做一个变量,和Model对应。这样做,还得定义一个约定。

form

有了这样的约定,就可以这么描述一个联动关系了

    <select name="key1" {{watch "select: problem.defaultValue">
      {{#each problem.values}}
        <option value="{{value}}">{{text}}</option>
      {{/each}}
    </select>
    <div>
      {{#watch "linkage: reasons.values: problem" "radio: reasons.defaultValue"}}
        <label class="radio-inline"> <input type="radio" value="{{value}}"/>{{text}} </label>
      {{/watch}}
    </div>

优点

优势还是蛮多的,最方便的是,处理开发各种诡异的需求,简单多了。举个例子,开发说,二级原因被选中的时候,需要发一个异步请求,然后获得三级原因,然后现实三级原因。比如,他们经常说,哎呀,这个二级原因的文本也得给我,放隐藏域里吧。这样的需求,对于我而言,不是很复杂,稍微改下模板,不用动其他业务逻辑。

缺点

缺陷同样蛮多的,最开始做这个的时候,我觉得还慢不错的。但是,当我听到react的分享后,我瞬间觉得这个东东弱爆了。下面讲讲问题:

  1. 一个变量绑定一个dom的方式,看起来很简单,也很美好。但现实要骨感得多,比如,一个表单元素,当某个二级原因选中的时候,这一行表单元素需要隐藏,实现这个,要做的涉及到很多改动,第一,这个表单元素需要隐藏起来,同时表单本身需要被disabled,不能直接被删掉,也许什么时候,这个元素又要出来了。也就是,一个变量的修改,可能涉及到很多dom的修改,这直接去修改dom,还是比较浪费的。

  2. 与其他JS组件沟通的问题,MVVM通常直接与dom绑定,但是有些其他组件,比如表单元素模拟的组件,也要处理表单,经过模拟的表单元素,你甚至看不到表单元素了,这样的问题还是不好解决的。

  3. 模块粒度问题,bidi没法处理view嵌套的情况。这个问题,其实不算大问题,如果能够处理好view的嵌套,就可以做出webcompement了,angular在往这个方向发展。但是做得还是不够好,我觉得上面我遇到的问题,react都能解决。

最好讲讲react,当我听到react杀手锏是,自己定义了一套虚拟的dom,这个dom和html文档里面的dom进行沟通。JS本身不直接接触dom,而是虚拟的dom。这样的好处,可以随时随地修改dom,但是这些操作不会直接对dom进行改动,统一通过visual dom映射到dom中。这其实在dom操作和js中间,搭建了一个桥,所有的操作首先放到桥上,然后整理一下,用最快的方式,在dom中操作。

另外,一个大多数mvvm或者其他web compemnt组件所做不到的是,visual dom是JS实现的,所以,react可以在服务器端跑,这样一来,页面不仅仅可以同时在前后端渲染了。这是非常非常好的特性了。