Vue的思考扩展
1、Vue是如何实现数据双向绑定的
1.1、实现双向绑定的基本原理
Vue 采用数据劫持结合发布者-订阅者模式的方式来实现数据的响应式,通过Object.defineProperty来劫持数据的setter、getter,在数据变动时发布消息给订阅者,订阅者收到消息后进行相应的处理,修改 dom 节点内容。
数据响应原理:当你把一个普通的 JavaScript 对象传给 Vue 实例的 data选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter,在属性被访问和修改时通知变化。每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的setter被调用时,会通知watcher重新计算,从而致使它关联的组件得以更新。
正如上面所说,vue实现数据双向绑定主要是采用数据劫持结合发布-订阅者模式的方式。
数据劫持是通过 Object.defineProperty() 实现的,该函数为每个属性添加setter、getter 的方法,在数据发生改变时 setter 方法会被触发,然后发布消息给订阅者,触发相应的监听回调函数。当把一个普通 Javascript 对象传给 Vue 实例来作为它的 data 选项时,Vue 将遍历它的属性,用 Object.defineProperty() 为每个属性添加 setter,getter 的方法。
vue的数据双向绑定主要通过三个模块完成:监听者Observer、订阅者Watcher、Compile解析模板指令。
执行顺序如下:
- 首先利用 Object.defineProperty() 创建 Observer,劫持所有属性。
- Compile() 初始渲染页面、为节点绑定函数、添加watcher监听者。Compile 会扫描和解析每个节点的相关指令,在遇到 {{}} 或 v-modle 等节点的时候,首先将模板中的变量替换成数据,根据初始数据渲染页面视图。并且将每个指令对应的节点绑定函数,一旦视图发生交互,绑定的函数就被触发,数据会发生变化。创建 Watcher 订阅者,并将它存入 Dep,当数据发生变化时,改变节点的内容。
由此即实现了双向绑定。当数据发生变化时,set 函数会触发,然后通知 watcher 改变元素内容。当元素节点发生一些交互时,节点绑定的事件会触发,此时会更新数据,set 函数会触发,然后通知 watcher 改变元素内容。
(详细可以查看下面的 1.3.2 查看代码实现)
var vm = new Vue({ data: { obj: { a: 1 } }, created: function () { console.log(this.obj); } });
打印 Vue 实例的data里的某个数据的某个属性,可以看到该属性含有 setter、getter 方法,也就是对该属性进行了监听。当我们使用 vue 时,输出某一个对象,当看到该对象的变量有 getter 和 setter,则意味着该对象的变量已经是响应式的了。
因为 vue 是在一开始时就对 data 里的所有属性遍历并添加 getter 和 setter,所以Vue 不能动态添加 data 属性里的根级别的响应式属性,并且由于通过直接修改数组索引的值无法触发 setter 函数,所以要想修改数组的索引值只能使用一些变异方法来修改,这些方法 vue 已经进行了重写。
1.2、原生JS实现简单双向绑定
1.2.1、命令式操作视图(简单的数据劫持示例)
<body> <div id="app"> <input type="text" id="txt"> <p id="show"></p> </div> </body> <script type="text/javascript"> var obj = {} Object.defineProperty(obj, 'txt', { get: function () { return obj }, set: function (newValue) { document.getElementById('txt').value = newValue document.getElementById('show').innerHTML = newValue } }) document.addEventListener('keyup', function (e) { obj.txt = e.target.value }) </script>
上面代码中,首先 defineProperty 为每个属性添加 getter、setter 方法,当数据发生改变, setter 方法被触发,视图也发生改变。setter 里面的执行命令可以看做是一个订阅者 Watcher,将视图和数据连接起来了。最下面的代码为节点绑定方法可以看做是Compile的作用,为指令节点绑定方法,当发生视图交互时,函数被触发,数据被改变,Watcher 收到通知,视图也将发生改变。
效果如下:
1.2.2、指令式操作视图(数据劫持+发布订阅者示例)
下面实现 v-text 声明式的指令版本。一但data中的属性值发生变化后,标记的 v-text 的文本内容就会立即得到更新。
(下面代码只是先实现了数据劫持)
<div id="app"> <p v-text="name"></p> <input v-model="searchVal" /> </div> <script> let data = { name: 'myname', searchVal: 'aaa测试' } // 遍历每一个属性 Object.keys(data).forEach((key) => { defineReactive(data, key, data[key]) }) function defineReactive(data, key, value) { Object.defineProperty(data, key, { get() { return value }, set(newVal) { value = newVal // 数据发生变化,操作dom进行更新 compile() } }) } function compile() { let app = document.getElementById('app') // 1.拿到app下所有的子元素 const nodes = app.childNodes // [text, input, text] //2.遍历所有的子元素 nodes.forEach(node => { // nodeType为1为元素节点 if (node.nodeType === 1) { const attrs = node.attributes // 遍历所有的attrubites找到 v-model Array.from(attrs).forEach(attr => { const dirName = attr.nodeName const dataProp = attr.nodeValue if (dirName === 'v-text') { node.innerText = data[dataProp] } if (dirName === 'v-model') { node.value = data[dataProp] // 视图变化反应到数据 无非是事件监听反向修改 node.oninput = function(e) { data[dataProp] = e.target.value } } }) } }) } // 首次渲染 compile() </script>
效果如下:
在控制台中直接修改 data 的属性值,元素标签的内容会同时发生改变,或者在输入框中直接输入值,data 中属性值实际也会发生改变。这也就实现了 view -> model 和 model -> view 的一个双向绑定的效果。
不管是指令也好,插值表达式也好,这些都是将数据反应到视图的标记而已,通过标记我们可以把数据的变化响应式的反应到对应的dom位置上去。我们把这个找标记,把数据绑定到dom的过程称之为binding。
上面的代码有个问题,就是在 data 的某个属性值发生改变时,整个 compile 函数都会被执行,所有的 dom 元素也都会被重新设置。下面我们可以通过发布-订阅者模式来优化代码,实现当 data 属性值发生改变时,只改变订阅了该属性值的 dom 元素的内容,实现精准更新。
代码如下:
(下面代码实现了数据劫持+发布-订阅者模式)
<div id="app"> <p v-text="name"></p> <input v-model="searchVal" /> </div> <script> let data = { name: 'myname', searchVal: 'aaa测试' } // 遍历每一个属性,利用 Object.defineProperty() 创建 Observer function observe(data) { if (!data || typeof data !== 'object') { return; } // 取出所有属性遍历 Object.keys(data).forEach(function (key) { defineReactive(data, key, data[key]); }); }; function defineReactive(data, key, value) { observe(value); // 监听子属性 Object.defineProperty(data, key, { get() { return value }, set(newValue) { // 更新视图 if (newValue === value) { return; } value = newValue // 再次编译要放到新值已经变化之后只更新当前的key dep.trigger(key) } }) } // 执行observe,监听每一个 data 的属性 observe(data) // 增加dep对象 用来添加订阅者和通知订阅者 const dep = { map: Object.create(null), // 添加订阅者 collect(dataProp, updateFn) { if (!this.map[dataProp]) { this.map[dataProp] = [] } this.map[dataProp].push(updateFn) }, // 通知订阅者 trigger(dataProp) { console.log('触发了事件', dataProp); this.map[dataProp] && this.map[dataProp].forEach(updateFn => { updateFn() }) } } // 编译函数 function compile() { let app = document.getElementById('app') // 1.拿到app下所有的子元素 const nodes = app.childNodes // [text, input, text] //2.遍历所有的子元素 nodes.forEach(node => { // nodeType为1的是元素节点 if (node.nodeType === 1) { const attrs = node.attributes // 遍历所有的attrubites找到对应的指令 Array.from(attrs).forEach(attr => { const dirName = attr.nodeName const dataProp = attr.nodeValue if (dirName === 'v-text') { node.innerText = data[dataProp] // 给对应的data属性值添加订阅者 dep.collect(dataProp, () => { node.innerText = data[dataProp] }) } if (dirName === 'v-model') { node.value = data[dataProp] // 一些特定的指令(比如v-model)需要给节点绑定事件 node.oninput = function (e) { data[dataProp] = e.target.value } // 给对应的data属性值添加订阅者 dep.collect(dataProp, () => { node.value = data[dataProp] }) } }) } }) } // 首次加载执行compile,以此初始化页面;给节点绑定事件;给data属性值添加订阅者watcher,以便属性值发生改变好修改节点内容 compile() </script>
当 data 属性的值发生改变时,只有订阅了该属性值的 dom 元素的内容会随之发生变化。
上面代码也就吻合了双向绑定原理的执行顺序,如下:
- 首先利用 Object.defineProperty() 创建 Observer,劫持所有属性。
- Compile() 初始渲染页面、为节点绑定函数、添加watcher监听者。Compile 会扫描和解析每个节点的相关指令,在遇到 {{}} 或 v-modle 等节点的时候,首先将模板中的变量替换成数据,根据初始数据渲染页面视图。并且将每个指令对应的节点绑定函数,一旦视图发生交互,绑定的函数就被触发,数据会发生变化。创建 Watcher 订阅者,并将它存入 Dep,当数据发生变化时,改变节点的内容。
由此即实现了双向绑定。当数据发生变化时,set 函数会触发,然后通知 watcher 改变元素内容。当元素节点发生一些交互时,节点绑定的事件会触发,此时会更新数据,set 函数会触发,然后通知 watcher 改变元素内容。
1.3、vue3和vue2的响应式处理的区别
vue2.x 中的 data 配置项,只要放到了data里的数据,不管层级多深不管你最终会不会用到这个数据都会进行递归响应式处理。所以如果非必要,尽量不要添加太多的冗余数据在data中。
vue3.x中,解决了 vue2 中对于数据响应式处理的无端性能消耗,使用的手段是 Proxy 劫持对象整体 + 惰性处理(用到了才进行响应式转换)。
2、浏览器渲染页面过程
(浏览器渲染引擎的渲染流程)
2.1、关键渲染路径
关键渲染路径是指浏览器从最初接收请求来的HTML、CSS、javascript等资源,然后解析、构建树、渲染布局、绘制,最后呈现给客户能看到的界面这整个过程。
所以浏览器的渲染过程主要包括以下几步:
- 解析HTML生成DOM树。
- 解析CSS生成CSSOM规则树。
- 将DOM树与CSSOM规则树合并在一起生成渲染树。
- 遍历渲染树开始布局,计算每个节点的位置大小信息。
- 将渲染树每个节点绘制到屏幕。
3、JS操作真实DOM的代价!
4、虚拟DOM的作用
虚拟DOM就是为了解决浏览器性能问题而被设计出来的。假如像上面所说的,若一次操作中有10次更新DOM的动作,会生成一个新的虚拟DOM,将新的虚拟DOM和旧的进行比较,然后将10次更新的 diff 内容保存到一个JS对象中,最终通过这个JS对象来更新真实DOM,由此只进行了一次操作真实DOM,避免大量无谓的计算量。所以,虚拟DOM的作用是将多个DOM操作合并成一个,并且将DOM操作先全部反映在JS对象中(操作内存中的JS对象比操作DOM的速度要更快),再将最终的JS对象映射成真实的DOM,交由浏览器去绘制。
5、实现虚拟DOM
虚拟DOM就是是用JS对象来代表节点,每次渲染都会生成一个VNode。当数据发生改变时,生成一个新的的VNode,通过 diff 算法和上一次渲染时用的VNode进行对比,生成一个对象记录差异,然后根据该对象来更新真实的DOM。原本要操作的DOM在vue这边还是要操作的,不过是统一计算出所有变化后统一更新一次DOM,进行浏览器DOM的一次性更新。
参考:https://baijiahao.baidu.com/s?id=1593097105869520145&wfr=spider&for=pc、https://www.jianshu.com/p/af0b398602bc
5.1、diff 算法
主流框架中多采用VNode更新结点,更新规则为diff算法。
diff 算法的原理:框架会将所有的结点先转化为虚拟节点Vnode,在发生更改后将VNode和原本页面的OldNode进行对比,然后以VNode为基准,在oldNode上进行准确的修改。(修改准则:原本没有新版有,则增加;原本有新版没有,则删除;都有则进行比较,都为文本结点则替换值;都为静态资源不处理;都为正常结点则替换)
6、Vue 中路由的hash模式和history模式
Vue 中路由有 hash 模式和 history 模式,hash 模式带 # 号,history 没有这个 # 号,就是普通的 url 。可以通过在 router 中配置 mode 选项来切换模式。
Vue 中的路由是怎么实现的可以参考:https://segmentfault.com/a/1190000011967786
Vue 中路由的实现是通过监听 url 的改变,然后通过解析 url ,匹配上对应的组件进行渲染实现的。
在 hash 模式下,跳转路由导致后面 hash 值的变化,但这并不会导致浏览器向服务器发出请求。另外每次 hash 值的变化,还会触发 hashchange
这个事件,通过监听这个事件就能知道 hash 值的改变,并能解析出 url。在 hash 模式下,刷新页面和直接输入链接都不会导致浏览器发出请求。
history 模式的实现原理是通过HTML5中的两个方法:pushState
和 replaceState
,这两个方法可以改变 url 地址且不会发送请求,由此可以跳转路由而不刷新页面,不发出请求。但是在 history 模式下,用户如果直接输入链接或者手动刷新时,浏览器还是会发出请求,而会导致服务器寻找该 url 路径下的对应的文件,而该路径下的文件往往不存在,所以会返回 404。为了避免这种情况,在使用 history 模式时,需要后端进行配合使用,配置在URL 匹配不到任何静态资源时应该返回什么东西,比如可以配置在找不到文件时返回项目的主页面。
参考:https://www.cnblogs.com/xufeimei/p/10745353.html
7、Vue 列表为什么加 key?
1)为了性能优化
key 是为 Vue 中 vnode 的唯一标记,通过这个 key,我们的 diff 操作可以更准确、更快速。
vue是虚拟DOM,更新DOM时用diff算法对节点进行一一比对。比如有很多li元素,要在某个位置插入一个li元素,但没有给li上加key,那么在进行运算的时候,就会将所有li元素重新渲染一遍。但是如果有key,那么它就会按照key一一比对li元素,只需要创建新的li元素,插入即可,不需要对其他元素进行修改和重新渲染。
2)解决就地复用问题
通过 key 可以解决 “就地复用问题”,即下一个元素复用了上一个在当前位置元素的状态。
key也不能是li元素的index,因为假设我们给数组前插入一个新元素,它的下标是0,那么和原来的第一个元素重复了,整个数组的key都发生了改变,这样就跟没有key的情况一样了。