作者简介:
道格
格物钛 Infra 团队 运维开发工程师
深入浅出 Vue 数据响应式原理
在使用 Vue 框架进行开发的过程中,常常会遇到更新数据但是视图无法更新的 bug,从而对开发的进度造成阻塞。
为了提高开发效率, 我们可以通过遵守最佳实践来减少类似的 bug 的频率。
除此之外,若开发者对数据响应式的过程有更好的理解,也能在功能实现的过程中对代码有更好的把控,进而减少类似问题的发生。
由此,为了帮助提升团队伙伴的开发效率,我对 vue 数据响应式的实现进行了探索,并将其实现简化成了可被执行的样例代码,以帮助大家更好的理解视图更新和依赖收集的过程。
无论你是前端开发者还是后端开发者,亦或者是算法工程师,相信阅读这篇文章以后都会有所收获。
何为数据响应式
数据响应式的改变了前端渲染视图的代码,相对于传统 web 开发,使得代码更可读性更强,我会分为 3 个步骤介绍如何实现简易的响应式数据。
- 无响应式
- Observer 模式
- 数据响应式的解决方案
首先,简单介绍一下两个贯穿全文的类:
- 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 :
- 实现了 Observer 模式,在更改数据的时候自动调用 render 函数。
- 避免了传统开发可读性差的问题,避免了诸如 update 和 watch 这样的函数,而是直接通过 class 的 getter 和 setter 对其状态进行监听和修改。
如果理解了前面 Proxy 的演示代码,那么对通过 setter 进行状态更新的通知很好理解,困难的是如何通过 getter 将 View 类注册到对应的 State 中,下面通过流程图解析:
总结
通过上面的样例代码,我们理清了 Vue 数据响应式的过程,分为两个步骤:
- 视图更新:通过 Observe 模式重新渲染。
- 依赖收集:通过一定的机制收集有谁订阅了数据中的特定字段。
实际 Vue 的实现肯定会更为复杂,但希望本文所介绍的内容能让你对其原理和过程有一个更好的理解。
希望你能有所收获~
Reference: