一、配置项目
初始化安装项目
- 初始化项目
npm init -y
- 安装webpack、webpack-cli、webpack-dev-server;
npm i webpack webpack-cli webpack-dev-server
- 安装html-webpack-plugin;
npm i html-webpack-plugin
:::
配置文件 webpck.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
module.exports = {
entry:'./src/index.js',
output:{
filename:'bundle.js',
path:path.resolve(__dirname,'dist')
},
devtool:'source-map',
resolve:{
// 配置寻找文件时,先在根目录文件下寻找,根目录文件没有找到,在去node_modules下寻找
modules:[path.resolve(__dirname,''),path.resolve(__dirname,'node_modules')]
},
plugins:[
new HtmlWebpackPlugin({
template:path.resolve(__dirname,'public/index.html')
})
]
}
二、实现vue2数据劫持的基本思路
2.1 程序入口 (src/index.js)
import Vue from 'vue'
let vm = new Vue({
el:'#app',
data(){
return {
title:'水果列表',
species:[
{
name:'apple',
number:20,
origin:'中国',
kind:[
{name:'red',number:15},
{name:'green',number:5}
]
},
{
name:'banana',
number:60,
origin:'泰国',
kind:[
{name:'yellow',number:50},
{name:'green',number:10}
]
},
],
merchant:{
address:'长征路',
number:3,
name:['小王水果','优乐选','百果园']
},
list:['香蕉','苹果'],
info:{
a:{
b:1
}
}
}
}
});
Vue
是构造函数,options
将是我们用户提供的配置项,例如:el、data、template、methods
等。_init()
初始化函数将是整个程序的入口。
function Vue(options) {
this._init(this, options)
}
init
方法做了以下几件事情:
- 保存
this
指向。为什么要保存this
指向呢?其实保存this
指向更容易让我们明确当前this
的指向,由于vm.init()
的原因,所以当前init
方法内部的this
指向vm
。 - 保存
options
配置项。为什么要保存options
配置项呢?其实我觉得是处于保存一份用户配置项为目的,做法也是非常的简单,将options
配置项挂载到vm
实例对象上即可。 - 保存挂载用户配置的
data
数据。为什么要保存data
配置项呢?在原生的Vue
实例对象上也挂载着_data
属性,其目的我觉得在于保存用户的原始数据,不在用户的原始数据上进行操作。
Vue.prototype._init = function(vm,options){
vm.$options = options;
initState(vm);
}
function initState(vm){
var options = vm.$options;
if(options.data){
initData(vm);
}
}
function initData(vm){
var data = vm.$options.data;
// data配置可能存在函数形式与对象形式。这两种形式都可以进行配置,区别在于函数的形式最后返回独立的对象,这样就避免引用共享的问题。所以我们在处理data形式的时候,需要分别进行判断,并且设置不同的返回值。
data = vm._data = typeof data === 'function' ? data.call(vm) : data || {};
}
2.2 难点一
目前为止,我们已经搭建了基本的架构,现在需要处理我们遇到的第一个问题,我们先梳理一下需要处理的问题:
现状:我们现在想通过vm
实例对象去获取data
属性下某个值,我们只能通过vm._data.xxx
的形式进行获取。因为在initData
方法中,我们已经将配置后的data
属性挂在到vm
实例对象上去了(vm._data = data = _data)
。
差异:但是vm._data.xxx
的形式并不是我们需要的,因为在原生的Vue
中,我们可以在其它配置项内部直接通过this.xxx
形式取值,例如:在methods
配置中获取title
属性值console.log(this.title)
。
预期:我们现在也想要能够通过this.xxx||vm.xxx
的形式进行取值操作。
解决方法:代理数据。什么是代理数据呢?其实我理解的很简单,也就是说,你现在想将vm._data.title
取值的形式转换为vm.title
的形式。那么实际上当你进行vm.title
取值操作时,你获取的值实际上就是vm._data.title
的值。当你进行vm.title = xxx
赋值操作时,你赋值的形式就是vm._data.title = xxx
。换句话说,这种取值赋值的行为相当于对vm._data.title
包裹了一层操作,通过这层操作才能对vm._data.title
起到效果,所以vm.title
就起到这层包裹的效果。
思路:所谓的代理数据,其实本质上目的也很明确,就是在你需要的对象上定义属性(如果用Object.defineProperty()
)方法。那么我们现在的思路就是要搞清楚,怎么代理数据?谁是代理对象?数据代理给谁?数据从那里来?这些问题?
回过头来看我们的需求:vm._data.title => vm.title
,我们需要将vm._data.title
的取值操作转换为vm.title
的形式。首先我们vm
实例对象上要有title
属性,不然怎么去进行取值操作,title
属性是哪里来的呢?很显然title
是options
配置项中data
数据中的属性,之前我们将options.data
保存备份在vm
实例对象中。那么vm.title
取值取的是谁的值呢?也很简单,vm.title => vm._data.title
,明白了吗?实际上vm.title
取的就是vm._data.title
值。
结合上述的分析,我们看下面方法的思路:
proxyData(vm, target, key)
方法是核心方法,思路也和我们上述分析的一致:在vm
实例对象上定义属性key
,取key
值的操作get(){}
方法实质是在取vm._data[key]
的值。而赋值set(newValue){}
操作,其实本质上是在对vm._data[key]
进行赋值。其中,set
函数做了优化,当新赋予的值与旧值相同时,则不进行赋值操作。
Vue.prototype._init = function(vm,options){
vm.$options = options;
initState(vm);
}
function initState(vm){
var options = vm.$options;
if(options.data){
initData(vm);
}
}
function initData(vm){
var data = vm.$options.data;
// data配置可能存在函数形式与对象形式。这两种形式都可以进行配置,区别在于函数的形式最后返回独立的对象,这样就避免引用共享的问题。所以我们在处理data形式的时候,需要分别进行判断,并且设置不同的返回值。
data = vm._data = typeof data === 'function' ? data.call(vm) : data || {};
for (const key in data) {
proxyData(vm, '_data', key);
}
}
function proxyData(vm,target,key){
Object.defineProperty(vm,key,{
get(){
// vm._data[key]
// console.log('proxyData',vm[target][key]);
return vm[target][key];
},
set(newValue){
// vm.title = xxx vm._data.title = xxx
if (newValue === vm[target][key]) return;
vm[target][key] = newValue;
}
})
}
到目前为止的话,我们成功完成基本的数据代理,现在你尝试vm.title
的形式去进行取值操作,就达到了我们预期。
2.3 难点二
现在我们需要引入一个新的概念:观察模式observe
。什么是观察模式呢?为什么要引入观察模式呢?在哪里去引入观察模式呢?
现状:结合原生的Vue
来说,当我们尝试修改完成data
中的数据时,视图也会有所随之更新。那么就需要我们想一想,为什么只是单纯的修改数据就能够影响到视图更新呢?我们目前能够实现这种效果吗?
差异:我们现在并不能做到数据变化之后,视图随之进行更新的效果。但是原生Vue
可以做到,那不妨我们大胆的猜测一下:也就是说,Vue
在数据变化的时候,它不仅仅只是让数据单纯的变化(取值与赋值操作),它还做了一系列的其他操作。这样的话,当数据修改的同时还会进行其他的操作。
预期:我们也想实现类似原生Vue
的这种效果。
解决方法:数据劫持正好符合我们的概念。所以,当我们不想让数据只是单纯的进行取值赋值操作时,我们可以使用数据劫持的概念,让数据单纯的取值赋值操作具备其他的逻辑能力。例如:当我数据修改的同时,我想让视图进行更新、我想收集依赖、我想执行其他的逻辑操作。那么观察者模式observe
和数据劫持有什么关系?从整体看下来,observe
观察者模式更加抽象,而数据劫持更加具体。怎么说呢?观察者更像是一种模式,而数据劫持是一种解决方法。数据劫持去操作每个数据的取值
和赋值
行为,当某种行为触发的时候,将在取值和赋值的基础上添加其他逻辑。而所有的数据都会被这种模式监听和观察,如果说数据劫持像一台机器的话,那么观察者模式就是一个工厂。某个数据进入到我的工厂里面,工厂把该数据投入到对应的机器中进行加工,当数据发生对应的行为时,就会产生对应的响应行为。所有的数据都遵循着这种模式,你只需要把数据交给我的工厂,我就会给你输出相同的产品。
function initData(vm){
var data = vm.$options.data;
data = vm._data = typeof data === 'function' ? data.call(vm) : data || {};
for (const key in data) {
proxyData(vm, '_data', key);
}
// 加入观察者模式
observe(vm._data)
}
observe()
观察者模式需要接收参数是对象的形式,当接受的是非对象的形式,将自动返回,因为原始值无需放入观察者模式中进行观察。
function observe(data){
if(typeof data !== 'object' || data == null) return;
return new Observer(data);
}
Observer
函数就是观察者模式的核心方法,需要我们注意的是,观察到对象不仅仅只是对象的形式,还有可能是数组的形式。对于不同的类型,我们需要分别处理,相比较数组的处理方法,对象的处理方法比较简单:
对象形式的处理思路:我们通过循环枚举对象属性,利用Object.defineProperty()
方法对该对象重新定义属性,利用数据劫持的概念去重新定义该属性取值与赋值的行为。需要我们注意的是:在进行数据劫持的时候,可能对象是深层次的,那么我们就需要递归深层次的进行数据劫持。
function Observer(data){
if(Array.isArray(data)){
// 数组形式
}else{
// 对象形式
this.walk(data);
}
}
Observer.prototype.walk = function(data){
// 获取该对象键值
var keys = Object.keys(data);
// console.log(keys,'keys');
// 循环枚举对象属性
for (let i = 0; i < keys.length; i++) {
var key = keys[i],
value = data[key];
// 数据劫持重新定义属性行为
defineReactiveData(data,key,value);
}
}
function defineReactiveData(data,key,value){
// 如果value是对象,继续递归深层次数据劫持
observe(value)
// 重新定义属性的取值与赋值行为
Object.defineProperty(data,key,{
get(){
console.log('响应式数据获取',value);
return value;
},
set(newValue){
console.log('响应式数据设置',newValue);
if(newValue === value) return;
// 如果设置的值是对象,递归深层次数据劫持
observe(newValue)
value = newValue;
}
})
}
数组形式问题出现的原因:虽然我们现在对数组本身进行了数据劫持,但是并未对数组内部的元素进行处理,这样做将会引发一个问题,比如说:我现在对 list 数据进行赋值的操作(vm.list = []
),现在我们是可以数据劫持到的。但是如果是vm.list.push(1)
的形式,那么现在我们就无法劫持到数据,因为Object.defineProperty
方法本身就是给对象定义属性了来用的,对于处理数组问题,它还不是随心所欲。换句话来说,我们目前无法劫持到对原数组本身操作的方法。
数组形式的解决方法:我们对那些操作原数组的方法进行重构,也就是说,我们不改变数组的原有方法,让数组的原有方法去处理关于对数组对操作。我们只需关注自己需要做的逻辑,比如说,push
方法添加的元素是否是对象或者数组形式,如果是的话,我们还需要将新增的数据放入到观察者模式。
// 定义哪些数组方法是操作的原数组
var ARR_METHODS = ['push','pop','shift','unshift','splice','sort','reverse'];
var originArrMethods = Array.prototype, // 拿到所有数组的方法
arrMethods = Object.create(originArrMethods); // 创建一个原型链指向数组的对象
ARR_METHODS.map(function(m){
// 遍历所有需要重新定义的数组方法
arrMethods[m] = function(){
// // 将实际参数列表转换数组形式
var args = Array.prototype.slice.call(arguments),
// 执行原数组的方法
rt = originArrMethods[m].apply(this, args);
console.log('数组新方法执行',args);
// 视图更新的逻辑......
var newArr;
switch(m){
case 'push':
case 'unshift':
newArr = args;
break;
case 'splice':
newArr = args.slice(2);
break;
default:
break;
}
newArr && observeArr(newArr);
return rt;
}
})
// 对数组新增的元素进行观察,因为新增的可能是新的数组或者对象
function observeArr(arr){
for (let i = 0; i < arr.length; i++) {
observe(arr[i])
}
}
三、源码链接
vue2数据劫持源码剖析