Vue官方文档和源码研读
2021.08.31开始 运行时的源码在vue/dist/vue.runtime.esm.js里面,不过有些非函数的定义,打印不出来(看看vue源码断点怎么打比较合适)。看官方文档时候,直接百度中文的那段文字,经常搜不出来,可以先根据官方文档或自己定位到对应的代码段,然后搜索代码段的变量名,就能搜到了
1.官方文档 模板语法-插值-使用javascript表达式 最后,有这么一句话:模板表达式都被放在沙盒中,只能访问全局变量的一个白名单,如 Math
和 Date
。你不应该在模板表达式中试图访问用户定义的全局变量。
开始我以为是vue.prototype.$aa = 1,不能访问这个$aa。看了源码之后,才明白。全局对象指的是Array、Object这类的js关键字,当然也可以自己定义。比如我写个js文件 export 1,然后在vue文件中import aaa from ./js文件,在模板语法中使用{{aaa}},就会报错。因为模板表达式的沙盒里没有aaa这个变量。想使用的话可以把它挂载到vueprototype上或者直接赋值给当前vue实例的data中的一个属性
function makeMap ( str, expectsLowerCase ) { var map = Object.create(null); var list = str.split(','); for (var i = 0; i < list.length; i++) { map[list[i]] = true; } return expectsLowerCase ? function (val) { return map[val.toLowerCase()]; } : function (val) { return map[val]; } }
var allowedGlobals = makeMap( // 'Infinity,undefined,NaN,isFinite,isNaN,' + 'parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,' + 'Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,' + 'require' // for Webpack/Browserify );
var hasHandler = { has: function has (target, key) { //target就是当前vue实例,key就是要解析的值 var has = key in target; //对象的in方法,只要key在target的原型链上,就会返回true,所以设置vue.prototype.$aa = 1之后,使用$aa是没问题的
var isAllowed = allowedGlobals(key) || (typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data)); //这里判断的是全局变量或者以_
开头的属性,这么做是由于渲染函数中会包含很多以_
开头的内部方法,如渲染函数里遇到的_c
、_v
等等 if (!has && !isAllowed) { if (key in target.$data) { warnReservedPrefix(target, key); } else { warnNonPresent(target, key); } } return has || !isAllowed } };
最后的判断!has
我们可以理解为你访问了一个没有定义在实例对象上(或原型链上)的属性,所以这个时候提示错误信息是合理,但是即便!has
成立也不一定要提示错误信息,因为必须要满足!isAllowed
,也就是说当你访问了一个虽然不在实例对象上(或原型链上)的属性,但如果你访问的是全局对象那么也是被允许的。这样我们就可以在模板中使用全局对象了
2.在谷歌浏览器的控制台,打印一些数据时。发现同样的数据,显示的格式经常是不一样的,有时是data:{...},有时是data:{a:1},有时是data:{__ob__:Observer}。
经过我的验证,发现没被挂载到vue实例上的(被浅拷贝的也算),会直接显示数据data:{a:1},被挂载到vue实例上并且对象长度小于等于4的,会显示data:{__ob__:Observer},其余显示的都是data:{...}
拓展:给vue data属性的对象添加属性时,有以下几种情况。
const a = {content:{a:1,b:2,c:3,d:4,e:5}} 情况一 this.form = a; 这样相当于将a的地址赋值给this.form,这时不管是打印a 还是this.form都是一样的,不会去判断对象长度是多少,点开查看(调用getter)之前都是显示{...} 情况二 this.form = {...a}或者this.form = Object.assign({},this.form,a). 这时this.form===a是false,他俩这时的引用类型属性是共用一个地址,但是基本类型的值是互不干扰的。 打印a,会判断长度,小于等于4的显示{__ob__:Observer},大于4的显示{...}; 打印this.form,不会判断长度,统一显示{...} 情况三 this.form = JSON.parse(JSON.stringify(a)) //深拷贝 这时打印a,会直接显示所有的值,不会有{...}和{__ob__:Observer},因为它没有被vue影响 打印this.form,不会判断长度,统一显示{...} 但是,以上三种情况,当我直接打印this.form.a时,都会显示一个{__ob__:Observer}。
具体原因要等以后看源码再了解了(2021.09.07)
3.当你设置 vm.someData = 'new value'
,该组件不会立即重新渲染(这里指的是html里的dom元素 不会重新渲染)。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)
。这样回调函数将在 DOM 更新完成后被调用。
因为 $nextTick()
返回一个 Promise
对象,所以你可以使用新的 ES2017 async/await 语法完成相同的事情:
methods: { updateMessage: async function () { this.message = '已更新' console.log(this.$el.textContent) // => '未更新' await this.$nextTick() console.log(this.$el.textContent) // => '已更新' } }
当我使用vue检测不到的方法来增减对象和数组时,可能是数据驱动不了视图,也可能是数据驱动了视图(原因参考7)。但通过this.$refs.textContent是获取不到更新后的dom的,这时候就要用$nextTick了。
4.VUE事件修饰符 https://cn.vuejs.org/v2/guide/events.html#%E4%BA%8B%E4%BB%B6%E4%BF%AE%E9%A5%B0%E7%AC%A6
注意: 特殊的系统修饰键有ctrl、alt 、shift、 meta(window键),例如 @key.alt.67 //alt+c @click.ctrl //ctrl+click
修饰键与常规按键不同,在和 keyup
事件一起用时,事件触发时修饰键必须处于按下状态。换句话说,只有在按住 ctrl
的情况下释放其它按键,才能触发 keyup.ctrl
。而单单释放 ctrl
也不会触发事件。如果你想要这样的行为,请为 ctrl
换用 keyCode
:keyup.17
2.5.0新增的exact修饰符 允许你控制由精确的系统修饰符组合触发的事件。
<!-- 即使 Alt 或 Shift 被一同按下时也会触发。只监听ctrl,不管同时有其他几个按键在按 -->
<button v-on:click.ctrl="onClick">A</button>
<!-- 有且只有 Ctrl 被按下的时候才触发 -->
<button v-on:click.ctrl.exact="onCtrlClick">A</button>
<!-- 没有任何系统修饰符被按下的时候才触发 -->
<button v-on:click.exact="onClick">A</button>
鼠标修饰符有 .left .right .middle
5.复选框<input
type="checkbox"
v-model="toggle"
true-value="yes"
false-value="no"
>
// 当选中时
vm.toggle === 'yes'
// 当没有选中时
vm.toggle === 'no'
这里的 true-value
和 false-value
attribute 并不会影响输入控件的 value
attribute,因为浏览器在提交表单时并不会包含未被选中的复选框。如果要确保表单中这两个值中的一个能够被提交,(即“yes”或“no”),请换用单选按钮。
翻译:比如有这样的页面
<input type="checkbox" v-model="picked" :true-value="value1" :false-value="value2"> <label> 复选框 </label> <p> {{picked}} </p> <p>{{value1}} </p> <p>{{value2}} </p>
data: { picked:false, value1:123, value2:345 }
那么刚进页面时,picked的值就是false,这个value1和value2的值是不会影响picked的值的。
但是一旦对这个chekcbox做了勾选或取消勾选的操作,这个picked的值就会变成123或者456。
拓展:如果换成radio的话,刚进页面是一样的,但是一旦做了操作,picked的值就会变成null。
6.阅读vuex官方文档时,对于rootState一直无法理解,官方文档对于它的定义是 根节点状态。但是有rootState.count这种用法,而我打印的时候,rootState底下就是几个module对象,没有.count这一层级。
原因:参考了一些文档,发现.count确实是根节点的状态,并且想获取这个层级的值,就要在new Vuex.Store()时给根节点赋一个state对象。像这样
const store = new Vuex.Store({ state: { count: 1, }, modules: { a: moduleA, b: moduleB, }, })
这时候在moduleA的action或者getters里面打印rootState,里面就有count和a、b两个modules里面的state对象。
7.Vue 深入响应式原理说明:Vue 不能检测数组和对象的一些变化,也就是说数据更新时视图不会更新。但是实际使用中发现还是更新了。
原因: 查了一些文档,没发现回答。自己又测试了一下,发现官方文档写的是没有问题的。操作的时候,如果只有arr.length=1或者arr[0] = 1这种操作时,不会触发视图更新。但是如果有其他的能被vue检测到的数据更新(this.count='xx),就会顺带把vue检测不到的数据变动也一起更新了,这样就实现了类似arr.length = 1也被vue检测到的效果。但是这样只更新了视图,并没有给这个新数据加上getter和setter,在控制台打印可以看到,有getter和setter的会默认不显示,没有的会直接显示出来。
坏处:目前能想到的坏处,就是虽然视图更新了,但是vue的watch是监听不到数据变化的。因为这个数据没有setter,watch是靠监听setter来实现的。
export default { data() { return { items: ['a', 'b', 'c'],
count:1 } }, methods: { hah() { this.items.length = 2 // 不是响应性的,没有被vue检测到
//this.count = 'xx' //是响应性的,会触发vue更新 setTimeout(() => { this.items.shift() },3000) }, }, watch: { items(new,old) { console.log(new,old) // 1,3 ,vue只能监听到一开始的长度3和shift之后的长度1 } } }
源码解析:简单打断点看了一下,应该是this.count触发了它自己的set和watch.然后又触发了vue的$nextTick和render方法,vue就顺带把当前实例的数据都更新到视图上了。但是并没有给新属性加上setter和getter,也没有触发其他属性的watch。
经验:以后对于vue检测不到的变动,还是都用this.$set来操作吧。因为不可能每次都正好有别的属性在更新,万一一开始有,后面又拿掉了那个更新的属性操作,vue就检测不到更新了,这是在埋bug.
8.vue官方文档说到父组件引用子组件时的标签名和props在标签上的使用,是这样的
使用 kebab-case Vue.component('my-component-name', { /* ... */ })
当使用 kebab-case (短横线分隔命名) 定义一个组件时,你也必须在引用这个自定义元素时使用 kebab-case,例如 <my-component-name>
。
使用PascalCase Vue.component('MyComponentName', { /* ... */ })
当使用 PascalCase (首字母大写命名) 定义一个组件时,你在引用这个自定义元素时两种命名法都可以使用。也就是说 <my-component-name>
和 <MyComponentName>
都是可接受的。注意,尽管如此,直接在 DOM (即非字符串的模板) 中使用时只有 kebab-case 是有效的。
重点在最后一句,我在.vue文件中直接使用<MyComponent userName="a"/>是可用的,这是为啥?
原因:vue官方文档经常提到的字符串模板和非字符串模板,其实是这样定义的
1.字符串模板就是写在vue中的template中定义的模板,如.vue的单文件组件模板和定义组件时template属性值的模板。字符串模板不会在页面初始化参与页面的渲染,会被vue进行解析编译之后再被浏览器渲染,所以不受限于html结构和标签的命名。
Vue.component('MyComponentA', { template: '<div MyId="123"><MyComponentB>hello, world</MyComponentB></div>' }) <div id="app"> <MyComponentA></MyComponentA> </div>
2.dom模板(或非字符串模板、Html模板)就是写在html文件中,一打开就会被浏览器进行解析渲染的,所以要遵循html结构和标签的命名,否则浏览器不解析也就不能获取内容了。
下面的例子不会被正确渲染, 会被解析成mycomponent,但是注册的vue的组件是MyComponent,因此无法渲染。
<!DOCTYPE <html> <head> <meta charset="utf-8"> <title>Vue Component</title> </head> <body> <div id="app"> Hello Vue <MyComponent></MyComponent> </div> <script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js"></script> <script > //全局注册 Vue.component('MyComponent', { template: '<div>组件类容</div>' }); new Vue ({ el: '#app' }); </script> </body> </html>
所以,下面的例子就可以正常显示了:
<!DOCTYPE <html> <head> <meta charset="utf-8"> <title>Vue Component</title> </head> <body> <div id="app"> Hello Vue <my-component></my-component> </div> <script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js"></script> <script > //全局注册 Vue.component('my-component', { template: '<div>组件类容</div>' }); new Vue ({ el: '#app' }); </script> </body> </html>
因为html对大小写不敏感,所以在DOM模板中使用组件必须使用kebab-case命名法(短横线命名)。
因此,对于组件名称的命名,可参考如下实现:
/*-- 在单文件组件、JSX和字符串模板中 --*/ <MyComponent/> /*-- 在 DOM 模板中 --*/ <my-component></my-component> 或者 /*-- 在所有地方 --*/ <my-component></my-component>
9,vuex官方文档说只能通过mutation来修改state,而实际使用中,发现this.$store.state.a = 132是可以生效的,并且其他组件也都可以访问,只不过它没有被设置set和get,想要这两个用this.$set就可以了。不过在vuex的严格模式下会报错。
开启严格模式,仅需在创建 store 的时候传入strict: true
const store = new Vuex.Store({ // ... strict: true 或者
strict: process.env.NODE_ENV !== 'production'
})
在严格模式下,无论何时发生了状态变更且不是由 mutation 函数引起的,将会抛出错误。这能保证所有的状态变更都能被调试工具跟踪到。
总结:为了防止vuex的数据改变来源难以追踪,使用中统一用mutation来改变state。也可以在开发过程中开启vuex严格模式(生产环境中必须关闭)。
10.vue实例中的data、computed和methods中,访问this指向的为什么是当前vue实例?
原因:源码是使用了bind和call,改变了computed、methods()和data()中的this指向。代码在src/core/instance/init.js的initData()和initMethods()中。官方文档有说明,computed和data可以使用箭头函数,但是函数内部的this就不是vue实例了,这时候可以把vm作为函数的第一个参数进行传递,data: vm => ({ a: vm.myProp })。但是对于methods和watch,是不可以使用箭头函数的,在参数里也获取不到vm。
11.在阅读src/core/instance/state.js时,发现js初始化的定义了一个sharedPropertyDefinition。
const sharedPropertyDefinition = { enumerable: true, configurable: true, get: noop, set: noop, };
在后续的代码中,每次给不同的属性加set和get时,都是直接
sharedPropertyDefinition.get = function proxyGetter() { return this[sourceKey][key]; }; sharedPropertyDefinition.set = function proxySetter(val) { this[sourceKey][key] = val; }; Object.defineProperty(target, key, sharedPropertyDefinition);
每次都是这样,但是更改后的sharedPropertyDefinition.get和set并不会影响到之前的属性
原因:给第一个属性a赋值时,在内存中开辟了一处空间A来存放get和set。后续改变sharedPropertyDefinition时,由于是直接将函数赋值给get和set的,相当于是在内存中又开辟了一处空间B,来存放新的get和set。而之前的a属性,对应的地址还是A,这时再继续给其他属性b赋值的话,对应的地址就是B了。所以每次更改sharedPropertyDefinition时,不会影响之前的属性。
拓展:像这种改变之后不会影响之前赋值的情况,除了引用类型的直接赋值,还有一种就是值类型的赋值。因为值类型的赋值只拷贝了值。可以看下面的例子
① 不会影响(值类型只拷贝了值) let aa = 1; let bb = aa; aa = 2; console.log(bb) // 1 ②不会影响 (引用类型直接赋值,指向新地址,开辟新空间) let aa = {a:1}; let bb = aa; aa = {a:2}; console.log(bb) // {a:1} ③会影响 (引用类型共用一个地址) let aa = {a:1}; let bb = aa; aa.a =2; console.log(bb) // {a:2}
12.在阅读src/core/instance/state.js时的createComputedGetter()方法时,发现函数中直接使用了this,指向的是vue实例。我打断点看了一下,这个方法是vue的某个computed属性被读取时触发,也就是某个computed属性的get方法。这里可以理解,因为触发条件是this.b,这个this就是vue实例。自己写了个小demo,测试属性的get和set方法中的this指向;
const aa = {b:2}; Object.defineProperty(aa,'a',{get:function() { console.log(this) // {b:2} // 为什么这里不是{b:2,a:1},因为return 1的语句在这里还没有执行 return 1; }}); console.log(aa.a) //1
13.vue源码的initData方法中,最后执行了observe(data, true)方法。
function observe(value, asRootData) { // 非对象和 VNode 实例不做响应式处理。因为get和set是加在非对象外面的对象上的,比如data里有个a,那么这个a的get和set是加在data这个对象上 if (!isObject(value) || value instanceof VNode) { return } ..... }
但是打印一下vue实例,会发现vm实例上没有__Ob__属性,是因为没有做响应式处理吗?显示不是的,我们再继续找,原来__Ob__是被加到了_data和$data对象上。vue实例上能直接看到data里的各个属性,只是因为使用了proxy()方法,其实真正的属性是在_data和$data上
function initData (vm: Component) { // vm.$options 是访问自定属性,此处就是vue实例中的 this.$data let data = vm.$options.data; // 先执行三元表达式,然后赋值给 vm._data 和 data,这样子这两个值都是同时变化的,就是 this.$data.xxx 和 this._data 同时变化 data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {} ……… }
看看proxy是怎么实现的
//调用:proxy(vm, "_props", key) 和 proxy(vm, "_data", key); function proxy (target, sourceKey, key) { sharedPropertyDefinition.get = function proxyGetter () { return this[sourceKey][key] }; sharedPropertyDefinition.set = function proxySetter (val) { this[sourceKey][key] = val; }; Object.defineProperty(target, key, sharedPropertyDefinition); } //比如_data有个a:1,就是Object.defineProperty(vm,'a', sharedPropertyDefinition);sharedPropertyDefinition return 的都是this._data.a;
这样就实现了代理,可以通过this.a访问和修改this._data_a了
14.computed属性的生命周期
initComputed函数
① 在这里为每一个computed[key]添加了一个lazy=true的watcher,并且让每一个watcher[key]的dirty属性 = this.lazy(true)。
②执行defineComputed,为computed[key]设置getter和setter,注意这里只是设置,并不会立即执行。接着,通过 Object.defineProperty(target, key, sharedPropertyDefinition) 将computed[key]对应的getter和setter设置在vue根实例上 (props属性也是如此。但是不知道为什么不设置在当前vue实例上,并且computed[key]的值貌似不是通过proxy设置在当前vue实例上的)。
当computed[key]被读取时,会执行getter函数,第一次会执行watcher.evaluate(),然后将dirty置为false,下次再获取当前的computed[key],就会不进行计算,直接返回watcher.value,这就是computed的缓存机制。当页面刷新,或者computed[key]依赖的data值更新时,会触发watcher.update()来将dirty 重置为true。但是,这里不是全量重置。比如有两个computed的key,aa(依赖a),bb(依赖b),当b的值更新时,aa的watcher的dirty依旧是false,还是取缓存。
当computed[key]被修改时,会执行setter函数,这个函数是用户自己定义的,只能用来监听setter并做出一些操作,并不能修改自己的值,所以没啥好说的。
当computed[key]被读取时,还会判断是否有Dep.target,如果有,就执行watcher.depend(依赖收集,在 dep 中添加 watcher,也在 watcher 中添加 dep)
15.Dep、Watcher的关系
<div>{{name}}</div> data() { return { name: '林三心' } }, computed: { info () { return this.name } }, watch: { name(newVal) { console.log(newVal) } }
上方代码可知,name
变量被三处地方所依赖,分别是html里,computed里,watch里
。只要name
一改变,html里就会重新渲染,computed里就会重新计算,watch里就会重新执行。那么是谁去通知这三个地方name
修改了呢?那就是Watcher
了。
上面所说的三处地方就刚刚好代表了三种Watcher
,分别是:
渲染Watcher
:变量修改时,负责通知HTML里的重新渲染computed Watcher
:变量修改时,负责通知computed里依赖此变量的computed属性变量的修改user Watcher
:变量修改时,负责通知watch属性里所对应的变量函数的执行
name
变量被三个地方所依赖,三个地方代表了三种Watcher
,那么name
会直接自己管这三个Watcher
吗?答案是不会的,name
会实例一个Dep,来帮自己管这几个Wacther
,类似于管家,当name
更改的时候,会通知dep,而dep则会带着主人的命令去通知这些Wacther
去完成自己该做的事。这是因为computed属性里的变量没有自己的dep,也就是他没有自己的管家,看以下例子:
这里先说一个知识点:如果html里不依赖
name
这个变量,那么无论name
再怎么变,他都不会主动
去刷新视图,因为html没引用他(说专业点就是:name
的dep
里没有渲染Watcher
),注意,这里说的是不会主动
,但这并不代表他不会被动
去更新。什么情况下他会被动去更新呢?那就是computed有依赖他的属性变量。
<div>{{person}}</div> computed: { person { return `名称:${this.name}` } }
person
事依赖于name
的,但是person
是没有自己的dep
的(因为他是computed属性变量),而name
是有的。好了,继续看,请注意,此例子html里只有person
的引用没有name
的引用,所以name
一改变,按理说虽然person
跟着变了,但是html不会重新渲染,因为name
虽然有dep
,有更新视图的能力,但是奈何人家html不引用他啊!person
想要自己去更新视图,但他却没这个能力啊,毕竟他没有dep
这个管家!这个时候computed Watcher
里收集的name
的dep
就派上用场了,可以借助这些dep
去更新视图,达到更新html里的person
的效果。具体会在下面computed里实现。