🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
[TOC] # Vue 双向绑定原理 参考: [链接1](https://juejin.im/post/5acc17cb51882555745a03f8) [链接2](https://www.cnblogs.com/canfoo/p/6891868.html) [链接3](https://yuchengkai.cn/docs/frontend/framework.html#%E6%95%B0%E6%8D%AE%E5%8A%AB%E6%8C%81) [链接4](https://www.cnblogs.com/kidney/p/6052935.html?utm_source=gold_browser_extension) 首先明确下双向绑定的概念: - 单向绑定指的是 Model(模型)更新时,View(视图)会自动更新 - 如果反过来 View 更新时 Model 的数据也能自动更新,那就是双向绑定 也就是说,我们只要满足上述条件就算实现双向绑定了,那么下面的代码就是最简单的双向绑定: ```html <!DOCTYPE html> <html lang="en"> <head> <title>Document</title> </head> <body> <input type="text" id="a"> <span id="b"></span> <script> const obj = {} Object.defineProperty(obj, 'attr', { set: function (newVal) { document.getElementById('a').value = newVal document.getElementById('b').innerHTML = newVal } }) document.addEventListener('keyup', function (e) { obj.attr = e.target.value }) </script> </body> </html> ``` 我们在输入框输入文字时,JavaScript 代码中的数据会发生变化;在控制台显式地修改 obj.attr 的值,视图也会相应地更新,所以说这是一个极简的双向绑定。 链接 4 还提到了 Object.defineProperty 的几个要点: - 读取或设置访问器属性的值,实际上是调用其内部特性:get 和 set 函数 - get 和 set 方法内部的 this 都指向 obj,这意味着其可以操作对象内部的值 - 访问器属性的会"覆盖"同名的普通属性,因为访问器属性会被优先访问,与其同名的普通属性则会被忽略。 下面再看 vue 是如何双向绑定的,这里不再做具体分析了(可以参考上面的链接)。我用一句话、一张图、一段代码来整理自己的思路: ***** <span style="font-size: 20px; color:#42b383" >一句(比较长)的话</span> vue 的双向绑定采用数据劫持结合发布-订阅模式实现,数据劫持即使用 Object.defineProperty 把传入的 data 选项(一个 JavaScript 对象)的属性转换为 getter / setter,发布-订阅即模板解析过程中,与渲染相关的数据属性会添加相应的 Watcher,该属性的 setter 触发时就会通知对应的 Watcher 更新视图。 <span style="font-size: 20px; color:#42b383" >盗一张图 -.-</span> ![](https://box.kancloud.cn/dee4be024ce54050fa58dd8a55c2c2a5_978x552.png) <span style="font-size: 20px; color:#42b383" >再剽一段代码 -.-</span> 代码来源:[https://github.com/bison1994/two-way-data-binding/blob/master/index.html](https://github.com/bison1994/two-way-data-binding/blob/master/index.html) ```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Two-way-data-binding</title> </head> <body> <div id="app"> <input type="text" v-model="text"> {{ text }} </div> <script> // 这里是劫持效果是 this.xxx -> this.$data.xxx function observe (obj, vm) { Object.keys(obj).forEach(function (key) { defineReactive(vm, key, obj[key]); }) } function defineReactive (obj, key, val) { var dep = new Dep(); Object.defineProperty(obj, key, { get: function () { // 添加订阅者 watcher 到主题对象 Dep if (Dep.target) dep.addSub(Dep.target); return val }, set: function (newVal) { if (newVal === val) return val = newVal; // 作为发布者发出通知 dep.notify(); } }); } // 编译 DOM 结构,用文档片段的形式存储,然后将编译后的 DOM 挂载到绑定的 el 上 function nodeToFragment (node, vm) { var flag = document.createDocumentFragment(); var child; // appendChild 方法有个隐蔽的地方,就是调用以后 child 会从原来 DOM 中移除 // 所以,第二次循环时,node.firstChild 已经不再是之前的第一个子元素了 while (child = node.firstChild) { compile(child, vm); flag.appendChild(child); } return flag } function compile (node, vm) { var reg = /\{\{(.*)\}\}/; // 节点类型为元素 if (node.nodeType === 1) { var attr = node.attributes; // 解析属性 for (var i = 0; i < attr.length; i++) { if (attr[i].nodeName == 'v-model') { // 该特性的名称 var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名 node.addEventListener('input', function (e) { // 给相应的 data 属性赋值,进而触发该属性的 set 方法 vm[name] = e.target.value; }); node.removeAttribute('v-model'); } }; new Watcher(vm, node, name, 'input'); } // 节点类型为 text if (node.nodeType === 3) { if (reg.test(node.nodeValue)) { // 有{{}} var name = RegExp.$1; // 获取匹配到的字符串 name = name.trim(); new Watcher(vm, node, name, 'text'); } } } // nodeType 准确来说应该是事件类型,比如 v-model v-bind 归为一类 function Watcher (vm, node, name, nodeType) { Dep.target = this; // 全局变量 this.name = name; this.node = node; this.vm = vm; this.nodeType = nodeType; this.update(); // 更新视图,即修改相应其监听的 DOM 节点的某个特性值 Dep.target = null; } Watcher.prototype = { update: function () { this.get(); if (this.nodeType == 'text') { this.node.nodeValue = this.value; } if (this.nodeType == 'input') { this.node.value = this.value; } }, // 获取 data 中的属性值 get: function () { this.value = this.vm[this.name]; // 触发相应属性的 get } } function Dep () { this.subs = [] } Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, notify: function() { this.subs.forEach(function(sub) { sub.update(); }); } } function Vue (options) { this.data = options.data; var data = this.data; observe(data, this); var id = options.el; var dom = nodeToFragment(document.getElementById(id), this); // 编译完成后,将 dom 返回到 app 中 document.getElementById(id).appendChild(dom); } var vm = new Vue({ el: 'app', data: { text: 'hello world' } }) </script> </body> </html> ``` # vue 的 nextTick 是如何实现的? 参考链接: [https://mp.weixin.qq.com/s/mCcW4OYj3p3471ghMBylBw](https://mp.weixin.qq.com/s/mCcW4OYj3p3471ghMBylBw) [https://segmentfault.com/a/1190000013314893](https://segmentfault.com/a/1190000013314893) - nextTick 的用途? 该 API 可以在 DOM 更新完毕后执行一个回调,其可以确保我们操作的是更新后的 DOM ```js // 修改数据 vm.msg = 'Hello' // DOM 还没有更新 Vue.nextTick(function () { // DOM 更新了 }) ``` - 如何检测 DOM 的更新并确保回调是在 DOM 更新后执行? 1. vue 用异步队列的方式来控制 DOM 更新和 nextTick 回调先后执行 2. microtask 因为其高优先级特性,能确保队列中的微任务在一次事件循环前被执行完毕 3. 因为兼容性问题,vue 不得不做了 microtask 向 macrotask 的降级方案 来看下面这段代码: ```html <div id="example"> <div ref="test">{{test}}</div> <button @click="handleClick">tet</button> </div> ``` ```js var vm = new Vue({ el: '#example', data: { test: 'begin', }, methods: { handleClick() { this.test = 'end' + this.test; // 这里确保 DOM 更新,你可以试试 this.test = 'end' 会发现第二次点击时会输出 1 promise 2 3 console.log('1') setTimeout(() => { // macroTask console.log('3') }, 0); Promise.resolve().then(function() { //microTask console.log('promise!') }) this.$nextTick(function () { console.log('2') }) } } }) ``` 在 Chrome 下,这段代码会输出 `1、2、promise、3` DOM 更新其实就是生成一个 Watcher 队列,最后会调用我们的 nextTick 函数(具体见链接2的分析) ```js export function nextTick (cb?: Function, ctx?: Object) { let _resolve callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { //通过 pending 来判断是否已经有 timerFunc 这个函数在事件循环的任务队列等待被执行 pending = true timerFunc() // 把回调作为 microTask 或 macroTask 参与到事件循环 } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } } ``` 这里面通过对 `pending` 的判断来检测是否已经有 `timerFunc` 这个函数在事件循环的任务队列等待被执行。如果存在的话,那么是不会再重复执行的。 最后异步执行 `flushCallbacks` 时又会把 `pending` 置为 `false`。 ```js // 执行所有 callbacks function flushCallbacks () { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } ``` 所以回到我们的例子: ```js handleClick() { this.test = 'end'; console.log('1') setTimeout(() => { // macroTask console.log('3') }, 0); Promise.resolve().then(function() { //microTask console.log('promise!') }); this.$nextTick(function () { console.log('2') }); } ``` 代码中,`this.test = 'end'` 必然会触发 `watcher` 进行视图的重新渲染,而我们在文章的 `Watcher` 一节中(链接2)就已经有提到会调用 `nextTick` 函数,一开始 `pending` 变量肯定就是 `false`,因此它会被修改为 `true` 并且执行 `timerFunc`。之后执行 `this.$nextTick` 其实还是调用的 `nextTick` 函数,只不过此时的 `pending` 为 `true` 说明 `timerFunc` 已经被生成,所以 `this.$nextTick(fn)` 只是把传入的 `fn` 置入 `callbacks` 之中。此时的 `callbacks` 有两个 `function` 成员,一个是 `flushSchedulerQueue`,另外一个就是 `this.$nextTick()` 的回调。 因此,上面这段代码中,在 `Chrome` 下,有一个 `macroTask` 和两个 `microTask`。一个`macroTask`就是`setTimeout`,两个`microTask`:分别是`Vue`的`timerFunc`(其中先后执行`flushSchedulerQueue`和`function() {console.log('2')}`)、代码中的`Promise.resolve().then()`。 最后我们贴出 timeFunc 的代码来看看其降级策略: ```js // vue@2.6.10 /src/core/util/next-tick.js // The nextTick behavior leverages the microtask queue, which can be accessed // via either native Promise.then or MutationObserver. // MutationObserver has wider support, however it is seriously bugged in // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It // completely stops working after triggering a few times... so, if native // Promise is available, we will use it: /* istanbul ignore next, $flow-disable-line */ if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() timerFunc = () => { p.then(flushCallbacks) // In problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop) } isUsingMicroTask = true } else if (!isIE && typeof MutationObserver !== 'undefined' && ( isNative(MutationObserver) || // PhantomJS and iOS 7.x MutationObserver.toString() === '[object MutationObserverConstructor]' )) { // Use MutationObserver where native Promise is not available, // e.g. PhantomJS, iOS7, Android 4.4 // (#6466 MutationObserver is unreliable in IE11) let counter = 1 const observer = new MutationObserver(flushCallbacks) const textNode = document.createTextNode(String(counter)) observer.observe(textNode, { characterData: true }) timerFunc = () => { counter = (counter + 1) % 2 textNode.data = String(counter) } isUsingMicroTask = true } else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { // Fallback to setImmediate. // Techinically it leverages the (macro) task queue, // but it is still a better choice than setTimeout. timerFunc = () => { setImmediate(flushCallbacks) } } else { // Fallback to setTimeout. timerFunc = () => { setTimeout(flushCallbacks, 0) } } // 降级策略:Native Promise -> MutationObserver -> setImmediate -> setTimeout // 虽然参考链接中有说用 MessageChannel 但是这里的 Vue 源码中没看到? 2019.7.31 ``` # 聊聊 keep-alive 参考链接:[https://segmentfault.com/a/1190000011978825](https://segmentfault.com/a/1190000011978825) [https://juejin.im/post/5cce49036fb9a031eb58a8f9](https://juejin.im/post/5cce49036fb9a031eb58a8f9) ## keep-alive 内置组件的用途? `<keep-alive>` 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。`<keep-alive>`是一个抽象组件:它自身不会渲染一个 DOM 元素,也不会出现在父组件链中。 当组件在 `<keep-alive>` 内被切换,它的 `activated` 和 `deactivated` 这两个生命周期钩子函数将会被对应执行。 - Props: - `include`\- 字符串或正则表达式。只有名称匹配的组件会被缓存。 - `exclude`\- 字符串或正则表达式。任何名称匹配的组件都不会被缓存。 - `max`\- 数字。最多可以缓存多少组件实例。 应用场景:避免组件的反复重建和渲染,保存用户状态等。 ## 为什么 keep-alive 组件自身不会被渲染? Vue 在初始化生命周期的时候,为组件实例建立父子关系会根据 `abstract` 属性决定是否忽略某个组件。在 keep-alive 中,设置了 `abstract: true`,那 Vue 就会跳过该组件实例。 ## keep-alive 组件包裹的组件是如何使用缓存的? ```js // src/core/vdom/patch.js function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) { let i = vnode.data if (isDef(i)) { const isReactivated = isDef(vnode.componentInstance) && i.keepAlive if (isDef(i = i.hook) && isDef(i = i.init)) { i(vnode, false /* hydrating */) } if (isDef(vnode.componentInstance)) { initComponent(vnode, insertedVnodeQueue) insert(parentElm, vnode.elm, refElm) // 将缓存的DOM(vnode.elm)插入父元素中 if (isTrue(isReactivated)) { reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm) } return true } } } ``` 在首次加载被包裹组件时,由 `keep-alive.js` 中的 `render` 函数可知,`vnode.componentInstance` 的值是 `undefined`,`keepAlive` 的值是 `true`,因为 keep-alive 组件作为父组件,它的 `render` 函数会先于被包裹组件执行;那么就只执行到 `i(vnode, false /* hydrating */)`,后面的逻辑不再执行; ***** 再次访问被包裹组件时,`vnode.componentInstance` 的值就是已经缓存的组件实例,那么会执行 `insert(parentElm, vnode.elm, refElm)` 逻辑,这样就直接把上一次的 DOM 插入到了父元素中。 ## 如何做到避免组件的重复创建? 一般的组件,每一次加载都会有完整的生命周期,即生命周期里面对应的钩子函数都会被触发,为什么被 keep-alive 包裹的组件却不是呢? 因为被缓存的组件实例会为其设置 keepAlive = true,而在初始化组件钩子函数中: ```js // src/core/vdom/create-component.js const componentVNodeHooks = { init (vnode: VNodeWithData, hydrating: boolean): ?boolean { if ( vnode.componentInstance && !vnode.componentInstance._isDestroyed && vnode.data.keepAlive ) { // kept-alive components, treat as a patch const mountedNode: any = vnode // work around flow componentVNodeHooks.prepatch(mountedNode, mountedNode) } else { const child = vnode.componentInstance = createComponentInstanceForVnode( vnode, activeInstance ) child.$mount(hydrating ? vnode.elm : undefined, hydrating) } } // ... } ``` 可以看出,当 vnode.componentInstance 和 keepAlive 同时为 truly 值时,不再进入 $mount 过程,那 mounted 之前的所有钩子函数(beforeCreate、created、mounted)都不再执行。 ## activated 与 deactivated 钩子 在 patch 的阶段,最后会执行 invokeInsertHook 函数,而这个函数就是去调用组件实例(VNode)自身的 insert 钩子: ```js // src/core/vdom/patch.js function invokeInsertHook (vnode, queue, initial) { if (isTrue(initial) && isDef(vnode.parent)) { vnode.parent.data.pendingInsert = queue } else { for (let i = 0; i < queue.length; ++i) { queue[i].data.hook.insert(queue[i]) // 调用 VNode 自身的 insert 钩子函数 } } } ``` 再看`insert`钩子: ```js // src/core/vdom/create-component.js const componentVNodeHooks = { // init() insert (vnode: MountedComponentVNode) { const { context, componentInstance } = vnode if (!componentInstance._isMounted) { componentInstance._isMounted = true callHook(componentInstance, 'mounted') } if (vnode.data.keepAlive) { if (context._isMounted) { queueActivatedComponent(componentInstance) } else { activateChildComponent(componentInstance, true /* direct */) } } // ... } ``` 在这个钩子里面,调用了`activateChildComponent`函数递归地去执行所有子组件的`activated`钩子函数: ```js // src/core/instance/lifecycle.js export function activateChildComponent (vm: Component, direct?: boolean) { if (direct) { vm._directInactive = false if (isInInactiveTree(vm)) { return } } else if (vm._directInactive) { return } if (vm._inactive || vm._inactive === null) { vm._inactive = false for (let i = 0; i < vm.$children.length; i++) { activateChildComponent(vm.$children[i]) } callHook(vm, 'activated') } } ``` 相反地,`deactivated`钩子函数也是一样的原理,在组件实例(VNode)的 `destroy` 钩子函数中调用`deactivateChildComponent`函数。 # vue-router 实现浅析 参考:[https://zhuanlan.zhihu.com/p/27588422](https://zhuanlan.zhihu.com/p/27588422) "更新视图但不重新请求页面" 是前端路由原理的核心之一,目前在浏览器环境中这一功能的实现主要有两种方式: * 利用 URL 中的 hash(“#”) * 利用 History interface 在 HTML5 中新增的方法 ## Hash 模式 `http://www.example.com/index.html#print` \# 符号本身以及它后面的字符称之为 hash,可通过 window.location.hash 属性读取。它具有如下特点: - hash 虽然出现在 URL 中,但不会被包括在 HTTP 请求中。它是用来指导浏览器动作的,对服务器端完全无用,因此,改变 hash 不会重新加载页面 - 可以为 hash 的改变添加监听事件: ```js window.addEventListener("hashchange", funcRef, false) ``` - 每一次改变 hash(window.location.hash),都会在浏览器的访问历史中增加一个记录 路由操作主要就是 push 和 replace,push 是将新的路由添加到浏览器历史记录栈的栈顶,replace 是替换当前栈顶。 函数触发顺序: ```js 1 $router.push() // 调用方法 2 HashHistory.push() // 设置 hash 并添加到浏览器历史记录(添加到栈顶)(window.location.hash= XXX) 3 History.transitionTo() // 监测更新,更新则调用 History.updateRoute() 4 History.updateRoute() // 更新路由 5 {app._route= route} // 替换当前app路由 6 vm.render() // 更新视图 ``` ## History 模式 更改了 API,可以直接操作浏览器历史记录栈 1.push:与 hash 模式类似,只是将 window.hash 改为 history.pushState 2.replace:与 hash 模式类似,只是将 window.replace 改为 history.replaceState 3.监听地址变化:在 HTML5History 的构造函数中监听 popState(window.onpopstate) # vuex 实现浅析 参考:[https://www.jianshu.com/p/d95a7b8afa06](https://www.jianshu.com/p/d95a7b8afa06) vuex 仅仅是作为 vue 的一个插件而存在,不像 Redux,MobX 等库可以应用于所有框架, vuex 只能使用在 vue 上,很大的程度是因为其高度依赖于 vue 的 computed 依赖检测系统以及其插件系统。 每一个 vue 插件都需要有一个公开的 install 方法,vuex 也不例外。其调用了一下 applyMixin 方法,该方法主要作用就是在所有组件的 **beforeCreate** 生命周期注入了设置 **this.$store** 这样一个对象。 ```js // src/mixins.js // 对应applyMixin方法 export default function (Vue) { const version = Number(Vue.version.split('.')[0]) if (version >= 2) { Vue.mixin({ beforeCreate: vuexInit }) } else { const _init = Vue.prototype._init Vue.prototype._init = function (options = {}) { options.init = options.init ? [vuexInit].concat(options.init) : vuexInit _init.call(this, options) } } /** * Vuex init hook, injected into each instances init hooks list. */ function vuexInit () { const options = this.$options // store injection if (options.store) { this.$store = typeof options.store === 'function' ? options.store() : options.store } else if (options.parent && options.parent.$store) { this.$store = options.parent.$store } } } ``` Vuex 的构造函数中有如下一个方法: ```js // src/store.js function resetStoreVM (store, state, hot) { // 省略无关代码 Vue.config.silent = true store._vm = new Vue({ data: { $$state: state }, computed }) } ``` 其本质就是将我们传入的 state 作为一个隐藏的 vue 组件的 data,也就是说,我们的 commit 操作,本质上其实是修改这个组件的 data 值,结合上文的 computed,修改被 **defineReactive** 代理的对象值后,会将其收集到的依赖的 **watcher** 中的 **dirty** 设置为 true,等到下一次访问该 watcher 中的值后重新获取最新值。 这样就能解释了为什么 vuex 中的 state 的对象属性必须提前定义好,如果该 **state** 中途增加**一个属性**,因为该**属性**没有被 **defineReactive**,所以其依赖系统没有检测到,自然不能更新。