美文网首页
响应式前端框架

响应式前端框架

作者: johnzhu12 | 来源:发表于2019-04-19 14:13 被阅读0次

    1. 响应式前端框架

    [TOC]

    1.1. 什么是响应式开发

    wiki上的解释

    reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change(响应式开发是一种专注于数据流和变化传播的声明式编程范式)

    所谓响应式编程,是指不直接进行目标操作,而是用另外一种更为简洁的方式通过代理达到目标操作的目的。

    联想一下,在各个前端框架中,我们现在要改变视图,不是用jquery命令式地去改变dom,而是通过setState(),修改this.data或修改$scope.data...

    1.1.1. concept

    举个例子

    let a =3;
    let b= a*10;
    console.log(b) //30
    a=4
    //b = a * 10
    console.log(b)//30
    

    这里b并不会自动根据a的值变化,每次都需要b = a * 10再设置一遍,b才会变。所以这里不是响应式的。

    B和A之间就像excel里的表格公式一样。
    B1的值要“响应式”地根据A1编辑的值相应地变化

    A B
    1 4 40(fx=A1*10)
    onAChanged(() => {
      b = a * 10
    })
    

    假设我们实现了这个函数:onAChanged。你可以认为这是一个观察者,一个事件回调,或者一个订阅者。
    这无所谓,关键在于,只要我们完美地实现了这个方法,B就能永远是10倍的a。

    如果用命令式(命令式和声明式)的写法来写,我们一般会写成下面这样:

    <span class="cell b1"></span>
    
    document
      .querySelector(‘.cell.b1’)
      .textContent = state.a * 10
    

    把它改的声明式一点,我们给它加个方法:

    <span class="cell b1"></span>
    
    onStateChanged(() => {
      document
        .querySelector(‘.cell.b1’)
        .textContent = state.a * 10
    })
    

    更进一步,我们的标签转成模板,模板会被编译成render函数,所以我们可以把上面的js变简单点。

    模板(或者是jsx渲染函数)设计出来,让我们可以很方便的描述state和view之间的关系,就和前面说的excel公式一样。

    <span class="cell b1">
      {{ state.a * 10 }}
    </span>
    
    onStateChanged(() => {
      view = render(state)
    })
    

    我们现在已经得到了那个漂亮公式,大家对这个公式都很熟悉了:
    view = render(state)
    这里把什么赋值给view,在于我们怎么看。在虚拟dom那,就是个新的虚拟dom树。我们先不管虚拟dom,认为这里就是直接操作实际dom。

    但是我们的应用怎么知道什么时候该重新执行这个更新函数onStateChanged?

    let update
    const onStateChanged = _update => {
      update = _update
    }
    
    const setState = newState => {
      state = newState
      update()
    }
    

    设置新的状态的时候,调用update()方法。状态变更的时候,更新。
    同样,这里只是一段代码示意。

    1.2. 不同的框架中

    在react里:

    onStateChanged(() => {
      view = render(state)
    })
    
    setState({ a: 5 })
    

    redux:

    store.subscribe(() => {
      view = render(state)
    })
    
    store.dispatch({
      type: UPDATE_A,
      payload: 5
    })
    

    angularjs

    $scope.$watch(() => {
      view = render($scope)
    })
    
    $scope.a = 5
    // auto-called in event handlers
    $scope.$apply()
    

    angular2+:

    ngOnChanges() {
      view = render(state)
    })
    
    state.a = 5
    // auto-called if in a zone
    Lifecycle.tick()
    

    真实的框架里肯定不会这么简单,而是需要更新一颗复杂的组件树。

    1.3. 更新过程

    如何实现的?是同步的还是异步的?

    1.3.1. angularjs (脏检查)

    脏检查核心代码

    (可具体看test_cast第30行用例讲解)

    Scope.prototype.$$digestOnce = function () {  //digestOnce至少执行2次,并最多10次,ttl(Time To Live),可以看test_case下gives up on the watches after 10 iterations的用例
        var self = this;
        var newValue, oldValue, dirty;
        _.forEachRight(this.$$watchers, function (watcher) {
            try {
                if (watcher) {
                    newValue = watcher.watchFn(self);
                    oldValue = watcher.last;
                    if (!self.$$areEqual(newValue, oldValue, watcher.valueEq)) {
                        self.$$lastDirtyWatch = watcher;
                        watcher.last = (watcher.valueEq ? _.cloneDeep(newValue) : newValue);
                        watcher.listenerFn(newValue,
                            (oldValue === initWatchVal ? newValue : oldValue),
                            self);
                        dirty = true;
                    } else if (self.$$lastDirtyWatch === watcher) {
                        return false;
                    }
                }
            } catch (e) {
                // console.error(e);
            }
    
        });
        return dirty;
    };
    
    

    digest循环是同步进行。当触发了angularjs的自定义事件,如ng-click,$http,$timeout等,就会同步触发脏值检查。(angularjs-demos/twowayBinding)

    唯一优化就是通过lastDirtyWatch变量来减少watcher数组后续遍历(这里可以看test_case:'ends the digest when the last watch is clean')。demo下有src

    其实提供了一个异步更新的API叫$applyAsync。需要主动调用。
    比如$http下设置useApplyAsync(true),就可以合并处理几乎在相同时间得到的http响应。

    changeDetectorInAngular.jpg

    angularjs为什么将会逐渐退出(注意不是angular),虽然目前仍然有大量的历史项目仍在使用。

    • 数据流不清晰,回环,双向 (子scope是可以修改父scope属性的,比如test_case里can manipulate a parent scope's property)
    • api太复杂,黑科技
    • 组件化大势所趋

    1.3.2. react (调和过程)

    调和代码

    function reconcile(parentDom, instance, element) {   //instance代表已经渲染到dom的元素对象,element是新的虚拟dom
      if (instance == null) {                            //1.如果instance为null,就是新添加了元素,直接渲染到dom里
        // Create instance
        const newInstance = instantiate(element);
        parentDom.appendChild(newInstance.dom);
        return newInstance;
      } else if (element == null) {                      //2.element为null,就是删除了页面的中的节点
        // Remove instance
        parentDom.removeChild(instance.dom);
        return null;
      } else if (instance.element.type === element.type) {   //3.类型一致,我们就更新属性,复用dom节点
        // Update instance
        updateDomProperties(instance.dom, instance.element.props, element.props);
        instance.childInstances = reconcileChildren(instance, element);         //调和子元素
        instance.element = element;
        return instance;
      } else {                                              //4.类型不一致,我们就直接替换掉
        // Replace instance
        const newInstance = instantiate(element);
        parentDom.replaceChild(newInstance.dom, instance.dom);
        return newInstance;
      }
    }
    //子元素调和的简单版,没有匹配子元素加了key的调和
    //这个算法只会匹配子元素数组同一位置的子元素。它的弊端就是当两次渲染时改变了子元素的排序,我们将不能复用dom节点
    function reconcileChildren(instance, element) {
      const dom = instance.dom;
      const childInstances = instance.childInstances;
      const nextChildElements = element.props.children || [];
      const newChildInstances = [];
      const count = Math.max(childInstances.length, nextChildElements.length);
      for (let i = 0; i < count; i++) {
        const childInstance = childInstances[I];
        const childElement = nextChildElements[I];
        const newChildInstance = reconcile(dom, childInstance, childElement);      //递归调用调和算法
        newChildInstances.push(newChildInstance);
      }
      return newChildInstances.filter(instance => instance != null);
    }
    

    setState不会立即同步去调用页面渲染(不然页面就会一直在刷新了😭),setState通过引发一次组件的更新过程来引发重新绘制(一个事务里).
    源码的setState在src/isomorphic/modern/class/ReactComponent.js下(15.0.0)

    举例:

    this.state = {
      count:0
    }
    function incrementMultiple() {
      const currentCount = this.state.count;
      this.setState({count: currentCount + 1});
      this.setState({count: currentCount + 1});
      this.setState({count: currentCount + 1});
    }
    

    上面的setState会被加上多少?

    在React的setState函数实现中,会根据一个变量isBatchingUpdates判断是直接更新this.state还是放到队列中回头再说,而isBatchingUpdates默认是false,也就表示setState会同步更新this.state,但是,有一个函数batchedUpdates,这个函数会把isBatchingUpdates修改为true,而当React在调用事件处理函数之前就会调用这个batchedUpdates,造成的后果,就是由React控制的事件处理过程setState不会同步更新this.state。

    setStateProcess.png

    但如果你写个setTimeout或者使用addEventListener添加原生事件,setState后state就会被同步更新,并且更新后,立即执行render函数。

    (示例在demo/setState-demo下)

    那么react会在什么时候统一更新呢,这就涉及到源码里的另一个概念事务。事务这里就不详细展开了,我们现在只要记住一点,点击事件里不管设置几次state,都是处于同一个事务里。

    1.3.3. vue(依赖追踪)

    核心代码:

    export function defineReactive(obj, key, val) {
        var dep = new Dep()
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get: function reactiveGetter() {
                // console.log('geter be called once!')
                var value = val
                if (Dep.target) {
                    dep.depend()
                }
                return value
            },
            set: function reactiveSetter(newVal) {
                // console.log('seter be called once!')
                var value = val
                if (newVal === value || (newVal !== newVal && value !== value)) {
                    return
                }
                val = newVal
                dep.notify()
            }
        })
    }
    
    vueObserver.png

    1.3.4. 组件树的更新

    react的setState
    vue的this.Obj.x = xxx
    angular的state.x = x

    1.png

    优化方法

    2.png

    在vue中,组件的依赖是在渲染过程中自动追踪的,所以系统能精确知道哪个组件确实需要被重渲染。你可以理解为每一个组件都已经自动获得了shouldComponentUpdate,但依赖收集太过细粒度的时候,也是有一定的性能开销。

    1.4. MV*和组件化开发

    archDevelop.jpg

    1.4.1. MV*设计

    MVCDesign.png MVPDesign.png

    MVP是MVC的变种
    View与Model不发生联系,都通过Presenter传递。Model和View的完全解耦
    View非常薄,不部署任何业务逻辑,称为“被动视图”,即没有任何主动性,而Presenter非常厚,所有逻辑都在这里。

    MVVMDesign.png

    Presenter调用View的方法去设置界面,仍然需要大量的、烦人的代码,这实在是一件不舒服的事情。

    能不能告诉View一个数据结构,然后View就能根据这个数据结构的变化而自动随之变化呢?

    于是ViewModel出现了,通过双向绑定省去了很多在View层中写很多case的情况,只需要改变数据就行。(angularjs和vuejs都是典型的mvvm架构)

    另外,MVC太经典了,目前在客户端(IOS,Android)以及后端仍然广泛使用。

    1.4.1.1. 那么前端的MVC或者是MV*有什么问题呢?

    MVCDie.png
    • controller 和 view 层高耦合

      下图是view层和controller层在前端和服务端如何交互的,可以看到,在服务端看来,view层和controller层只两个交互。透过前端和后端的之间。

      serverMVC.png

    但是把mvc放到前端就有问题了,controller高度依赖view层。在某些框架里,甚至是被view来创建的(比如angularjs的ng-controller)。controller要同时处理事件响应和业务逻辑,打破了单一职责原则,其后果可能是controller层变得越来越臃肿。

    clientMVC.png
    • 过于臃肿的Model层

      另一方面,前端有两种数据状态需要处理,一个是服务端过来的应用状态,一个是前端本身的UI状态(按钮置不置灰,图标显不显示,)。同样违背了Model层的单一职责。

    1.4.1.2. 组件化的开发方式怎么解决的呢?

    组件就是: 视图 + 事件处理+ UI状态.

    下图可以看到Flux要做的事,就是处理应用状态和业务逻辑

    componentDesign.png

    很好的实现关注点分离

    1.5. 虚拟dom,模板以及jsx

    1.5.1. vue和react

    虚拟dom其实就是一个轻量的js对象。
    比如这样:

     const element = {
      type: "div",
      props: {
        id: "container",
        children: [
          { type: "input", props: { value: "foo", type: "text" } },
          { type: "a", props: { href: "/bar" } },
          { type: "span", props: {} }
        ]
      }
    };
    

    对应于下面的dom:

      <div id="container">
      <input value="foo" type="text">
      <a href="/bar"></a>
      <span></span>
      </div>
    

    通过render方法(相当于ReactDOM.render)渲染到界面

    function render(element, parentDom) {
        const { type, props } = element;
        const dom = document.createElement(type);
        const childElements = props.children || [];
        childElements.forEach(childElement => render(childElement, dom));  //递归
        parentDom.appendChild(dom);
    
        // ``` 对其添加属性和事件监听
      }
    

    jsx

    <div id="container">
        <input value="foo" type="text" />
        <a href="/bar">bar</a>
        <span onClick={e => alert("Hi")}>click me</span>
      </div>
    

    一种语法糖,如果不这么写的话,我们就要直接采用下面的函数调用写法。

    babel(一种预编译工具)会把上面的jsx转换成下面这样:

    const element = createElement(
      "div",
      { id: "container" },
      createElement("input", { value: "foo", type: "text" }),
      createElement(
        "a",
        { href: "/bar" },
        "bar"
      ),
      createElement(
        "span",
        { onClick: e => alert("Hi") },
        "click me"
      )
    );
    

    createElement会返回上面的虚拟dom对象,也就是一开始的element

    function createElement(type, config, ...args) {
      const props = Object.assign({}, config);
      const hasChildren = args.length > 0;
      props.children = hasChildren ? [].concat(...args) : [];
      return { type, props };
    
      //...省略一些其他处理
    }
    

    同样,我们在写vue实例的时候一般这样写:

    // template模板写法(最常用的)
    new Vue({
      data: {
        text: "before",
      },
      template: `
        <div>
          <span>text:</span> {{text}}
        </div>`
    })
    
    // render函数写法,类似react的jsx写法
    new Vue({
      data: {
        text: "before",
      },
      render (h) {
        return (
          <div>
            <span>text:</span> {{text}}
          </div>
        )
      }
    })
    

    由于vue2.x也引入了虚拟dom,他们会先被解析函数转换成同一种表达方式

    new Vue({
      data: {
        text: "before",
      },
      render(){
        return this.__h__('div', {}, [
          this.__h__('span', {}, [this.__toString__(this.text)])
        ])
      }
    })
    

    这里的this.h 就和react下的creatElement方法一致。

    1.5.2. js解析器:parser

    最后,模板的里的表达式都是怎么变成页面结果的?

    举个简单的例子,比如在angular或者vue的模板里写上{{a+b}}

    parser.png

    经过词法分析(lexer)就会变成一些符号(Tokens)

    [
      {text: 'a', identifier: true},
      {text: '+'},
      {text: 'b', identifier: true}
    ]
    

    然后经过(AST Builder)就转化成抽象语法数(AST)

    {
      type: AST.BinaryExpression,
      operator: '+',
      left: {
        type: AST.Identifier,
    name: 'a' },
      right: {
        type: AST.Identifier,
        name: 'b'
    } }
    

    最后经过AST Compiler变成表达式函数

    function(scope) {
      return scope.a + scope.b;
    }
    
    • 词法分析会一个个读取字符,然后做不同地处理,比如会有peek方法,如当遇到x += y这样的表达式,处理+时会去多扫描一个字符。

    (可以看下angularjs源码test_case下516行的'parses an addition',最后ASTCompiler.prototype.compile返回的函数)

    1.6. rxjs

    Rx_Logo_S.png

    响应式开发最流行的库:rxjs

    Netflix,google和微软对reactivex项目的贡献很大reactivex

    RxJS是ReactiveX编程理念的JavaScript版本。ReactiveX来自微软,它是一种针对异步数据流的编程。简单来说,它将一切数据,包括HTTP请求,DOM事件或者普通数据等包装成流的形式,然后用强大丰富的操作符对流进行处理,使你能以同步编程的方式处理异步数据,并组合不同的操作符来轻松优雅的实现你所需要的功能。

    示例在demos/rxjs-demo下

    1.7. 小结

    响应式开发是趋势,当前各个前端框架都有自己的响应式系统实现。另外,Observable应该会加入到ES标准里,可能会在ES7+加入。

    参考链接:
    https://medium.com/@j_lim_j/summary-of-advanced-vuejs-features-by-evan-you-part-1-of-7-reactivity-b88ea6935a5d

    https://medium.freecodecamp.org/is-mvc-dead-for-the-frontend-35b4d1fe39ec?gi=3d39e0be4c84#.q25l7qkpu

    相关文章

      网友评论

          本文标题:响应式前端框架

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