响应式原理
嘿,朋友们,本节是
Vue
源码阅读的第四讲。我很高兴Vue
源码阅读系列得到许多朋友们的反馈与意见,再接再厉!
如果没有看之前的前三讲的内容可以先去看看哦,好啦,让我们开始吧。
温故知新
①
VNode
类: 用于构造虚拟DOM
;
②
getVNode
方法: 用于将真正的DOM
变为虚拟DOM
,该方法先当作complier
函数来使用,因为Vue
将真正的DOM
作为字符串进行解析,得到AST
,AST
算法较为复杂,现不过多讨论,所以使用带有“坑”的虚拟DOM
来模拟抽象语法树;
③
getValueByPath
方法: 用于访问任意对象层级的属性;
④
combine
方法: 将带有“坑”的虚拟DOM
与数据结合,得到填充数据的VNode
;
⑤
JGVue
构造函数: 现只提供了数据和模板(元素转换),Vue
是把它作为字符串解析;mount
方法用于挂载;
⑥
mount
方法内部调用了createRenderFn
方法,其将获取虚拟DOM
,有缓存作用,调用render
方法也就获取到虚拟DOM
。mount
内部有render
方法,而不把它定义在原型上,原因是Vue
本身是可以带有render
成员,也就是上期Q5
的内容;
⑦
mountComponent
方法用于挂载组件,里面有一个mount
方法,负责调用update
;而为什么不直接调用update
,多写了个mount
方法,原因是后续使用发布订阅模式,渲染和计算行为交给watcher
来完成。
⑧
render
方法用于生成带有数据的虚拟DOM
加入页面中,update
算法在Vue
中还是挺复杂的,这里我们简化。
完成编写
update
方法
//将虚拟 DOM 渲染到页面中,diff算法在这里
JGVue.prototype.update = function() {
// 简化, 直接生成 HTML DOM replaceChild 到页面中
// 父元素.replaceChild(newElement,oldElement)
// 需要拿到父元素
}
问题:父元素和新旧元素的获取
父元素.replaceChild(newElement,oldElement)
JGVue
构造函数编写
function JGVue(options) {
this._data = options.data;
let elm = document.querySelector(options.el);
this._template = elm;
this._parent = elm.parentNode;//获取父元素
//提供一个新的方法 挂载
this.mount();
}
Vue
中有一个元素 elm
是真正的 DOM
, 其 children
同样也是真正的 DOM
parseVNode
方法用于vNode
转为真正的 DOM
tips
: 写到这里,其实大家也发现了代码量非常大,看多了自己都不知道写啥,后面使用构建工具(模块化)rollup webpack...
来帮忙!
继续编写 update
方法
//将虚拟 DOM 渲染到页面中,diff算法在这里
JGVue.prototype.update = function(vnode) {
// 简化, 直接生成 HTML DOM replaceChild 到页面中
// 父元素.replaceChild(newElement,oldElement)
// 新元素
let realDOM = parseVNode(vnode);
let oldDOM = document.querySelector('#root');
// 不太正确
this._parent.replaceChild(realDOM,oldDOM);
// 这样会将页面中的 DOM 全部替换
}
完成!验证一下:
<div id="root">
<div class="c1">
<div title="tt1" id="id">{{ name }}--{{ gender }}--{{ age }}</div>
</div>
</div>
let app = new JGVue({
el: "#root",
data: {
name: 'zhangsan',
age: '18',
gender: 'male'
}
})
页面:
验证成功!源码见文章末尾。
进入正题 : 响应式原理
使用 Vue
的时候,赋值属性和获取属性都是直接使用 Vue
实例,在设置属性的时候,页面数据进行更新。
需要使用到
ES5
语法Object.defineProperty(obj, prop, descriptor)
obj
: 需要定义属性的对象
prop
: 要定义或修改的属性的名称或 Symbol
descriptor
: 要定义或修改的属性描述符
其有比如 writeable
configurable
enumerable
value
get
set
返回值为被传递给函数的对象
举个例子:
/*
Object.defineProperty(obj, 'foo', {
writeable:true,
//...
configurable,
enumerable, //控制属性是否可以被枚举,能否被 for-in 遍历
set(){},//赋值触发
get(){},//取值触发
})
*/
var o = {};
// 给 o 提供属性
o.name = 'zhangsan';
Object.defineProperty(o, 'age', {
configurable: true,//可配置(修改或删除)
writable: true,//可写(可赋值)
enumerable: true,//可枚举
value: 19//值
})
//修改 enumerable 设置为 false 浏览器控制台输出无高亮
o.name //读取值
o.name = 'foo' //赋值
for(var k in o){
console.log(k);//name <无age> 因为是不可枚举
}
想要响应式,表示在赋值和读取值的时候,附带一些操作。
需要使用到 get
和 set
Object.defineProperty(o,'gender',{
configurable: true,
enumerable: true,
get(){// 访问gender 触发get方法(getter 读取器)
return 'gender被读取了'
},
set(newValue){// 修改gender 触发set方法(setter 修改器)
console.log('gender修改为:'+ newValue);
}
})
// get 和 set 是一对
// value 和 writeable 是一对,他们两对不能同时出现
图示:
如果同时使用 get
和 set
需要一个中间变量存储真正的数据
还是上述例子:
let _gender;
Object.defineProperty(o,'gender',{
configurable: true,
enumerable: true,
get(){// 访问gender 触发get方法(getter 读取器)
return _gender;
},
set(newValue){// 修改gender 触发set方法(setter 修改器)
_gender = newValue;
}
})
实际上,上述操作只是给 gender
赋值而已。
问题是 gender
被暴露在全局作用域,如何解决?
在 Vue 中使用
defineReactive(target,key,value,enumerable)
//简化版
function defineReactive(target, key, value, enumerable) {
//函数内部就是一个局部作用域 这个 value 就只在函数内使用的变量(闭包)
Object.defineProperty(target, key, {
configurable: true,
enumerable: !!enumerable,
get() {
console.log(`读取 o 的 ${key} 属性`);
return value;
},
set(newValue) {
console.log(`设置 o 的 ${key} 属性为:${newValue}`);
value = newValue;
}
})
}
Q1
: 中间变量是什么?
A1
: 中间变量是 value
,value
作为函数的参数,相当于这个函数作用域里面的局部变量。
将对象转换为响应式
let keys = Object.keys(o);
for (let i = 0; i < keys.length; i++) {
defineReactive(o, keys[i], o[keys[i]], true);
//该函数相当于一个闭包
//闭包:简单理解就是函数内部调用函数外部的变量叫做闭包
}
图示:
现在将对象转换为响应式是远远不够的,在实际开发中对象一般是有很多级,跟之前说的对象层级类似。
let o = {
list: [
{}
],
ads: [
{}
],
user: {
}
}
Q2
: 对象结构很复杂,有数组,有对象,还很多层,那如何处理?
A2
: 递归 (除了使用递归还可以使用队列(深度优先转换为广度优先)有兴趣尝试,欢迎讨论!)
具体实现思路:
case
① : 判断这个属性是否是引用类型,如果是则递归,不是则不用递归
case
② : 判断是否是数组,如果是就需要循环数组,然后将数组里面的元素进行响应式化
let data = {
name: 'zhangsan',
age: 19,
course: [
{ name: '数据结构' },
{ name: '操作系统' },
{ name: '计算机网络' },
{ name: '计算机汇编与组成原理' }
]
}
// 如果是引用类型 递归代码写在哪里
// 应该是写在 defineReactive
// 将对象 o 响应式化
function reactify(o) {
let keys = Object.keys(o);//['name,'age','course']
for (let i = 0; i < keys.length; i++) {
let key = keys[i];
let value = o[key];
// 判断这个属性是否是引用类型
// 判断是否是数组
// 如果是引用类型 需要递归
// 不是 就不用递归
// 如果是数组 就需要循环数组 然后将数组里面的元素进行响应式化
if (Array.isArray(value)){
//是数组
for(let j = 0; j < value.length; j++){
reactify(value[j]);//对数组元素响应式化 递归
}
}else{
//对象或值类型
defineReactive(o,key,value,true);
}
}
}
//简化版
function defineReactive(target, key, value, enumerable) {
//函数内部就是一个局部作用域 这个 value 就只在函数内使用的变量(闭包)
if(typeof value === 'Object' && value != null && !Array.isArray(value)){
//非数组的引用类型
reactify(value);//递归
}
//值类型走这
Object.defineProperty(target, key, {
configurable: true,
enumerable: !!enumerable,
get() {
console.log(`读取 ${target} 的 ${key} 属性`);
return value;
},
set(newValue) {
console.log(`设置 ${target} 的 ${key} 属性为:${newValue}`);
value = newValue;
}
})
}
验证:
验证成功!
现在又有一个问题,如图:
新添加的数组元素等对数组元素操作,是没有响应式化的。
对象可以使用递归来响应式化,同时数组也需要处理。
对于数组使用-push -pop -shift -unshift -reverse -sort -splice
…方法,对其实现响应化。
那需要做什么?
Ⅰ. 在改变数组数据的时候,发出“通知”
比如 > data.name
会有 读取[Object object]的 name 属性
的“通知”
Vue2
中的缺陷是数组发生变化,设置 length
无法通知,Vue3
使用 Proxy
语法(es6
语法)解决了问题。
Ⅱ. 加入的元素应该变成响应式
技巧:如果一个函数已经定义了,但是需要扩展其功能,一般处理办法:
㈠ 使用一个临时的函数名存储函数
㈡ 重新定义原来的函数
㈢ 定义扩展的功能
㈣ 使用临时的函数
来个例子:
function func() {
console.log('原始功能');
}
//1.使用一个临时的函数名存储函数
let _tmpFn = func;
//2.重新定义原来的函数
func = function () {
//4.使用临时的函数
_tmpFn();
//3.定义扩展的功能
console.log('新的扩展功能');
}
func();//打印
//原始功能
//新的扩展功能
上述就是在函数原有的基础上添加额外的操作:函数的拦截
Q3
: 扩展数组的方法(现只考虑 push
和 pop
)如何处理?
A3
: 修改要进行响应式化的数组的原型(_proto_
)
思路:原型式继承:修改原型链的结构
let arr = [];//字面量创建
//继承关系: arr --> Array.prototype --> Object.prototype --> ...
//继承关系: arr --> 改写的方法 --> Array.prototype --> Object.prototype --> ...
let arr = [];
let ARRAY_METHOD = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice'
];
/*
Object.create()方法是ES5中方法,这个方法用于创建一个新对象。被创建的对象继承另一个对象的原型,在创建新对象时可以指定一些属性
*/
let array_methods = Object.create(Array.prototype);
ARRAY_METHOD.forEach(method => {
//改写数组里的方法
array_methods[method] = function () {
//调用原来的方法
//调用拦截的method方法
console.log('调用拦截的' + method + '方法');
//改变上下文
let res = Array.prototype[method].apply(this, arguments);
return res;//数组方法是有返回值的
}
});
arr.__proto__ = array_methods;
//对 arr 操作试试看
图示操作:
tips
: 关于 apply bind call prototype __proto__
等相关知识我会在 你应该掌握的 javascript 高阶技能
专栏中更新!
Q4
: __proto__
是否有兼容性问题?
A4
: Vue
源码中做出了判断,如果浏览器支持 __proto__
, 那么操作如上述,如果不支持, Vue
使用的是混入(简单理解就是不挂载原型上,而是挂载到当前对象上)
Vue
部分源码
/**
* Augment a target Object or Array by intercepting
* the prototype chain using __proto__
*/
//通过拦截来增强目标对象或数组,使用__proto__的原型链
function protoAugment (target, src) {
/* eslint-disable no-proto */
target.__proto__ = src;
/* eslint-enable no-proto */
}
// protoAugment 方法 类似于 arr.__proto__ = array_methods;
现在对数组的元素进行响应式化,也就是拦截中进行操作。
function reactify(o) {
//todo...
if (Array.isArray(value)) {
//是数组
value.__proto__ = array_methods;//数组响应式化
for (let j = 0; j < value.length; j++) {
reactify(value[j]);
}
} else {
//对象或值类型
defineReactive(o, key, value, true);
}
}
ARRAY_METHOD.forEach(method => {
//改写数组里的方法
array_methods[method] = function () {
//调用原来的方法
//调用拦截的method方法t
//将数据进行响应化
//todo...reactify the data
for(let i = 0; i < arguments.length; i++){
reactify(argument[i]);
}
//todo...
}
});
验证:
数组元素修改和新添加的数组元素都是响应式的。验证成功。
现有又有一个问题 Q4
来了,如图:
此时的 course
不是响应式的。
Q4
: 现在已经将对象改成响应式了,但是 course
是数组,如果直接赋值为对象,那么就不是响应式的。如何解决?
A4
: set
方法中将新值进行响应式化处理
//todo...
set(newValue) {
console.log(`设置 ${target} 的 ${key} 属性为:${newValue}`);
value = reactify(newValue);
//现在 newValue 是对象
//reactify处理的是对象的属性
that.mountComponent();
}
//...
这里还是没有解决,如果你有很好的想法,欢迎在评论区讨论!
Vue2
中比如说直接为数组元素赋值、修改数组长度等等不能触发视图更新,数据也不是响应式的,Vue3
使用 Proxy
代理解决了这个问题,实际上,现在写的reactify
并没有对数组进行处理。
现在需要将响应式化的需求代码与上期的
JGVue
构造函数进行合并。
function JGVue(options) {
this._data = options.data;
let elm = document.querySelector(options.el);
this._template = elm;
this._parent = elm.parentNode;//获取父元素
//提供一个新的方法 挂载
reactify(this._data);
this.mount();
}
如图:
Q5
: 现在又有一个需求,修改数据的时候,模板更新?
A5
: 调用 mountComponent
,在 defineReactive
中 set()
中进行模板更新就可以了。(现在这里需要传入 Vue
实例,但是还有问题的,实际上是使用 watcher
来完成的,后期会讲)
function JGVue(options) {
this._data = options.data;
let elm = document.querySelector(options.el);
this._template = elm;
this._parent = elm.parentNode;//获取父元素
//提供一个新的方法 挂载
reactify(this._data,this);//传入Vue实例 折中处理
this.mount();
}
//简化版
function defineReactive(target, key, value, enumerable) {
//折中处理以后 this 就是 vue
let that = this;
//todo...
Object.defineProperty(target, key, {
//todo...
set(newValue) {
console.log(`设置 ${target} 的 ${key} 属性为:${newValue}`);
//模板更新
//todo? 传入 Vue 实例
value = newValue;
that.mountComponent();
//先赋值再更新
}
})
}
function reactify(o,vm) {
//todo...
if (Array.isArray(value)) {
//是数组
value.__proto__ = array_methods;//数组响应式化
for (let j = 0; j < value.length; j++) {
reactify(value[j],vm);
}
} else {
//对象或值类型
defineReactive.call(vm, o, key, value, true);
}
}
验证:更新数据,页面更新重新渲染
注意:模板使用到的 _data
都会触发 getter
, combine
方法中 getValueByPath
也会触发 getter
, 因为访问了数据,模板更新和渲染(数据改变),同样道理。还记得 update
方法嘛?this._parent.replaceChild(...)
是直接替换整个 DOM
,而 Vue
中是使用 diff
对页面进行局部替换,现在写的还是有很多瑕疵,但是重点是要有这种思想!
JGVue
源码如下:
let r = /\{\{(.+?)\}\}/g;
//虚拟 DOM 构造函数
class VNode {
//tag 标签 data 描述属性 value 文本内容 type (elm)
constructor(tag, data, value, type) {
this.tag = tag && tag.toLowerCase();
this.data = data;
this.value = value;
this.type = type;
this.children = [];
}
appendChild(vnode) {
this.children.push(vnode);
}
}
//由 HTML DOM 生成虚拟 DOM (VNode)
//将这个函数当作 compiler 函数
//vue 把 vnode 作为 字符串 进行解析 得到 ast
//这个带有坑的模板 模拟 ast
function getVNode(node) {
//获取虚拟DOM节点
let nodeType = node.nodeType;//类型值
let _vnode = null;
if (nodeType === 1) {//这里看过源码阅读(一)估计有印象吧
//元素
let nodeName = node.nodeName;
let attrs = node.attributes;
//attrs 返回所有属性构成的伪数组,而我们要把它包装成 data
//伪数组转换成对象
let _attrObj = {};
for (let i = 0; i < attrs.length; i++) {
//attrs[i]属性节点(nodeType == 2)
//用 nodeName 和 nodeValue 来描述这样的结构
_attrObj[attrs[i].nodeName] = attrs[i].nodeValue;
}
//来初始化
_vnode = new VNode(nodeName, _attrObj, undefined, nodeType);
//考虑 node(真正的DOM) 子元素
let childNodes = node.childNodes;
for (let i = 0; i < childNodes.length; i++) {
//对每一个childNodes进行生成虚拟DOM 加入VNode
_vnode.appendChild(getVNode(childNodes[i]));//递归
}
} else if (nodeType === 3) {
//todo...
//文本节点
_vnode = new VNode(undefined, undefined, node.nodeValue, nodeType);
}
return _vnode;
}
function JGVue(options) {
this._data = options.data;
let elm = document.querySelector(options.el);
this._template = elm;
this._parent = elm.parentNode;//获取父元素
//提供一个新的方法 挂载
reactify(this._data, this);
this.mount();
tion getValueByPath(obj, path) {
let paths = path.split('.');// [foo,bar,baz]
let res = obj;
let prop;
while (prop = paths.shift()) {
// paths.shift() 返回它的第0项 删除
// 每次取一项
res = res[prop];
}
return res;
//我的思路 递归 循环 ?... reduce方法可尝试...
//如果有 undefined ?需要解决一下...
}
// 将 AST 和 data 结合 生成 VNode
// 现简化:待有“坑”的 VNode + data ==> 含有数据的VNode
// 模拟 AST --> VNode 行为
function combine(vnode, data) {
let _type = vnode.type;
let _data = vnode.data;
let _value = vnode.value;
let _tag = vnode.tag;
let _children = vnode.children;
let _vnode = null;
if (_type === 3) {
//文本结点
//填“坑”
_value = _value.replace(r, function (_, g) {
return getValueByPath(data, g.trim());
});
_vnode = new VNode(_tag, _data, _value, _type);
} else if (_type === 1) {
//元素结点
_vnode = new VNode(_tag, _data, _value, _type);
_children.forEach((_subvnode) => _vnode.appendChild(combine(_subvnode, data)));
}
return _vnode;
}
JGVue.prototype.mount = function () {
//调用 mountComponent()之前需要提供一个render方法
//作用生成 虚拟 DOM
//需要提供一个 render
this.render = this.createRenderFn();
this.mountComponent();
}
JGVue.prototype.mountComponent = function () {
//执行mountComponent()函数
let mount = () => {
this.update(this.render());
//render 用于生成虚拟 DOM update 负责渲染到页面上
}
mount.call(this);//本质上应该交给 watcher 来调用
}
//生成 render 函数 目的是为了缓存AST (使用虚拟 DOM 来模拟)
JGVue.prototype.createRenderFn = function () {
let ast = getVNode(this._template);
//ast 用虚拟 DOM 模拟
return function render() {
// 将 AST 和 data 结合 生成 VNode
// 现简化:待有“坑”的 VNode + data ==> 含有数据的VNode
//todo... combine
let _tmp = combine(ast, this._data);
return _tmp;
}
}
//将 vNode 转换为真正的 DOM
function parseVNode(vnode) {
//创建真正的 DOM
let type = vnode.type;
let _node = null;//真正的 DOM 结点
if (type === 3) {
//文本节点
return document.createTextNode(vnode.value);
} else if (type === 1) {
_node = document.createElement(vnode.tag);
//标签属性
let data = vnode.data;
//data是键值对 比如 data: {class:"1"}
Object.keys(data).forEach((item) => {
let attrName = item;
let attrValue = data[item];
_node.setAttribute(attrName, attrValue);
});
//该结点的子元素
let children = vnode.children;
children.forEach((subvnode) => {
// appendChild 添加子节点 跟 VNode 类中方法 appendChild 不是同一个方法
_node.appendChild(parseVNode(subvnode));
递归转换子元素 ( 虚拟 DOM )
});
return _node;
}
}
//在真正的 Vue 中使用了二次提交的设计结构
//将虚拟 DOM 渲染到页面中,diff算法在这里
JGVue.prototype.update = function (vnode) {
// 简化, 直接生成 HTML DOM replaceChild 到页面中
// 父元素.replaceChild(newElement,oldElement)
// 需要拿到父元素
let realDOM = parseVNode(vnode);
let oldDOM = document.querySelector('#root');
// 不太正确
this._parent.replaceChild(realDOM, oldDOM);
// 这样会将页面中的 DOM 全部替换
}
let ARRAY_METHOD = [
'push',
'pop',
'shift',
'unshift',
'reverse',
'sort',
'splice',
];
let array_methods = Object.create(Array.prototype);
ARRAY_METHOD.forEach(method => {
//改写数组里的方法
array_methods[method] = function () {
//调用原来的方法
//调用拦截的method方法
//将数据进行响应化
//todo...reactify the data
for (let i = 0; i < arguments.length; i++) {
reactify(arguments[i]);
}
console.log('调用拦截的' + method + '方法');
let res = Array.prototype[method].apply(this, arguments);
return res;
}
})
function defineReactive(target, key, value, enumerable) {
let that = this;
//函数内部就是一个局部作用域 这个 value 就只在函数内使用的变量(闭包)
if (typeof value === 'Object' && value != null && !Array.isArray(value)) {
//非数组的引用类型
reactify(value);//递归
}
Object.defineProperty(target, key, {
configurable: true,
enumerable: !!enumerable,
get() {
console.log(`读取 ${target} 的 ${key} 属性`);
return value;
},
set(newValue) {
console.log(`设置 ${target} 的 ${key} 属性为:${newValue}`);
value = reactify(newValue);
that.mountComponent();
}
})
}
function reactify(o, vm) {
let keys = Object.keys(o);//['name,'age','course']
for (let i = 0; i < keys.length; i++) {
let key = keys[i];
let value = o[key];
// 判断这个属性是否是引用类型
// 判断是否是数组
// 如果是引用类型 需要递归
// 不是 就不用递归
// 如果是数组 就需要循环数组 然后将数组里面的元素进行响应式化
if (Array.isArray(value)) {
value.__proto__ = array_methods;
//是数组
for (let j = 0; j < value.length; j++) {
reactify(value[j], vm);
}
} else {
//对象或值类型
defineReactive.call(vm, o, key, value, true);
}
}
}
let app = new JGVue({
el: "#root",
data: {
name: 'zhangsan',
age: '18',
gender: 'male',
course: [
{ name: '数据结构' },
{ name: '操作系统' },
{ name: '计算机网络' },
{ name: '计算机汇编与组成原理' }
]
}
})
这代码好乱,明天再看:这是我写的?后面需要构建工具帮帮忙了。
下期内容:代理方法与事件模型、
vue
中Observer Watcher Dep
学习心得
:源码学习过程一定是艰难
的,源码好比一辆车繁多的零件配置与复杂的组装图纸,而使用Vue
好比开车,阅读源码的益处就不用我多说了吧。阅读源码的过程中我也翻看了许多资料,大多数都是直接上手源码,从合并策略,数据代理检测,完整的渲染流程等等讲解,可能看了半天什么也没懂,而此次Vue
源码阅读专栏只要你对Vue
有一定的基础,我相信你能看明白!
随着
Vue
源码深入学习,同时难度也在增大,所以说要求我们javascript
打下良好的基础。为此,后面开一专题你应该掌握的 javascript 高阶技能
,为大家提供更多的优质学习内容,希望大家多多支持!