数据驱动 - 学习vue源码系列2

简介: 数据驱动 - 学习vue源码系列2

数据驱动 - 学习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._initinitMixin(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 的三种平台webweex服务器,最后一个肯定不需要,前两者,weex 里不支持模板是字符串或者 el 是字符串的模式,所以weex里只有一个,而web里有两种

web/entry-runtime-with-compiler.js

先看看web/entry-runtime-with-compiler.js

  • 缓存原有的$mount,重写的时候,是在原有的$mount上增加功能
  • el先判断是否传入,然后统一由query获取dom
  • 拿到 dom 之后,先看下是不是body或者html,是的话,警告并终止
  • 如果optionsrender函数直接返回原有的$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>

这样对vnodepatch有个概念之后,继续源码~

源码中的createElement,返回的是vnode

很容易找到就在src/core/vdom/create-element.jscreateElement实际上内部调用_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

找特定的方法,基本都是搜索,后期不再赘述。

_updatesrc/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#boxvnode,这里的参数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 的过程:

网络异常,图片无法展示
|

引用

目录
相关文章
|
21小时前
|
Web App开发 JavaScript 前端开发
解决Vue.js Devtools未检测到Vue实例的问题
解决Vue.js Devtools未检测到Vue实例的问题
|
2天前
|
Web App开发 编解码 JavaScript
【Vue篇】Vue 项目下载、介绍(详细版)
【Vue篇】Vue 项目下载、介绍(详细版)
10 3
|
2天前
|
JavaScript
vue打印v-model 的值
vue打印v-model 的值
|
2天前
|
移动开发 前端开发 JavaScript
VUE3内置组件Transition的学习使用
VUE3内置组件Transition的学习使用
|
2天前
|
移动开发 JavaScript 前端开发
学习vue3使用在线官方开发环境play.vuejs.org进行测试
学习vue3使用在线官方开发环境play.vuejs.org进行测试
10 1
|
2天前
|
JavaScript
Vue实战-组件通信
Vue实战-组件通信
7 0
|
2天前
|
JavaScript
Vue实战-将通用组件注册为全局组件
Vue实战-将通用组件注册为全局组件
7 0
|
2天前
|
JavaScript 前端开发
vue的论坛管理模块-文章评论02
vue的论坛管理模块-文章评论02
|
2天前
|
JavaScript Java
vue的论坛管理模块-文章查看-01
vue的论坛管理模块-文章查看-01
|
2天前
|
JavaScript
VUE里的find与filter使用与区别
VUE里的find与filter使用与区别
26 0