整理一些重点:
- 组件化和MVVM
- 响应式原理
- vdom和diff算法
- 模板编译
- 组件渲染过程
- 前端路由
一、组件化基础
在组件化基础上,实现了数据驱动视图(MVVM)
传统组件只是静态渲染,更新还要依赖于操作DOM
而MVVM——不用操作DOM 直接修改数据就能够改变视图
结合代码分析view如何修改model
整理一下自己的理解:
组件化是一个较早之前就有的理念,但传统的组件化,只是做静态渲染,更新依赖于直接操作DOM
而Vue在组件化的基础上,加入了创新——数据驱动视图MVVM
通过MVVM,我们不再需要直接操控DOM元素,而是来操作model(也就是data),通过指令来更新View视图层,相应的,视图层的DOM也可以通过聆听来改变model数据
二、Vue响应式
重点!!!
组件data的数据一旦变化,立刻触发视图的更新,这也是时间数据驱动试图的第一步
核心API-Object.defineProperty
但Vue3.0启用了Proxy来启动响应式了(因为Object.defineProperty存在一些缺点)
基本使用:
Object.defineProperty() 方***直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
其语法:
Object.defineProperty(obj,prop,descriptor)obj——要定义属性的对象 prop——要定义或修改的属性的名称或 Symbol descriptor——要定义或修改的属性描述符
const data={} const name='zhangsan' Object.defineProperty(data, "name", { get: function() { console.log('get!'); return name }, set: function(newVal) { console.log('set!'); name=newVal; } }); //通过以上代码 就可以get 和 set data中的name属性了
基于Object.defineProperty这个函数,写最简单的监听函数:
// 触发更新视图 function updateView() { console.log('视图更新') } // 核心监听函数 function defineReactive(target, key, value) { // 核心 API Object.defineProperty(target, key, { get() { return value }, set(newValue) { if (newValue !== value) { value = newValue // 触发更新视图 updateView() } } }) } // 监听对象属性 function observer(target) { if (typeof target !== 'object' || target === null) { // 如果传进来的不是对象或数组 就直接返回 return target } // 重新定义各个属性(for in 也可以遍历数组) for (let key in target) { defineReactive(target, key, target[key]) } } // 准备数据 const data = { name: 'zhangsan', age: 20, } 监听数据 observer(data) // 测试 data.name = 'lisi'//相当于调用了set函数 触发了更新视图函数updateView() data.age = 21
深度监听:
但上述函数无法深度监听 我们只能监听到key这一层
// 触发更新视图 function updateView() { console.log('视图更新') } // 重新定义属性,监听起来 function defineReactive(target, key, value) { // value很可能是个对象 之前普通监听没有value的判断 所以调用observer 可以进行判断 observer(value) // 核心 API Object.defineProperty(target, key, { get() { return value }, set(newValue) { if (newValue !== value) { // 设置newVal的时候可能设置了一个对象做新值 也需要深度监听 observer(newValue) // 设置新值 // 注意,value一直在闭包中,此处设置完之后,再get时也是会获取最新的值 value = newValue // 触发更新视图 updateView() } } }) } // 监听对象属性 function observer(target) { if (typeof target !== 'object' || target === null) { // 不是对象或数组 return target } // 重新定义各个属性(for in 也可以遍历数组) for (let key in target) { defineReactive(target, key, target[key]) } } // 准备数据 const data = { name: 'zhangsan', age: 20, info: { address: '北京' // 需要深度监听 } } // 监听数据 observer(data) // 测试 data.name = 'lisi' data.age = 21 data.info.address = '上海' // 深度监听说白了,我们做深度监听的时候,一定要保证,每个key 都被Object.defineProperty挂上了value 这样key值发生set时才能被监听到
但有此时还有很多缺点:
- 深度监听递归到底,一次性计算量大,可能会导致卡死
- 新增属性和删除属性时无法监听(Vue.set Vue.delete)
- 无法原生监听数组 需要特殊梳理
监听数组:
// 触发更新视图 function updateView() { console.log('视图更新') } // 重新定义数组原型 const oldArrayProperty = Array.prototype // 创建一个空新对象,原型指向oldArrayProperty,对arrProto扩展新的方法不会影响原型 const arrProto = Object.create(oldArrayProperty); //一次性扩展常用的方法 ['push', 'pop', 'shift', 'unshift', 'splice'].forEach(methodName => { arrProto[methodName] = function () { updateView() // 触发视图更新 oldArrayProperty[methodName].call(this, ...arguments) //去调用真正的数组原型方法 使用真的方法 //等同于 Array.prototype.push.call(this, ...arguments) } }) // 重新定义属性,监听起来 function defineReactive(target, key, value) { // 深度监听 observer(value) // 核心 API Object.defineProperty(target, key, { get() { return value }, set(newValue) { if (newValue !== value) { // 深度监听 observer(newValue) // 设置新值 // 注意,value 一直在闭包中,此处设置完之后,再 get 时也是会获取最新的值 value = newValue // 触发更新视图 updateView() } } }) } // 监听对象属性 function observer(target) { if (typeof target !== 'object' || target === null) { // 不是对象或数组 return target } // 如果是数组 if (Array.isArray(target)) { target.__proto__ = arrProto } // 重新定义各个属性(for in 也可以遍历数组) for (let key in target) { defineReactive(target, key, target[key]) } } // 准备数据 const data = { name: 'zhangsan', age: 20, info: { address: '北京' // 需要深度监听 }, nums: [10, 20, 30] } // 监听数据 observer(data) // 测试 data.nums.push(4) // 监听数组代码很长,我把最核心的改动部分再单独拿出来一下:
// 重新定义数组原型 const oldArrayProperty = Array.prototype // 创建一个空新对象,原型指向oldArrayProperty,对arrProto扩展新的方法不会影响原型 const arrProto = Object.create(oldArrayProperty); //一次性扩展常用的方法 ['push', 'pop', 'shift', 'unshift', 'splice'].forEach(methodName => { arrProto[methodName] = function () { updateView() // 触发视图更新 oldArrayProperty[methodName].call(this, ...arguments) //去调用真正的数组原型方法 使用真的方法 //等同于 Array.prototype.push.call(this, ...arguments) } })重点思路就是这一块:要重新定义一个数组原型,不能直接使用数组原型,会污染全局数据。
三、虚拟DOM(virtual DOM)
VDOM是实现VUE的基础,diff就是VDOM的最核心、最关键的部分。
VDOM就是用JS来模拟DOM结构,因为JS执行速度很快,这样可以计算出最小的变更,来操作DOM
用JS模拟DOM结构
传统JS结构:
用JS模拟DOM:主要就是 tag props children 三大元素
注:class-classname
以上代码通过JS实现了vnode 但这只是一个节点 还不是咱们需要的VDOM
VDOM可以进行新旧vnode的对比,得出最小的更新范围,最后更新DOM
学习VDOM可以先了解snabbdomhttps://github.com/snabbdom/snabbdom:h函数 vnode数据结构 patch函数
vue就是基础snabbdom实现的VDOM 它是一个简介强大的vdom库 非常简单
**diff算法
前文提到了,VDOM可以进行新旧vnode的对比,得出最小的更新范围,最后更新DOM
这个更新过程就是diff算法
diff算法概述
先来了解一下diff算法概述
原本树diff的时间复杂度非常高 达到了O(n^3)
因此,对diff进行了优化,时间复杂度优化到了O(n)
- 只比较同一层级
- 比较tag 若tag不同 就不比较了 直接删掉重建拉到
- tag和key都相同,则认为是相同节点,也不再做深度比较
要记住:diff的作用,就是以代价最小的方式来更新DOM ,所以会尽可能的复用DOM。
vue的VDOM是在snabbdom的基础上实现的,下面就是品味snabbdom的源码,领会这个过程:
h函数:
h函数就是vue中的createElement方法,这个函数作用就是创建虚拟dom,追踪dom变化的 它返回的就是Vnode
说白了 就是生成vnode的函数
接受三个参数:
- tag(标签名)、组件的选项对象、函数(必选);
- 一个对象,标签的属性对应的数据(可选);
- 子级虚拟节点,字符串形式或数组形式,子级虚拟节点也需要使用createElement构建。
h函数返回的也是一个函数
vnode函数返回的也是一个vnode结构的对象
patch函数:
patch就是实现虚拟DOM渲染的这个方法
通过patch可以进行新旧两个vnode的差异对比,但这只是手段,不是目的,它的最终目的还是渲染视图
patch函数有两种用法:
patch(container,vnode) patch(vnode,newVnode)
- 首次渲染,vnode把结构全部塞给container里面去
- 当数据改变时,第一个参数vnode是之前的结构,newVnode是改变后的结构,首先这两者先进行对比,找出不同的地方,只渲染不相同的地方,减少DOM的操作。
看一下源码,发现patch是由.init函数返回的:
去找这个snabbdom.init函数:
发现其返回结果就是一个patch函数:
这个patch函数 也就是我们要重点分析 接下来通过分析源码 领会这个patch函数:
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode { let i: number, elm: Node, parent: Node; const insertedVnodeQueue: VNodeQueue = []; // 执行 pre hook for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i](); // 第一个参数不是 vnode if (!isVnode(oldVnode)) { // 创建一个空的 vnode ,关联到这个 DOM 元素 oldVnode = emptyNodeAt(oldVnode); } // 相同的 vnode(key 和 sel 都相等) 进行patchVnode对比 if (sameVnode(oldVnode, vnode)) { // vnode 对比 patchVnode(oldVnode, vnode, insertedVnodeQueue); // 不同的 vnode ,直接删掉重建 } else { elm = oldVnode.elm!; parent = api.parentNode(elm); // 重建 createElm(vnode, insertedVnodeQueue); if (parent !== null) { api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)); removeVnodes(parent, [oldVnode], 0, 0); } }
那么如何对比呢? 首先进行sameVnode的判断 如果都相等 就进行patchVnode的对比
function sameVnode (vnode1: VNode, vnode2: VNode): boolean { // key 和 sel 都相等 // undefined === undefined // true return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel; }
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { // 执行 prepatch hook 就相当于一个生命周期钩子 const hook = vnode.data?.hook; hook?.prepatch?.(oldVnode, vnode); // 设置新的vnode.elem 因为此时新的vnode是断线风筝 不知道更新哪个element const elm = vnode.elm = oldVnode.elm!; // 旧 children let oldCh = oldVnode.children as VNode[]; // 新 children let ch = vnode.children as VNode[]; if (oldVnode === vnode) return; // hook 相关 先不看 if (vnode.data !== undefined) { for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); vnode.data.hook?.update?.(oldVnode, vnode); } // vnode.text === undefined (意味着一般情况下 vnode.children有值) if (isUndef(vnode.text)) { // 新旧都有children if (isDef(oldCh) && isDef(ch)) { if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue); // 新children有,旧children无 (旧text有) } else if (isDef(ch)) { // 清空 text if (isDef(oldVnode.text)) api.setTextContent(elm, ''); // 添加 children addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); // 旧child有,新child无 } else if (isDef(oldCh)) { // 移除 children removeVnodes(elm, oldCh, 0, oldCh.length - 1); // 旧 text 有 新text无 } else if (isDef(oldVnode.text)) { //清空text api.setTextContent(elm, ''); } // else: vnode.text有值(也就是vnode.children无值) //新旧text值不相等 } else if (oldVnode.text !== vnode.text) { // 移除旧 children if (isDef(oldCh)) { removeVnodes(elm, oldCh, 0, oldCh.length - 1); } // 设置新 text api.setTextContent(elm, vnode.text!); } hook?.postpatch?.(oldVnode, vnode); }其中重点 就是新旧都是children时 进行updateChildren
指针向中间移动,当两个指针重合的时候,循环结束
那么如何对比呢?
// 开始和开始对比 } else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue); oldStartVnode = oldCh[++oldStartIdx]; newStartVnode = newCh[++newStartIdx]; // 结束和结束对比 } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue); oldEndVnode = oldCh[--oldEndIdx]; newEndVnode = newCh[--newEndIdx]; // 开始和结束对比 } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue); api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!)); oldStartVnode = oldCh[++oldStartIdx]; newEndVnode = newCh[--newEndIdx]; // 结束和开始对比 } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue); api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!); oldEndVnode = oldCh[--oldEndIdx]; newStartVnode = newCh[++newStartIdx];如果sameVnode 就命中啦!! (这种对比方式不要较真 因为这是细节问题 可能不一样)
如果以上都没命中
// 以上四个都未命中 } else { if (oldKeyToIdx === undefined) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); } // 拿当前新节点的key 看它能否对应上oldCh中的某个节点的key idxInOld = oldKeyToIdx[newStartVnode.key as string]; // 没对应上 就新建 插入 if (isUndef(idxInOld)) { // api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!); newStartVnode = newCh[++newStartIdx]; // 对应上了 } else { // 拿到oldCh中对应上 key 的节点 elmToMove = oldCh[idxInOld]; // 判断sel 是否相等(sameVnode 的条件)sel不相等 就新建 if (elmToMove.sel !== newStartVnode.sel) { // New element api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!); // sel 相等,key 相等 } else { patchVnode(elmToMove, newStartVnode, insertedVnodeQueue); oldCh[idxInOld] = undefined as any; api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!); } newStartVnode = newCh[++newStartIdx]; } }
如果使用index作为key的话,当children有顺序上的变化时,就不好判断了
一个小结:
判断是否为sameVnode | 就是看key和tag是否相同 相同的话 进行patchVnode 进一步比较 不同的话 直接替换成新的 没有比较必要 |
patchVnode | 只有文本不一样 修改文本 旧的有子节点 新的没有子节点 删除子节点 removeVnodes 旧的没有子节点 新的有子节点 新增子节点 addVnodes 新的旧的都有子节点 且子节点不相同 进行updateChildren |
updateChildren | 新旧两组子节点 首尾都置指针 比较过程参考上面复制的那篇文章 |
三、模板编译
模板是vue中最常用的部分,看起来很像html 但又不是html 浏览器无法识别 他有指令有插值有JS表达式 还能做循环和片段
html就是一个标签语言 只有JS才能够实现这些逻辑
因此,模板一定是转成了某些JS代码
先要学习JS的with语法
(并不常用,但是模板编译需要用到)
也就是改变{ }内自由变量的查找规则 当作obj的属性来查找 查不到匹配的obj属性就会报错 他打破了作用域的规则
在with的基础上 我们来学习 vue模板究竟被编译成了什么?
模板编译
with(this)就相当于h函数,返回了一个vnode
this就是vue实例
当只有一个插值时:
// 插值 const template = `<p>{{message}}</p>` // 输出结果:with(this){return _c('p',[_v(_s(message))])} //相当于 with(this){return createElement('p',[createTextVNode(toString(message))])}当表达式时:
/ 表达式 const template = `<p>{{flag ? message : 'no message found'}}</p>` // // with(this){return _c('p',[_v(_s(flag ? message : 'no message found'))])}属性和动态属性时
// 属性和动态属性 const template = ` <div id="div1" class="container"> <img :src="imgUrl"/> </div> ` // with(this){return _c('div', // {staticClass:"container",attrs:{"id":"div1"}}, // [ // _c('img',{attrs:{"src":imgUrl}})])}
条件 把条件判断写成了一个三元表达式
const template = ` <div> <p v-if="flag === 'a'">A</p> <p v-else>B</p> </div>/ ` // with(this){return _c('div',[(flag === 'a')?_c('p',[_v("A")]):_c('p',[_v("B")])])}
剩下的累了 直接一起看吧
// 循环 const template = ` <ul> <li v-for="item in list" :key="item.id">{{item.title}}</li> </ul> ` // with(this){return _c('ul',_l((list), //function(item){return _c('li',{key:item.id},[_v(_s(item.title))])}),0)} // 事件 const template = ` <button @click="clickHandler">submit</button> ` // with(this){return _c('button',{on:{"click":clickHandler}},[_v("submit")])} // v-model const template = `<input type="text" v-model="name">` // 主要看 input 事件 //with(this){return _c('input', //{directives:[{name:"model",rawName:"v-model",value:(name),expression:"name"}], //attrs:{"type":"text"},domProps:{"value":(name)}, //on:{"input":function($event){if($event.target.composing)return;name=$event.target.value}}})} //我们在渲染时 已经绑定了一个input事件 // render 函数 // 返回 vnode // patch // 编译 const res = compiler.compile(template) console.log(res.render) // ---------------分割线-------------- // // 从 vue 源码中找到缩写函数的含义 // function installRenderHelpers (target) { // target._o = markOnce; // target._n = toNumber; // target._s = toString; // target._l = renderList; // target._t = renderSlot; // target._q = looseEqual; // target._i = looseIndexOf; // target._m = renderStatic; // target._f = resolveFilter; // target._k = checkKeyCodes; // target._b = bindObjectProps; // target._v = createTextVNode; // target._e = createEmptyVNode; // target._u = resolveScopedSlots; // target._g = bindObjectListeners; // target._d = bindDynamicKeys; // target._p = prependModifier; // }以上那些生成的类似vnode函数,都被叫做render函数 会返回一个vnode
模板编译过程说白了就
- 将模板编译为render函数,执行render函数返回vnode
- 在生成vnode后,再执行patch和diff
- 实际项目中,使用webpack vue-loader 会在开发环境下编译模板
在vue组件中,可以使用render代替template
很显然 template更好
但React一直都用的render
四、渲染更新
最后一部分来了!!!!
分为三个部分
- 初次渲染过程
- 更新过程
- 异步渲染
初次渲染过程
- 解析模板为render函数(或在开发环境中已经完成 vue-loader)
- 触发响应式 监听data属性 getter setter
- 执行render函数 生成vnode 执行patch(elem,vnode)
第二步看起来没有用,但其实不然,在执行render函数 就会触发getter
更新过程
- 修改data 触发stter 但要看一下这个触发了setter的函数是否在之前的getter中已经监听 (说白了 就是看这个修改了的data模板有没有用到,和视图有没有关系,如果是像上图代码中的city 修改了也没事 它和视图无关啊!不用重新渲染)
- 重新执行render 生成newVnode
- patch(vnode,newVnode)
*异步渲染
非常非常重要!和$nextTick结合着看
异步渲染会汇总data的修改,一次性更新视图,从而减少DOM操作次数,提高性能
小结:
VUE高级原理最重要的就是响应式、模板编译、VDOM、渲染这四大块。他们是息息相关的
要牢牢记住:
- 渲染和响应式的关系
- 渲染和模板编译的关系
- 渲染和VDOM的关系
五、路由
路由原理
回顾vue-router的路由模式
hash
H5 history
首先分析一下网页url组成部分
hash方法
- hash变化会触发网页跳转 即浏览器的前进后退
- hash变化不会刷新页面 这也是SPA必须的特点
- hash永远不会提交到server端 在前端自生自灭
- JS 修改 url
- 手动修改 url 的 hash
- 浏览器前进、后退
代码演示:
通过JS设置hash
通过window.onhashchange这个API监听hash变化
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>hash test</title> </head> <body> <p>hash test</p> <button id="btn1">修改 hash</button> <script> // hash 变化,包括: // a. JS 修改 url // b. 手动修改 url 的 hash // c. 浏览器前进、后退 window.onhashchange = (event) => { console.log('old url', event.oldURL) console.log('new url', event.newURL) console.log('hash:', location.hash) } // 页面初次加载,获取 hash document.addEventListener('DOMContentLoaded', () => { console.log('hash:', location.hash) }) // JS 修改 url document.getElementById('btn1').addEventListener('click', () => { location.href = '#/user' }) </script> </body> </html>
H5 history
- 用url规范的路由,但是跳转时不刷新页面(与hasn不一样)
- 通过history.pushState window.onpopstate实现
- 需要server端的配合
正常页面浏览时:
H5 history
代码演示:
重点是切换路由的API
history.pushState(state, '', 'page1')
以及 监听路由变化的API
window.onpopstate = (event) => { // 重要!! console.log('onpopstate', event.state, location.pathname) }整体代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>history API test</title> </head> <body> <p>history API test</p> <button id="btn1">修改 url</button> <script> // 页面初次加载,获取 path document.addEventListener('DOMContentLoaded', () => { console.log('load', location.pathname) }) // 打开一个新的路由 // 【注意】用 pushState 方式,浏览器不会刷新页面 document.getElementById('btn1').addEventListener('click', () => { const state = { name: 'page1' } console.log('切换路由到', 'page1') history.pushState(state, '', 'page1') // 重要!! }) // 监听浏览器前进、后退 window.onpopstate = (event) => { // 重要!! console.log('onpopstate', event.state, location.pathname) } // 需要 server 端配合,可参考 // https://router.vuejs.org/zh/guide/essentials/history-mode.html#%E5%90%8E%E7%AB%AF%E9%85%8D%E7%BD%AE%E4%BE%8B%E5%AD%90 </script> </body> </html>
需要后端的配合 不管访问什么路由 都返回一个index.html的路由 再从这个index.html页面,通过history.pushState跳转到需要的路由
选择:
to B(后台)的系统推荐用hash 简单易用
to C的系统,可以考虑H5 history
还是hash更简单