数据驱动 - 学习vue源码系列2
决定跟着黄轶老师的 vue2 源码课程好好学习下vue2
的源码,学习过程中,尽量输出自己的所得,提高学习效率,水平有限,不对的话请指正~
将vue 的源码clone 到本地,切换到分支2.6
。
Introduction
数据驱动
是vue
的核心思想。
数据的变化,JQuery
通过 操作DOM
更新视图,而vue
会自动更新视图,解耦 DOM 和数据。
本节重点,分析 <div id="app">{{message}}</div>
怎么变成<div id="app">hello</div>
。
切记:分析源码,先做主线任务,然后再是支线任务~~~
new Vue 发生了什么
来找找,实现这样的功能,都涉及到了哪些文件。
从入口文件开始找,Vue 在src/core/instance/index.js
定义的:
function Vue(options) { if (!(this instanceof Vue)) { warn("Vue is a constructor and should be called with the `new` keyword"); } // 这里就执行这一个方法 this._init(options); } // 这些都是在原型上挂载相应方法的 initMixin(Vue); stateMixin(Vue); eventsMixin(Vue); lifecycleMixin(Vue); renderMixin(Vue);
这里还有个小细节:书写顺序上,虽然this._init
在initMixin(Vue);....
之前,但实际上,this._init
只有new
的时候才执行,而new
之前,initMixin(Vue);....
已经执行,所以,this._init
能调用所有原型上的方法。
从挂载原型的方法上面,显然可以找到initMixin
是定义_init
方法的,于是找到init.js
:
依次各种方法,但是什么才是实现{{message}}
变成hello
的相关代码呢?
小技巧:增加调试
想要找到这个问题的答案,其实就是建个 demo,然后引入vue
文件,在init
的相关代码打上debugger
, 发现哪个执行完之后,{{message}}
变成hello
了,那就找到了相关的代码了!
我这边偷懒了点,直接在克隆下来的 vue
库里,建个z.html
,引入vue
:
<div id="app"> <div>{{message}}</div> </div> <!-- 这里改成自己的路径 --> <script src="vue/dist/vue.js"></script> <script> new Vue({ el: "#app", data: { message: "hello", }, }); </script>
然后搜下_init
,在这里打上断点,然后再浏览器里运行z.html
Got it!这里可以断定vm.$mount
是让{{message}}
变成hello
的关键方法!
为什么能 this.message
方法已经找到,这里再说另外一个事,为什么data
里面定义的属性,能直接this.xx
访问呢?
在使用一次debugger
,这次仔细看下,this
里面的属性,开始非常少,然后越来越多:
哟西!很快就发现执行initState(vm)
之后,this
上面就有了message
属性,显然这个文件做了相关的功能。
在state.js
里看看:
// initState方法 initData(vm); // initData方法,这里看到在实例上将data挂载在`this._data`属性上 data = vm._data = typeof data === "function" ? getData(data, vm) : data || {}; proxy(vm, `_data`, key); // proxy方法,这里其实做了映射,当访问this.xx的时候,就会访问 this._data.xx Object.defineProperty(target, key, sharedPropertyDefinition);
Vue 实例挂载的实现
挂载的实现,重点就是this.$mount
方法的实现。
寻找特定方法的定义,src
全局搜索下$mount
发现定义$mount
有三处:
- platforms/web/entry-runtime-with-compiler.js
- platforms/web/runtime/index.js
- platforms/weex/runtime/index.js
其实也很好理解,之前说过 vue 的三种平台web
、weex
、服务器
,最后一个肯定不需要,前两者,weex 里不支持模板是字符串或者 el 是字符串的模式,所以weex
里只有一个,而web
里有两种
web/entry-runtime-with-compiler.js
先看看web/entry-runtime-with-compiler.js
- 缓存原有的
$mount
,重写的时候,是在原有的$mount
上增加功能 el
先判断是否传入,然后统一由query
获取dom
- 拿到 dom 之后,先看下是不是
body
或者html
,是的话,警告并终止 - 如果
options
有render函数
直接返回原有的$mount
;没有的话,将template
或者el
转化为render函数
之后才调用原有的$mount
- 重点看下,这里,怎么将
template
或者el
转化为render函数
,其实就是找到DOM
字符串
- 没有
render函数
,就两种情况:有template
或者el
,将最终的DOM
字符串赋值给template
- 优先处理
template
:
template
是一个字符串,只支持以#
开头,当做 id 获取元素,返回元素的innerHTML
,template
是一个元素的话,返回元素的innerHTML
- 其他情况不支持
- 没有
template
,看下el
,获取 el 的outerHTML
,在赋值给template
- 然后将
DOM
字符串用compileToFunctions
处理成render 函数
// 缓存原来的,重写的时候,在原有的基础上增加功能 const mount = Vue.prototype.$mount; // 重写 Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component { // 如果传入el的话,就去拿到el,query返回dom元素 el = el && query(el); /* istanbul ignore if */ // 不能是body,html if (el === document.body || el === document.documentElement) { process.env.NODE_ENV !== "production" && warn( `Do not mount Vue to <html> or <body> - mount to normal elements instead.` ); return this; } // 缓存options const options = this.$options; // resolve template/el and convert to render function if (!options.render) { let template = options.template; if (template) { // template是字符串的话,只支持#开头 if (typeof template === "string") { if (template.charAt(0) === "#") { template = idToTemplate(template); /* istanbul ignore if */ if (process.env.NODE_ENV !== "production" && !template) { warn( `Template element not found or is empty: ${options.template}`, this ); } } } else if (template.nodeType) { // 元素的话 直接返回内容 template = template.innerHTML; } } else if (el) { // 即便有el,还是统一赋值给template template = getOuterHTML(el); } if (template) { // template会统一转化为render函数 const { render, staticRenderFns } = compileToFunctions( template, ); options.render = render; options.staticRenderFns = staticRenderFns; } } return mount.call(this, el, hydrating); }; Vue.compile = compileToFunctions; export default Vue;
小技巧:只有非正式环境,才有 warn
这个小技巧很容易在日常的开发中使用
const isProduction = process.env.NODE_ENV === "production"(!isProduction) && warn("some warn");
小技巧:判断元素是不是 body 或者 html
这个也很容易
const isBodyOrHtml = el === document.body || el === document.documentElement;
小技巧:先处理异常情况
总会先处理异常情况,最后处理相对正常的情况
if(someError){ return ... } return ...
小技巧:el 可以是字符串,也可以是元素的写法
这个一旦涉及到获取元素,就很好使用
function query(el) { if (typeof el === "string") { const selected = document.querySelector(el); if (!selected) { process.env.NODE_ENV !== "production" && warn("Cannot find element: " + el); return document.createElement("div"); } return selected; } return el; }
元素的判断,也可以用xx.nodeType
小技巧:在已有的函数上增加新功能
如果希望在已有的函数上增加新的功能,可以先缓存原有的方法,然后重写,这样的好处是不需要到原有的函数中增删代码,而且不同的条件,可能需要不同的重写,灵活性更高:
let print = function (name) { console.log(name); }; print("hello"); // 想增加新功能的话 let oldPrint = print; print = function (name) { console.log("想另外加功能"); oldPrint.call(this, name); }; print("hello");
$mount:platforms/web/runtime/index.js
上面$mount
改写,改的就是platforms/web/runtime/index.js
定义的$mount
,这里注意runtime
肯定是需要的,所以先实现这个。而compiler
看情况,所以另外的文件实现。
继续看,这里的定义
import { mountComponent } from "core/instance/lifecycle"; // public mount method Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component { el = el && inBrowser ? query(el) : undefined; return mountComponent(this, el, hydrating); };
代码很明了,拿到el
,然后让mountComponent
实现,继续探索core/instance/lifecycle.js
定义了updateComponent
,然后实例化Watcher
,专业名词渲染watcher
import Watcher from "../observer/watcher"; function mountComponent(vm) { updateComponent = () => { vm._update(vm._render(), hydrating); }; // 渲染watcher,updateComponent传到这里来了 new Watcher( vm, updateComponent, noop, {}, true /* isRenderWatcher */ ); return vm; }
渲染watcher
的回调函数中会调用 updateComponent
方法,在此方法中调用 vm._render
方法首先生成虚拟 Node
,最终调用 vm._update
更新 DOM
Watcher
在这里起到两个作用,一个是初始化的时候会执行回调函数,另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数。
render
先重点看下_render
,打印 vm 实例,看到这个方法在原型上面,全局搜下,很快就知道在core/instance/render.js
里:
_render
函数,返回的是vnode
,而vnode
是由$createElement
这个方法返回的
Vue.prototype._render = function () { // ... // render self let vnode; try { // 这里注意,第一个参数是this,先不用管,render的真正参数是` vm.$createElement` vnode = render.call(vm._renderProxy, vm.$createElement); } catch (e) { // ... } // if the returned array contains only a single node, allow it // set parent vnode.parent = _parentVnode; return vnode; };
理解 vnode 和 patch
- vnode 其实就是
虚拟dom
(virtual dom),因为真实的 node(real dom)属性和方法都非常多,频繁操作费性能,而 vnode 有相对精简的属性,其本身很容易映射成真实的 node,当然 vnode 和 node 本质上都是对象。 - patch 就是将 vnode 变成真实的 node,并且插入到文档里(渲染)
看个 demo,直观感受 vnode 和 patch,可以在本地运行:
<body> <div id="app"></div> <script> // vnode本质上就是对象,{"sel":"div","data":{},"text":"Hello World"},等同于描述<div>hello</div> let vnode = new VNode("div", "hello"); console.log(JSON.stringify(vnode)); let app = document.querySelector("#app"); // 真实dom /* patch的功能:将第二个vnode转化为真实dom,并插入到文档中,销毁掉第一个vnode,。 * 这里有个细节,如果首个节点是真实节点,内部会转化为vnode,然后进行操作 */ patch(app, vnode); // patch之后,vnode上面的elm就有了 console.log(vnode); /* 各个函数的定义 */ // VNode的类 function VNode(tag, text, elm) { this.sel = tag; this.text = text; this.elm = elm; } // 创建vnode function createElement(tag, text, elm) { return new VNode(tag, text, elm); } // 真实的dom转化为vnode function elToVNode(el) { return createElement(el.tagName, el.textContent, el); } // patch将第二个vnode转化为真实的dom,然后插入到文档中。第一个节点存在的意义上和第二个节点进行比较,从而精准的进行渲染 function patch(oldVNode, newVNode) { // 第一个节点是真实节点的话,转化为vnode if (oldVNode.nodeType) { oldVNode = elToVNode(oldVNode); } // 将第二个节点转化为真实的dom let node = document.createElement(newVNode.sel); node.textContent = newVNode.text; newVNode.elm = node; // 插入到文档中 oldVNode.elm.parentNode.insertBefore(node, oldVNode.elm); // 销毁掉第一个节点(这里是销毁,但很多时候不是) oldVNode.elm.parentNode.removeChild(oldVNode.elm); return newVNode.elm; } </script> </body>
这样对vnode
和patch
有个概念之后,继续源码~
源码中的createElement,返回的是vnode
很容易找到就在src/core/vdom/create-element.js
。createElement
实际上内部调用_createElement
,其返回一个vnode
。
children
表示当前 VNode 的子节点,它是任意类型的,需要被规范为标准的 VNode 数组
,根据normalizationType
规范的方法也不一样。(normalizationType
主要区分render 函数
是编译生成的还是用户手写的)
import { normalizeChildren, simpleNormalizeChildren } from './helpers/index' const SIMPLE_NORMALIZE = 1 const ALWAYS_NORMALIZE = 2 export function createElement ( context: Component, tag: any, data: any, children: any, normalizationType: any, alwaysNormalize: boolean ): VNode | Array<VNode> { // 当data参数不存在的时候,后面的参数前置 if (Array.isArray(data) || isPrimitive(data)) { normalizationType = children children = data data = undefined } if (isTrue(alwaysNormalize)) { normalizationType = ALWAYS_NORMALIZE } return _createElement(context, tag, data, children, normalizationType) } export function _createElement ( context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number ): VNode | Array<VNode> { // 针对不同的normalizationType,对children做不同的处理,其实就是扁平化children if (normalizationType === ALWAYS_NORMALIZE) { children = normalizeChildren(children) } else if (normalizationType === SIMPLE_NORMALIZE) { children = simpleNormalizeChildren(children) } let vnode, ns if (typeof tag === 'string') { let Ctor ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag) if (config.isReservedTag(tag)) { // platform built-in elements // 是保留标签的话,直接创建vnode,vnode支持字符串和组件类型,但这里暂时只看字符串就好 vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ) } }
children 规范化
src/core/vdom/helpers/normalize-children.js
的两个方法:
simpleNormalizeChildren
这个方法很简单,如果children
是二维的话,拍平,children 始终是[vnode,vnode]
,这里也只考虑到二维的情况normalizeChildren
这个稍微麻烦一点,考虑到多维的情况,需要递归,最后也是拍平
// 1. When the children contains components - because a functional component // may return an Array instead of a single root. In this case, just a simple // normalization is needed - if any child is an Array, we flatten the whole // thing with Array.prototype.concat. It is guaranteed to be only 1-level deep // because functional components already normalize their own children. export function simpleNormalizeChildren(children: any) { for (let i = 0; i < children.length; i++) { if (Array.isArray(children[i])) { return Array.prototype.concat.apply([], children); } } return children; } // 2. When the children contains constructs that always generated nested Arrays, // e.g. <template>, <slot>, v-for, or when the children is provided by user // with hand-written render functions / JSX. In such cases a full normalization // is needed to cater to all possible types of children values. export function normalizeChildren(children: any): ?Array<VNode> { return isPrimitive(children) ? [createTextVNode(children)] : Array.isArray(children) ? normalizeArrayChildren(children) : undefined; } function isTextNode(node): boolean { return isDef(node) && isDef(node.text) && isFalse(node.isComment); } function normalizeArrayChildren( children: any, nestedIndex?: string ): Array<VNode> { const res = []; let i, c, lastIndex, last; for (i = 0; i < children.length; i++) { c = children[i]; if (isUndef(c) || typeof c === "boolean") continue; lastIndex = res.length - 1; last = res[lastIndex]; // nested if (Array.isArray(c)) { if (c.length > 0) { c = normalizeArrayChildren(c, `${nestedIndex || ""}_${i}`); // merge adjacent text nodes 合并 if (isTextNode(c[0]) && isTextNode(last)) { res[lastIndex] = createTextVNode(last.text + (c[0]: any).text); c.shift(); } res.push.apply(res, c); } } } return res; }
小技巧:扁平化二维数组
其实扁平化二维数组,可以利用下concat
:
function flat(arr) { return [].concat(...arr); }
[].concat(1,[4])
就是[1,4]
update其实大部分功能就是patch
找特定的方法,基本都是搜索,后期不再赘述。
_update
在src/core/instance/lifecycle.js
// hydrating服务端才用到,否则就是false Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this; // 缓存之前的dom const prevEl = vm.$el; // 缓存之前的vnode const prevVnode = vm._vnode; const restoreActiveInstance = setActiveInstance(vm); // 新生成的vnode赋值 vm._vnode = vnode; if (!prevVnode) { // initial render 首次渲染走这里 首次的时候 挂载真实的el上 这里的patch和上面的例子相似 根据vnode创建真实的dom,然后插入到文档中,__patch__返回真实的dom vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */); console.log(vm.$el); } };
仔细看下__patch__
,这里需要层层向上找,其实和上面的简单例子的主体逻辑很像了
但这里为什么用createPatchFunction
生成patch
呢?
vue 有三种平台,前面说过了,服务器端不需要渲染 dom,忽略。而 web 和 weex 都需要渲染,但“平台 DOM” 的方法是不同的,并且对 “DOM” 包括的属性模块创建和更新也不尽相同。因此每个平台都有各自的 nodeOps 和 modules,所以存放在各自的平台里。
但是 patch 的主要逻辑是相似的,这样通过 createPatchFunction 把差异化参数提前固化,这样不用每次调用 patch 的时候都传递 nodeOps 和 modules 了,这种函数柯里化的编程技巧也非常值得学习。
/* src/platforms/web/runtime/index.js */ import { patch } from "./patch"; // 浏览器需要,服务器不需要,noop是空函数 Vue.prototype.__patch__ = inBrowser ? patch : noop; /* src/platforms/web/runtime/patch.js */ import { createPatchFunction } from "core/vdom/patch"; // nodeOps是增删改查dom的方法合集,modules是attrs, klass, events, domProps等这种的解析 export const patch: Function = createPatchFunction({ nodeOps, modules }); /* src/core/vdom/patch.js */ export function createPatchFunction(backend) { // ...定义很多和真实dom相关的函数 return function patch(oldVnode, vnode, hydrating, removeOnly) { // 首次渲染,是真实的dom 这边只贴出与其相关的代码 const isRealElement = isDef(oldVnode.nodeType); // replacing existing element // 缓存 旧的dom const oldElm = oldVnode.elm; // 缓存 旧的dom 父元素 const parentElm = nodeOps.parentNode(oldElm); // create new node 根据新的vnode创建dom 并在父元素里旧元素前面插入新的dom createElm( vnode, insertedVnodeQueue, oldElm._leaveCb ? null : parentElm, nodeOps.nextSibling(oldElm) ); // destroy old node if (isDef(parentElm)) { // 移除旧的dom removeVnodes(parentElm, [oldVnode], 0, 0); } // 返回 新的dom return vnode.elm; }; }
debugger 调试_update
<div id="app">{{message}}</div> <!-- 这里换成绝对路径 --> <script src="vue/dist/vue.js"></script> <script> const vm2 = new Vue({ el: "#app", data: { message: "hello", }, render(createElement) { return createElement( "section", { attrs: { id: "box" }, }, this.message ); }, }); </script>
在dist/vue.js
,这里打上断点
Vue.prototype._update = function (vnode, hydrating) { debugger; };
这里务必明确,开始的#app
是真实的 dom,而后面的section#box
是vnode
,这里的参数vnode
是指section#box
,而vm.$el
开始指的是#app
总结
当引入 vue
,首次渲染的过程大概如下:
initMixin(Vue); // ... 引入vue的时候,Vue.prototype已经挂载了各模块属性和方法。 src/core/instance/init.js new Vue({ el: "#app" }); // 用户创建vm实例 this._init(options); // src/core/instance/index.js vm.$mount(vm.$options.el); // _init方法里 src/core/instance/init.js template = getOuterHTML(el); const { render, staticRenderFns } = compileToFunctions(template); options.render = render; mount.call(this, el, hydrating); // $mount方法里 compiler这里重点是 将template或者el的dom字符串都会变成render函数,之后才是调用runtime的$mount方法 src/platforms/web/entry-runtime-with-compiler.js mountComponent(this, el, hydrating); // $mount方法里 runtime里,此时this已经有了render函数了 src/platforms/web/runtime/index.js updateComponent = () => { vm._update(vm._render(), hydrating); }; new Watcher(vm, updateComponent, noop, {}, true /* isRenderWatcher */) // mountComponent方法里 建了一个渲染watcher,执行`vm._update(vm._render(), hydrating)`,_render是返回vnode,_update将这个vnode变成真实dom,然后插入到文档中(渲染),首次渲染,_render其实就是执行参数中的render函数 src/core/instance/lifecycle.js const { render, _parentVnode } = vm.$options vnode = render.call(vm._renderProxy, vm.$createElement) return return vnode // _render函数里 src/core/instance/render.js vm.$el = vm.__patch__(vm.$el, vnode) // _update函数里 __patch__就是创建真实dom和插入到文档中 src/core/instance/lifecycle.js Vue.prototype.__patch__ = inBrowser ? patch : noop // __patch__就是patch方法 src/platforms/web/runtime/index.js export const patch: Function = createPatchFunction({ nodeOps, modules }) // web下的patch由createPatchFunction生成 src/platforms/web/runtime/patch.js function createPatchFunction(backend){ const { modules, nodeOps } = backend function createElm ( vnode, insertedVnodeQueue, parentElm, refElm) { const data = vnode.data const children = vnode.children const tag = vnode.tag nodeOps.createElement(tag, vnode) } return function patch (oldVnode, vnode, hydrating, removeOnly) { const oldElm = oldVnode.elm // 缓存 旧的dom 父元素 const parentElm = nodeOps.parentNode(oldElm) // create new node 根据新的vnode创建dom 并在父元素里旧元素前面插入新的dom createElm( vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm) ) // 移除旧的dom removeVnodes(parentElm, [oldVnode], 0, 0) // 返回 新的dom return vnode.elm } } // 这里的生成patch函数利用不同平台的modules,但patch的逻辑却相似 src/core/vdom/patch.js
这里在借助黄轶老师的图,表示从主线上把模板和数据如何渲染成最终的 DOM 的过程: