技术博客|深入浅出 Vue 数据响应式原理

技术博客|深入浅出 Vue 数据响应式原理_第1张图片

作者简介:
道格
格物钛 Infra 团队 运维开发工程师

深入浅出 Vue 数据响应式原理

在使用 Vue 框架进行开发的过程中,常常会遇到更新数据但是视图无法更新的 bug,从而对开发的进度造成阻塞。

为了提高开发效率, 我们可以通过遵守最佳实践来减少类似的 bug 的频率。

除此之外,若开发者对数据响应式的过程有更好的理解,也能在功能实现的过程中对代码有更好的把控,进而减少类似问题的发生。

由此,为了帮助提升团队伙伴的开发效率,我对 vue 数据响应式的实现进行了探索,并将其实现简化成了可被执行的样例代码,以帮助大家更好的理解视图更新依赖收集的过程。

无论你是前端开发者还是后端开发者,亦或者是算法工程师,相信阅读这篇文章以后都会有所收获。

何为数据响应式

数据响应式的改变了前端渲染视图的代码,相对于传统 web 开发,使得代码更可读性更强,我会分为 3 个步骤介绍如何实现简易的响应式数据。

  1. 无响应式
  2. Observer 模式
  3. 数据响应式的解决方案

首先,简单介绍一下两个贯穿全文的类:

- State & View 类

// State 负责管理页面状态
class State {
  text = "hello"
  constructor(initText){
    this.text = initText
  }
}
​
// TextView 负责接受一个参数,并且暴露一个 render 方法用于渲染视图
class TextView {
  state = {}
​
  // 在初始化 TextView 时就会自动调用 render 函数,以渲染到视图
  constructor(s){
    this.state = s
    this.render()
  }
​
  render(){
    // 用 console.log 输出到终端来模拟渲染函数
    console.log(this.state.text)
  }
}

基于这两个 class ,我们可以通过如下的代码模拟前端渲染:

let state = new State("hello world");
// 渲染出 hello world
let node = new TextView(state);

无响应式
当节点状态改变,并且需要重新渲染时分为两个步骤:

state.text = "foo bar";
node.render();

假设有 3 个节点都订阅了这个 state ,则代码如下:

state.text = "foo bar";
node.render();
node0.render();
node1.render();

显然,可扩展性很差,并且难以维护。

Observer 模式
对上述的代码稍加改动,即可实现更好的可扩展性。

class State {
  /** 省略与上部分重复代码 **/
​
  watchList = []
​
  watchText(view){
    this.watchList.push(view)
  }
​
  updateText(text){
    this.text = text;
    this.watchList.forEach(v => {
      v.render()
    })
  }
}
​
class TextView {
  /** 其他代码保持不变 **/
  constructor(s){
    s.watchText(this)
    this.state = s
    this.render()
  }
}

如此,在初始化 TextView 实例的时候,其会将自己注册到 State 的监听列表内。当有人通过 updateText 函数更新的数据的时候,就会调用所有监听 text 类的 View 实例,进行重新渲染:

let state = new State("hello world");
​
let node = new View(state);
let node0 = new View(state);
let node1 = new View(state);
// 终端输出 3 次 foo bar
state.updateText("foo bar")

似乎,可扩展性的问题解决了?

NO,NO,NO!

假设我们现在有了一个 HeaderView 视图类,代码如下:

class HeaderView {
    state = {}
​
    constructor(s){
      s.watchText(this)
      this.state = s
      this.render()
    }
​
    render(){
      // 仅此处不同
      console.log(this.state.header)
    }
  }

那我们的 State 代码大概会变成这样:

class StateWithHeader{
    text = "hello"
    header = "header"
    watchTextList = []
    watchHeaderList = []
​
    constructor(header,text){}
​
    updateText(text){/**省略实现**/}
    watchText(view){/**省略实现**/}
​
    watchHeader(view){/**省略实现**/}
    updateHeader(header){/**省略实现**/}
  }

如果字段变得越来越多,则方法也会越来越多,重复代码越来越多。

你可能会想,这不是个问题,仍然能解决:

class StateWithManyFields {
  // 存入许多状态
  state = {}
  // 一个 map,key 为 string 类型代表监听的字段,val 为一个 Array 类型
  fieldWatchers = {}
​
  constructor(initState){
    this.state = initState;
  }
​
  // 只 watch 对应的字段,并且传入 View 实例
  watch(field,view){
    if (this.fieldWatchers[field] == undefined){
      this.fieldWatchers[field] = [];
    }
    this.fieldWatchers[field].push(view)
  }
​
  // 更新字段,并且调用所有 View 实例的 render 函数
  update(field,val){
    this.state[field] = val;
    if (this.fieldWatchers[field] != undefined){
      this.fieldWatchers[field].forEach( v => v.render());
    }
  }
}

Bravo !

你回退到了传统 web 开发模式,并且失去了类型提示。
假设你有 20 个状态,由于这些状态变成动态的了,ide 无法给你提示,你可能得靠记忆去记住这些字符串的值。假设你有 20 个这样多状态的组件,并且 ide 无法给你类型提示。Good luck my friend !

所以数据响应式是如何解决的

在回答这个问题之前,需要先引入 javascript 的 Proxy 类,我们利用它对我们的 State 实例进行代理:

let state = new State("hello world");
let proxyedState = new Proxy(state,{
  // 当有人调用 someVar =  proxyedState. 时,会打印出对应的 
  get:(obj,field) => {
    console.log(field)
    return obj[field]
  },
​
  set:(obj,field,val) => {
    // 当有人调用 proxyedState. =  时,会打印出对应的  和 传入的 
    console.log(field,val)
    obj[field] = val;
    return true
  }
})

下面是调用方式:

// 触发了 Proxy 类的 get 代理
// 终端输出 "text"
let a = proxyedState.text;
​
// 触发了 Proxy 类的 set 代理
// 终端输出 "text foo bar"
proxyedState.text = "foo bar"

如果理解了上述 Proxy 的代码,那么根据 Proxy,我们可以优化我们的 state,省去 updateField 的代码,同时不失去类型提示。

class State {
  reactive = {}
  // 一个 map,key 为 string 类型代表监听的字段,val 为一个 Array 类型
  watchers = {}
​
  constructor(initState){
    this.reactive = initState;
    this.reactive = new Proxy(
      this.reactive,
      {
        // 假设当有人调用 reactive.text 时,则会更新所有监听 text 状态的视图
        set: (obj,field,val) => {
          obj[field]=val;
​
          if (this.watchers[field] != undefined){
            this.watchers[field].forEach( v => v.render() )
          }
​
          return true;
        },
      }
    )
  }
}
​

好了,我们离成功只差一步之遥了:

View 类如何不通过 Watch 方法就能将自己添加到 State 的 Watcher 中?

往下阅读之前,大家可以先想想解决方案。
接下来就简单介绍 Vue 中的依赖收集是怎么做的,首先在 ReactiveState 的 class 声明前,加入一个全局变量的声明:

let currentCreatingView = undefined;
class ReactiveState { /** 省略 **/ }

接下来,修改 View 类的 constructor 代码;

class TextView {
  state = {}
​
  constructor(s){
    // 将自己注册到全局变量中
    currentCreatingView = this;
    this.state = s
    // 触发 getter
    this.render()
    // 初始化完成
    currentCreatingView = undefined;
  }
​
  render(){
    console.log(this.state.text)
  }
}

最后完善 State 的代码:

class State {
  /** 省略其他字段 **/
  constructor(initState){
    this.reactive = initState;
    this.reactive = new Proxy(
      this.reactive,
      {
        get: (obj,field) => {
          // 当有视图正在初始化时,将这个正在初始化的视图添加到 watcher 中
          if (currentCreatingView != undefined){
            if (this.watchers[field]==undefined){
              this.watchers[field] = []
            }
            this.watchers[field].push(currentCreatingView)
          }
          return obj[field]
        }
        /** 省略 get **/
      }
    )
  }
}

在继续解析前,先试试看成果。

let s = new State({header:"hello",text:"world"}).reactive;
console.log("---- init views ----")
let textView = new TextView(s)
console.log("---- init complete ----")
console.log("modifying header")
// 我们没有调用 State.updateField 方法,而是直接更改数据就让视图重新渲染
// 同时,我们也没有调用 State.watch 方法,将 View 类注册到监听列表中
s.text = "foo";

输出:

---- init views ----
world /* print by TextView.render */
---- init complete ----
modifying header
foo  /* print by TextView.render */

现在,先我们终于让 State 变得 Responsive :

  1. 实现了 Observer 模式,在更改数据的时候自动调用 render 函数。
  2. 避免了传统开发可读性差的问题,避免了诸如 update 和 watch 这样的函数,而是直接通过 class 的 getter 和 setter 对其状态进行监听和修改。

如果理解了前面 Proxy 的演示代码,那么对通过 setter 进行状态更新的通知很好理解,困难的是如何通过 getter 将 View 类注册到对应的 State 中,下面通过流程图解析:
技术博客|深入浅出 Vue 数据响应式原理_第2张图片

总结

通过上面的样例代码,我们理清了 Vue 数据响应式的过程,分为两个步骤:

  1. 视图更新:通过 Observe 模式重新渲染。
  2. 依赖收集:通过一定的机制收集有谁订阅了数据中的特定字段。

实际 Vue 的实现肯定会更为复杂,但希望本文所介绍的内容能让你对其原理和过程有一个更好的理解。
希望你能有所收获~

Reference:

https://www.infoq.cn/article/...

你可能感兴趣的:(技术博客|深入浅出 Vue 数据响应式原理)