UI状态同步简史 --换种角度轻松理解现代前端框架

你是否对MVVM多少有点不解?
你是奇怪Jquery忽然成为“过时”的技术?
你是否想写出一个类似Vue的简单框架?

只要你会用原生JS, 不需要掌握Vue,react等高深技能,本文换种角度让你穷死理解现代前端框架。

0. 关于UI状态同步

有没有想过,为何使用现代前端框架?

为何使用前端框架?.png

React, Vue, Angular等提供很有意思的东西,如组件化,第三方UI组件,单网页支持,脚手加等工具。然而这些不是根本原因,《现代js 框架存在的根本原因》 给出本质原因:

现代前端框架支持UI状态同步

所谓UI状态同步是指浏览器能实时显示JS中的数据,比如js中 name: '张三',则浏览器页面中显示张三

张三.png

如果js中 `naee = '李四’, 则页面自动变为李四


李四.png

在以前Jquery以前的时代。想实现这一操作困难重重,需要不断的更新dom, 不仅性能首限,而且零碎的dom操作代码容易导致代码混乱。如何决这个问题呢? 按前端发展的历程,分为四步:

  1. 观察者
  2. 脏检查
  3. 描述属性符
  4. 代理
时间轴.png

1. 观察者

你没看错,这里的观察者就是N大设计模式中的观察者模式。在网页中,观察者即UI中显示的内容,被观察者就是JS中存储的数据。


观察者1对1.png

JS中存储的数据通常会在页面多个地方显示, 一个被观察者可以对应多个观察者。


观察者 一对一.png

我们需要JS中数据的变动引起页面的变动,即被观察者变动,引起对应的观察者A、观察者B等变动,这就是观察者模式。


观察者变动.png

实现此过程很简单,把被观察者存在一个称为订阅池的数组中,观察者变动时循环遍历订阅池数组,更新观察者即可。


观察者 订阅池.png

1.1 最简单的例子

下面用最简单的例子展示观察者。该例子简单到用日志输出console.log() 代表UI变动。

var uiName1 = function (val) {
    console.log('Name#1 become:' + val)
};
var uiName2 = function (val) {
    console.log('Name#2 become:' + val)
};

var subjects = [];

subjects.push(uiName1);
subjects.push(uiName2);

function set (val) {
    subjects.forEach(item => {
        item(val)
        })
}
  1. 建立第一个观察者uiName1, 表示页面中有一个地方显示姓名。
var uiName1 = function (val) {
   console.log('Name#1 become:' + val)
};

2.建立第二个观察者uiName1, 表示页面中另一个地方显示姓名。

...
var uiName2 = function (val) {
    console.log('Name#2 become:' + val)
};
  1. 创建一个数组,表示订阅池 (有些地方也写作watchers)。订阅池是本文最重要的三个概念之一。
...
var subjects = []
  1. 将两个观察者放入订阅池中
...
subjects.push(uiName1);
subjects.push(uiName2);
  1. 写一个set函数表示观察者的变动, 参数val表示姓名值
...
function set (val) {
    subjects.forEach(item => {
        item(val)
        })
}

打开浏览器命令行,打入set('Tom') ,便可看到Name发生变化

观察者 演示.png

1.2 多个被观察者

上面的例子中只有一个被观察者Name,而实际中有多个被观察数据。修改上例,增加一个被观察者Age

var uiName1 = function (val) {
    console.log('Name#1 become:' + val)
};
var uiName2 = function (val) {
    console.log('Name#2 become:' + val)
};
//  增加一个新的被观察者Age
var uiAge = function (val) {
    console.log('Age become:' + val)
};
//  改造订阅池
var subjects = {
    name: [uiName1, uiName2],
    age: [uiAge]
};
//  改造set函数
function set (key, val) {
    subjects[key].forEach(item => {
        item(val)
    })
}
  1. 增加一个新的被观察者Age,通样用console.log 表示变动
...
var uiAge = function (val) {
    console.log('Age become:' + val)
};
...
  1. 改造订阅池,用对象的Key表示被观察者,Value为相应的观察者
...
var subjects = {
    name: [uiName1, uiName2],
    age: [uiAge]
};
...
  1. 改造set函数,遍历指定被观察者的观察者, 参数key表示被观察者nameage, val表示样变成的值
...
function set (key, val) {
    subjects[key].forEach(item => {
        item(val)
    })
}
...

在命令行控制台输入set('name','Tom'), 会发现只有name发生改变, 输入set('age',18)z则只有age发生改变

观察者 多个被观察者.png

1.3在页面显示

总用命令行日志代码UI是不行的,我们把观察者模式用于网页。将上例中的

var uiName1 = function (val) {
    console.log('Name#1 become:' + val)
};
var uiName2 = function (val) {
    console.log('Name#2 become:' + val)
};

的观察者用下方的网页模版表示:

    

{{name}}

{{name}}

{{age}}

这种模版非常优雅,为简单起见,我们用如下方式呈现数据:

    

我们需要将模版转为上例中观察者,这一个过程叫做模版解析compile, 这是本文最重要的三个概念之二
为确定渲染的范围,增加id='app',全部的html代码如下:


    
    
      

下面增加JS部分的代码。

  1. 首先声明JS的数据,也就是前端框架常说的状态State:
var data = {
    name: 'mike',
    age: 1
};
  1. 创建订阅池和set函数,和上例几乎一样。只是需要把需要变的值赋值给data
var subjects = {};

function set(key, val) {
    data[key] = val
    subjects[key].forEach(item=> {
        item()
    })
}
  1. 下面需做模版解析,即把模版解析成观察者。 参数id表示只解析id‘app’)范围内的html代码:
function compile (id) {

}
compile('app')
  1. 下面我们补充comile()解析函数
    4.1 获取节点的全部子元素,nodes 的值为[

    ,

    ...]
function compile (id) {
    var nodes = document.getElementById(id).children;
}

4.2 遍历子节点,node 的值为

,

function compile (id) {
    var nodes = document.getElementById(id).children;
    for (let i = 0; i < nodes.length; i ++ ) {
        let node = nodes[i];
    }
}

4.3 如果包含属性my-value则获取该值,property 的值为 nameage,表示被观察者。

...
let node = nodes[i]
if (node.hasAttribute('my-value')) {
      let property = node.getAttribute('my-value');
...

4.4 如果订阅池中没有被观察者则放入被观察者

...
let property = node.getAttribute('my-value');
if (!subjects.hasOwnProperty(property)) {
        subjects[property] = []
      }

4.5 推入观察者至订阅池

...
if (!subjects.hasOwnProperty(property)) {
        subjects[property] = []
      }
subjects[property].push(()=>{
        node.innerHTML = data[property]
      })
...

4.6 修改Dom的显示

...
node.innerHTML = data[property]
...

完整JS代码如下

var data = {
    name: 'mike',
    age: 1
};

var subjects = {};
compile('app')

function set(key, val) {
    data[key] = val
    subjects[key].forEach(item=> {
        item()
    })
}

function compile(id) {
  var nodes = document.getElementById(id).children;
  for (let i = 0; i < nodes.length; i ++ ) {
    let node = nodes[i];
    if (node.hasAttribute('my-value')) {
      let property = node.getAttribute('my-value');
      if (!subjects.hasOwnProperty(property)) {
        subjects[property] = []
      }
      subjects[property].push(()=>{
        node.innerHTML = data[property]
      })
      node.innerHTML = data[property]
    }
  }
}

打开页面显示如下:


观察者 mike.png

在命令行输入set('name', 'Jim') 会发现页面相应改变

观察者 JIm.png

输入`set('age', 99) 会改变年龄


观察者 99.png

1.4 使用者

emberJs, 微信小程序,react等都在使用观察者模式,利用setsetData或类似函数更新数据,很常见。

观察者 使用者.png

2.脏检查

观察者通过模版解析和订阅池实现了UI状态同步,然而想更新被观察者,需要手动的调用set(key, value)函数,并不方便,如果JS中的状态变化能自动调用set函数就好啦。
为解决这个痛点,Angular1.0 提出脏检查这一概念。当触发了某些条件,比如页面加载完成,用户点击,或者一些数据发生改变后,会遍历所有的数据进行检查,如果发现有变化的地方则更新。

脏检查.png

Angular虽然对脏检查做了很多优化,深入了解可以阅读angular脏检查原理及伪代码实现。但由于经常要遍历全部数据,对现在的大型网页应用而言,效率太慢。当Angular维护的状态达到数百后,可能会出现卡顿现象。

3 属性描述符

如何能搞效的进行UI状态同步? 属性描述符(或称为对象定义属性)defineProperty,给出答案。我们利用defineProperty的getter 和 setter劫持数据对象,当数据变动时会自动调用setter中的方法,进而改变页面。


属性描述符 劫持.png

Object.defineProperty 可以丰富对象的取值和赋值操作,语法如下:

Object.defineProperty(obj, prop, descriptor)

obj是目标对象, prop是属性名即键值,descriptor是目标属性所拥有的特性。返回值是被传递给函数的对象, 简言之一个对象。具体语法参见理解Object.defineProperty的作用

再看下面的例子

var data = {}  // 被劫持的对象
Object.defineProperty(data,   ‘name’, {
   enumerable: true,  // 可枚举
   configurable: true,  // 可忽略
   get () {                    // 拦截取值
        return val
    },
   set (newVal) {        // 拦截赋值操作
        val = newVal
        console.log('我被劫持了') 
    }
})

当你在命令行执行 data.name = 'Tom'时,会发现输出一条日志我被劫持了

3.1 用defineProperty做UI状态同步

仍然用之前的代码,只是增加对象的劫持操作

{{name}}

{{name}}

{{age}}

注意obverser(data)这一行,obverse劫持对象,这是本文三个重点之三,参数data是被劫持的数据。

obverser()函数写起来也很简单,首先遍历data的每一个属性。Object.keys能把对象的键转为一个数组如['name','age'], forEach遍历这个数组。

...
function obverser(data) {
   Object.keys(data).forEach(key=>{
       let value = data[key]

之后添加getter选项,直接返回数据的值即可。

...
function obverser(data) {
   Object.keys(data).forEach(key=>{
       let value = data[key]

       Object.defineProperty(data, key, {
           get () {
               return value
           },

最后增加getter拦截函数

...
       Object.defineProperty(data, key, {
           get () {
               return value
           },
           set (newValue) {
                if (value != newValue) { // 只有在赋不同值后才起作用,避免循环调用
                   // console.log('我被劫持啦')
           value = newValue
                   set(key, value) // 以前需手动写的set函数,现在可以自动运行
                }     
            }  
         }) 
    }) 
}

至此完工,打开浏览器看看效果。


属性描述符 运行1.png

在Console中输入data.name= 'Jim'试试? 看,不需要手动写set函数

属性描述符 运行2.png

再输入data.age = 99 改下年龄

属性描述符 运行3.png

3.2 小结

再理下思路,不外乎三点:

  • UI中,对模版进行解析compile,产生观察者
  • JS中,对状态state进行劫持(或称作观察)observer,产生被观察者
  • 通过订阅池Watchers进行连接


    属性描述符 小节.png

本文的例子非常简单,只解释概念,如果继续完善下去,比如增加对表单onChange事件的监听,可以做出一个类似Vue的MVVM的框架,有兴趣可以阅读《剖析vue实现原理,自己动手实现mvvm》

这就是《一种基于访问器劫持的前端数据双向绑定实现方法》,你没看错,这竟然被注册成专利,有兴趣可以深入阅读《双向绑定也能申请专利》

描述属性符 专利.png

Vue,Angular 2以后的版本,以及国人出品的框架avalon在使用这种技术


属性描述符 使用.png

4代理

事情往往并不完美,属性描述符defineProperty也是如此。在声明对象属性后,defineProperty才能对该属性进行劫持,于是vue中我们还需要写this.$set(data, key,val)以添加新的属性。本节讲的代理将能完美解决definePropery的缺点。
代理Proxy, 作为ES6的新特性可能会遇到浏览器兼容问题。又由于profill的降级对代理几乎没用,很少有人将代理用于时间开发中,相信随着现代浏览器的普及这一现状将得到改变。使用代理Proxy前最好检查下浏览器的兼容问题,参加《Can I use proxy ?》

代理 兼容.png

4.1 简明代理语法

Proxy代理, 可以理解在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
比如,用proxy拦截取值操作:

var proxy = new Proxy({}, {
     get: function () { // 拦截取值,类似getter
        return 1;
    }
}
proxy.name // 1
proxy.book //1

Proxy的写法都如此, 语法如下:

var proxy =  new Proxy(target, handler)

new Proxy表示生成一个Proxy实例, taget参数表示所要拦截的目标对象。目标对象可以是js中的对象,数组,函数甚至另一个代理。handler参数也是一个对象,用来定制拦截行为。返回值是Proxy对象, new Proxy是稳定操作,不会对target有任何影响。

常见handler 的有:

  • get: 拦截取值
  • set: 拦截赋值
  • deleteProperty: 拦截删除
  • apply: 拦截函数执行
  • defineProperty: 拦截defineProperty操作
    更多Proxy操作可阅读《ECMAScript6入门》
代理 语法.png

代理给JS编程打开了一扇门,灵活快速,可称是对JS的“元编程”。代理的用途很广泛,比如表单验证,图片懒加载,异步队列,等等,有兴趣可以阅读(《使用 Javascript 原生的 Proxy 优化应用》)[https://juejin.im/post/5a3cb0846fb9a044fb07f36c]

4.2 状态同步代理版

用代理做UI状态同步非常简单,我们还是用上例的代码,只需修改observer函数即可。


{{name}}

{{name}}

{{age}}

1.创建代理, 注意为避免变量重复,这里把函数参数改为state

function obverser(state) {
    data = new Proxy(data, {
        
    })
}
  1. 拦截取值操作
function obverser(target) {
    data = new Proxy(target, {
        get (target, property) {
            return target[property]
        },
    })
}

其中targert表示目标对象, property表示目标对象的属性, return target[property] 相当于把原对象的值直接返回

  1. 拦截赋值操作
function obverser(target) {
    data = new Proxy(target, {
        get (target, property) {
            return target[property]
        },
        set (target, property, newValue) {
            target[property] = newValue
        set(property, newValue)}
    })
}

其中targert表示目标对象, property表示目标对象的属性, newValue顾名思义是新设置的值。target[property] = newValue 是赋值操作。 set(property, newValue) 就是前面众多例子中的set()函数。
至此,结束。
打开网页看下效果,注意日志中清晰简洁的呈现数据。

代理 演示.png

输入data.name = 'Jim', 会发现名字由Mike变为Jim

代理 name.png

输入data.age= 99, 会发现年龄由99变为1

![代理 phone.png](https://upload-images.jianshu.io/upload_images/1902062-2f6bc1d150bde1bb.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

输入data.phone= 15442258, 会发页面多出了电话号码。

请注意在

var data = {
        name: 'mike',
        age: 1
    };

data的声明中我们并没有写phone, 只是在模版中写有

可以看到代理可以对没有声明的属性进行监听,完美解决描述属性符的问题。

附: 完整代码



    
    5代理


4.3 更进一步:双向绑定

我们用代理做状态同步,再进一步,我们可以用代理做双向绑定。实现原理很简单,仍用前例的代码,只是更改compile函数,增加对输入框的监听

      node.addEventListener('input', () => {
        // 利用代理的set拦截。
        // 相当于在浏览器console中输入data.name = 'Jim'
        data[property] = node.value
      })

看下效果。


代理 双向绑定.png

这部分内容已经超出本文的范围,有兴趣可以直接阅读下面的代码和注释。本例可能是行数最少的双向绑定代码,只是比前面的例子增加了几行代码。




    
    6代理双向绑定


4.4 再进一步,再进一步

本文的例子很简单,不大可能用于实践,还有很多工作要做。

如果要写如下的嵌套模版怎么办?给Compile加层递归循环吧?

姓名:

年龄:

年龄:

下方compile函数又丑又长怎么办? 向Vue一样 用watcher和dap改造吧!

...
      node.innerHTML = data[property] || ''
    } else if (node.hasAttribute('model')) {
      let property = node.getAttribute('model');
      if (!subjects.hasOwnProperty(property)) {
        subjects[property] = []
      }
      subjects[property].push(()=>{
...

有兴趣阅读《用proxy实现一个更优雅Vue》

5.番外篇: 虚拟渲染

即使用代理进行双向绑定,也需要操作DOM,而操作DOM是耗时不高效的。
React另辟蹊径,不用代理,使用寻渲染做UI状态同步。
详细原理可阅读《如何理解虚拟DOM?》,大致原理如下:

  1. 创建虚拟DOM,即把HTML中的模版转为js显示。比如:
// html代码
  • Item 1
  • Item 2
  • Item 3
//转为JS var tree = h('ul', {id: 'list'}, [ h('li', {class: 'item'}, ['Item 1']), h('li', {class: 'item'}, ['Item 2']), h('li', {class: 'item'}, ['Item 3']) ])
  1. 通过render渲染函数将虚拟DOM转为真正的DOM并加载在页面上
var root = tree.render() 
document.body.appendChild(root)
  1. 如果JS发生改变,直接生成新的寻DOM,比如更改Item的名称
var newTree = h('ul', {id: 'list'}, [
  h('li', {class: 'item'}, ['A']),
  h('li', {class: 'item'}, ['B']),
  h('li', {class: 'item'}, ['C])
])
  1. 用DIff算法比较新就DOM树,并将不同点存在变量pathces中
var patches = diff(tree, newTree)])
// patches 内容类似如下:
[{node: 'li', old: 'Item 1, new 'A'} , {node: ...} ....]
  1. 在真正的DOM树中变更
patch(root, patches)

// DOM将变为:
  • A
  • B
  • C

至此,结束。

6. 结语

UI状态同步简史,有两条科技线。一条是通过观察者模式对数据的观察,一条是虚拟函数用JS代替HTML。


科技线.png

而到今天,两条科技线早已相互结合,互相吸取优点。于是有了今天的React, Vue等。
不过故事仍没结束,在UI状态同步的路上,优化无止境。


优化无止境.png
感谢.png

本文源于公司的一次内部分享

你可能感兴趣的:(UI状态同步简史 --换种角度轻松理解现代前端框架)