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

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

作者: seaasun | 来源:发表于2019-03-01 17:38 被阅读0次

    你是否对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)
    };
    
    

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

        <p> {{name}} </p>
        <p> {{name}} </p>
        <p> {{age}} </p>
    

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

        <p my-value='name'> </p>
        <p my-value='name'> </p>
        <p my-value='age'> </p>
    

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

    <html>
        <head></head>
        <body>
          <p my-value='name'> </p>
          <p my-value='name'> </p>
          <p my-value='age'> </p>
       </body>
    </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 的值为[<p my-value='name'> </p>, <p my-value='age'> </p>...]
    function compile (id) {
        var nodes = document.getElementById(id).children;
    }
    

    4.2 遍历子节点,node 的值为<p my-value='name'> </p>,<p my-value='age'> </p>

    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状态同步

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

    <div id=‘app’>
        <p> {{name}} </p>
        <p> {{name}} </p>
        <p> {{age}} </p>
    </div>
    <script>
        var data = {
            name: 'mike',
            age: 1
        };
    
        var subjects = {}
        compile(‘app’)
        obverser(data) // 注意这里,我妈劫持data啦
    
          function set(key, val) {/* 同前例.. */}
          function compile(el) {* 同前例.. */}
    
          function obverser(data) {
             // 注意这里,补全obverse函数
          }
        </script>
    

    注意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函数即可。

    
    <div id=‘app’>
        <p> {{name}} </p>
        <p> {{name}} </p>
        <p> {{age}} </p>
        <p value='phone'> </p> <!-- phone 是用来做什么的? 最后说 -->
    </div>
    <script>
        var data = {
            name: 'mike',
            age: 1
        };
    
        var subjects = {}
        compile(‘app’)
        obverser(data) // 注意这里,我妈劫持data啦
    
        function set(key, val) { /* 同前例.. */ }
        function compile(el) { /* 同前例.. */ }
    
        function obverser(state) {
             // 注意这里,重写obverser函数
          }
    </script>
    

    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://img.haomeiwen.com/i1902062/2f6bc1d150bde1bb.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

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

    请注意在

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

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

    <p value='phone'> </p> <!-- phone 是用来做什么的? 现在说 -->
    

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

    附: 完整代码

    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>5代理</title>
    </head>
    <body>
    <div id ='app'>
        <p value='name'> </p>
        <p value='name'> </p>
        <p value='age'> </p>
        <p value='phone'> </p>
    </div>
    <script>
    
    var data = {
        name: 'mike',
        age: 1
    };
    
    var subjects = {};
    function set(key, val) {
        subjects[key].forEach(item=> {
            item()
        })
    }
    
    function compile(el) {
      var nodes = document.getElementById(el).children;
      for (let i = 0; i < nodes.length; i ++ ) {
        let node = nodes[i];
        if (node.hasAttribute('value')) {
          let property = node.getAttribute('value');
          if (!subjects.hasOwnProperty(property)) {
            subjects[property] = []
          }
          subjects[property].push(()=>{
            node.innerHTML = data[property]
          });
          node.innerHTML = data[property] || ''
        }
      }
    }
    compile('app');
    
    obverser(data);
    function obverser(state) {
      data = new Proxy(state, {
        get (target, property) {
          return target[property]
        },
        set (target, property, newValue) {
          target[property] = newValue;
          set(property, newValue);
        }
      })
    }
    </script>
    </body>
    </html>
    

    4.3 更进一步:双向绑定

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

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

    看下效果。


    代理 双向绑定.png

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

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>6代理双向绑定</title>
    </head>
    <body>
    <div id ='app'>
        <p value='name'> </p>
        <input type="text" model = 'age'> <!-- 增加文本输入框 -->
        <p value='age'> </p>
    </div>
    <script>
    
    var data = {
        name: 'mike',
        age: 1
    };
    
    var subjects = {};
    function set(key) {
        subjects[key].forEach(item=> {
            item()
        })
    }
    
    /*
    * 只重写compile函数,其余JS代码均没变化
    * */
    function compile(el) {
      var nodes = document.getElementById(el).children;
      // 遍历子节点,同前例
      for (let i = 0; i < nodes.length; i ++ ) {  
        let node = nodes[i];
        // 为模版绑定值,同前例让js的数据显示在页面上
        if (node.hasAttribute('value')) {
          let property = node.getAttribute('value');
          if (!subjects.hasOwnProperty(property)) {
            subjects[property] = []
          }
          subjects[property].push(()=>{
            node.innerHTML = data[property]
          });
          node.innerHTML = data[property] || ''
        // 新增部分: 当遇到`model'属性,表示双向绑定,  
        } else if (node.hasAttribute('model')) {
          // 为模版绑定值,让js的数据显示在页面上
          let property = node.getAttribute('model');
          if (!subjects.hasOwnProperty(property)) {
            subjects[property] = []
          }
          subjects[property].push(()=>{
            node.value = data[property]
          });
          node.value = data[property] || ''
          // 关键:增加监听。当文本框变动时触发
          node.addEventListener('input', () => {
            /*
            * 关键中的关键: 利用代理的set拦截。
            * 相当于在浏览器console中输入data.name = 'Jim'
            */
            data[property] = node.value
          })
        }
      }
    }
    compile('app');
    
    obverser(data);
    function obverser(state) {
      data = new Proxy(state, {
        get (target, property) {
          return target[property]
        },
        set (target, property, newValue) {
          target[property] = newValue;
          set(property);
        }
      })
    }
    </script>
    
    </body>
    </html>
    

    4.4 再进一步,再进一步

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

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

    <div id ='app'>
        <p>
            姓名:
            <span value='name'> </span>
        </p>
        <p>
            年龄:
            <input type="text" model = 'age'>
        </p>
        <p>
            年龄:
            <span value='age'> </span>
        </p>
    </div>
    

    下方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代码
    <ul id='list'>
      <li class='item'>Item 1</li>
      <li class='item'>Item 2</li>
      <li class='item'>Item 3</li>
    </ul>
    
    //转为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将变为:
    <ul id='list'>
      <li class='item'>A</li>
      <li class='item'>B</li>
      <li class='item'>C</li>
    </ul>
    

    至此,结束。

    6. 结语

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


    科技线.png

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


    优化无止境.png 感谢.png

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

    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/ricjuqtx.html