阅读 232

[Vue源码学习]1-从零跑通Vue实例化过程

目标:通过demo理解Vue的实例化过程,学习Vue的代码组织结构

Vue简介

渐进式 - 便于学习

Vue (读音 /vjuː/,类似于 view) 是一套用于构建用户界面(View层)的渐进式框架 (与React类似)

image

Github star - 用户数量足够多(足够成熟,代码质量有保证)

image

Debug环境搭建

方案一

  1. 切换node版本 (Expected version ">=4 <=9".)
nvm use v7
复制代码
  1. clone vue 源码
git clone --branch v2.5.17 git@github.com:vuejs/vue.git
复制代码
  1. 生成源码打包后的sourcemap
  2. 打开源码package.json
  3. 修改dev命令
  4. 尾部加上 --sourcemap
  5. 运行yarn run dev
  6. dist目录下的sourcemap就有了
  7. 通过引用vue.js进行调试

方案二

  1. 下载分享部分源码 git clone git@github.com:PeterChen1997/vue2-analysis.git
  2. 进入vue-demo文件夹
  3. yarn dev即可断点调试

类型检查

Flow 是 facebook 出品的 JavaScript 静态类型检查工具。Vue.js 的源码利用了 Flow 做了静态类型检查,所以了解 Flow 有助于我们阅读源码

在 Vue.js 的主目录下有 .flowconfig 文件, 它是 Flow 的配置文件,感兴趣的同学可以看官方文档 这其中的 [libs] 部分用来描述包含指定库定义的目录,默认是名为 flow-typed 的目录

// .flowconfig
[libs]
flow
复制代码

这里 [libs] 配置的是 flow,表示指定的库定义都在 flow 文件夹内。我们打开这个目录,会发现文件如下:

flow
├── compiler.js        # 编译相关
├── component.js       # 组件数据结构
├── global-api.js      # Global API 结构
├── modules.js         # 第三方库定义
├── options.js         # 选项相关
├── ssr.js             # 服务端渲染相关
├── vnode.js           # 虚拟 node 相关
├── weex.js            # weex 相关
复制代码

flow主要有几个比较明显的问题:

  1. 编辑器或 IDE 集成度低(与 TypeScript 相比)
  2. 社区力量较弱,因此库的数量较少,而且库的类型定义质量不高 3.Facebook Flow 团队与社区之间缺乏互动,而且没有公共路线图
  3. 高内存消耗和频繁的内存泄漏
    image

React也是使用的flow,但是facebook也发现flow的问题也越来越明显,fb的jest重构也选择了ts,vue3也选择了ts,不知道后续React是否会有所动作

源码结构

我们先只看source目录下的结构

src
├── compiler        # 编译相关 
├── core            # 核心代码 
├── platforms       # 不同平台的支持
├── server          # 服务端渲染
├── sfc             # .vue 文件解析
├── shared          # 共享代码
复制代码

compiler

compiler 目录包含 Vue.js 所有编译相关的代码。它包括把模板解析成 ast 语法树,ast 语法树优化,代码生成等功能

编译的工作可以在构建时做(借助 webpack、vue-loader 等辅助插件);也可以在运行时做,使用包含构建功能的 Vue.js。显然,编译是一项耗性能的工作,所以更推荐前者——离线编译

core

core 目录包含了 Vue.js 的核心代码,包括内置组件(transition / slot等)、全局 API 封装($set),Vue 实例化、观察者、虚拟 DOM、工具函数等等 这里的代码可谓是 Vue.js 的灵魂,也是我们之后需要重点分析的地方

platforms

Vue.js 是一个跨平台的 MVVM 框架,它可以跑在 web 上,也可以配合 weex 跑在 native 客户端上 platform 是 Vue.js 的入口,2 个目录代表 2 个主要入口,分别打包成运行在 web 上和 weex 上的 Vue.js

我们会重点分析 web 入口打包后的 Vue.js,对于 weex 入口打包的 Vue.js,感兴趣的同学可以研究研究,作为补充分享

server

Vue.js 2.x 支持服务端渲染,所有服务端渲染相关的逻辑都在这个目录下 注意:这部分代码是跑在服务端的 Node.js,不要和跑在浏览器端的 Vue.js 混为一谈

服务端渲染主要的工作是把组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后合成为客户端的应用程序

sfc(single file component)

通常我们开发 Vue.js 都会借助 webpack 构建, 然后通过 .vue 单文件来编写组件。 这个目录下的代码逻辑会把 .vue 文件内容解析成一个 JavaScript 的对象

shared

Vue.js 会定义一些工具方法,这里定义的工具方法都是会被浏览器端的 Vue.js 和服务端的 Vue.js 所共享的

这样的目录设计让代码的阅读性和可维护性都变强,是非常值得学习和推敲的

Vue实例化过程

简单介绍了下Vue的相关内容后,现在开始讲讲今天的主题——Vue实例化过程

创建一个Vue实例

由于我们模板编译部分在后面才会讲到,所以我们实例化过程中只使用render函数进行编写,这和我们常见的vue编写方式不太一样

var vm = new Vue({
    el: '.app',
    render: function(createElement) {
        return createElement(
            'div',
            {},
            'Hello World'
        )
    }
})
复制代码

Vue 的设计虽然没有完全遵循 MVVM 模型,但是也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例 (比如ref 属性虽然为父组件操作子组件大开了方便之门,但是它绕开了 ViewModel 来访问 View)

(见DEMO 1)

从结果反向思考

如果只是简单的创建一个Hello World的div,我们用js会怎么实现:

  1. 找到element
  2. 调用函数生成HTML
  3. 替换HTML

最简单的vue

写成代码应该是这个样子

function createElement(type, options, text) {
    // createElement
    const element = document.createElement(type)

    // set content
    element.innerHTML = text

    return element
}

function Vue(options) {
    const { el, render } = options

    // 1. 找到element
    const targetDOM = document.querySelector(el)
    // 2. 调用函数生成Element
    const targetElement = render.call(this, createElement)
    // 3. 替换HTML
    targetDOM.outerHTML = targetElement.outerHTML
}

new Vue({
    el: '.app',
    render: function(createElement) {
        return createElement(
            'div',
            {},
            'Hello World'
        )
    }
})
复制代码

(DEMO 2)

当然,Vue作为一个视图框架,并不是只提供了一个简单渲染div的能力。如果我们希望在上面加一点细节,比如说加上个 vdom ,应该怎么加

加上vdom

vdom简介

Vue.js 中 Virtual DOM 是借鉴了一个开源库 snabbdom 的实现,然后加入了一些 Vue.js 特色的东西,相比于Vue的vdom,它更加简单和纯粹

VNode 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是用来扩展 VNode 的灵活性以及实现一些特殊 feature 的

由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的

下面是snabbdom的官网例子:

var snabbdom = require('snabbdom');var patch = snabbdom.init([ // Init patch function with chosen modules
  require('snabbdom/modules/class').default, // makes it easy to toggle classes
  require('snabbdom/modules/props').default, // for setting properties on DOM elements
  require('snabbdom/modules/style').default, // handles styling on elements with support for animations
  require('snabbdom/modules/eventlisteners').default, // attaches event listeners
]);

var h = require('snabbdom/h').default; // helper function for creating vnodes
var container = document.getElementById('container');
var vnode = h('div#container.two.classes', {on: {click: someFn}}, [
  h('span', {style: {fontWeight: 'bold'}}, 'This is bold'),
  ' and this is just normal text',
  h('a', {props: {href: '/foo'}}, 'I\'ll take you places!')
]);// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

var newVnode = h('div#container.two.classes', {on: {click: anotherEventHandler}}, [
  h('span', {style: {fontWeight: 'normal', fontStyle: 'italic'}}, 'This is now italic type'),
  ' and this is still just normal text',
  h('a', {props: {href: '/bar'}}, 'I\'ll take you places!')
]);// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
复制代码

我们观察后可以发现,sanbbdom的createElement(h)函数执行后,返回的并不是我们想象的一个真实的targetElement,而是一个 vnode 。再通过 patch 函数挂载到真实的DOM元素上

Vue内的Virtual DOM 的实现基本类似:

  • Vue先通过createElement函数生成vnode
  • 再通过_update函数把vnode挂载到真实元素上

我们先看看Vue是怎么生成vnode的

Vue.js 的 createElement 方法,定义在 src/core/vdom/create-elemenet.js 中

// wrapper function for providing a more flexible interface
// without getting yelled at by flowexport
function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }

  // normalizationType 表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考 render 函数是编译生成的还是用户手写的
  return _createElement(context, tag, data, children, normalizationType)
}
复制代码

这里实际调用的是_createElement函数,_createElement 方法有 5 个参数:

  • context 表示 VNode 的上下文环境,它是 Component 类型;
  • tag 表示标签,它可以是一个字符串,也可以是一个 Component;
  • data 表示 VNode 的数据,它是一个 VNodeData 类型,可以在 flow/vnode.js 中找到它的定义
  • children 表示当前 VNode 的子节点,它是任意类型的,它接下来需要被规范为标准的 VNode 数组
  • normalizationType 表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考 render 函数是编译生成的还是用户手写的

createElement 函数的主要做了两个工作,我们主要分析第二个:

  • children 的规范化
  • VNode 的创建

vnode的创建

下列代码摘选自_createElement 方法,定义在 src/core/vdom/create-elemenet.js 中

  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 = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
复制代码
  • 这里先对 tag 做判断
  • 如果是 string 类型
    • 则接着判断如果是内置的一些节点,则直接创建一个普通 VNode
    • 如果是为已注册的组件名,则通过 createComponent 创建一个组件类型的 VNode
    • 否则创建一个未知的标签的 VNode
  • 如果是 tag 一个 Component 类型
  • 则直接调用 createComponent 创建一个组件类型的 VNode 节点

对于 createComponent 创建组件类型的 VNode 的过程,我们在之后的组件化分享部分会详细介绍,这里先直接跳过

vnode的挂载

我们已经知道 Vue 是如何创建了一个 VNode,接下来就是要把这个 VNode 渲染成一个真实的 DOM 并渲染出来,这个过程是通过 _update函数 完成的,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候

我们这一次只分析首次渲染部分,数据更新部分会在之后分析响应式原理的时候涉及接下来分析一下这个过程

_update 方法定义在 src/core/instance/lifecycle.js

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    // ...
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      )
      // no need for the ref nodes after initial patch
      // this prevents keeping a detached DOM tree in memory (#5851)
      vm.$options._parentElm = vm.$options._refElm = null
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    // ...
  }
复制代码

_update 的核心就是调用 vm.patch 方法,这个方法实际上在不同的平台,比如 web 和 weex 上的定义是不一样的,在浏览器非服务端渲染情况下,他会指向到

src/platforms/web/runtime/patch.js

/* @flow */

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })
该方法的定义是调用 createPatchFunction 方法的返回值,这里传入了一个对象,包含 nodeOps 参数和 modules 参数

其中,nodeOps 封装了一系列 DOM 操作的方法,modules 定义了一些模块的钩子函数的实现,我们这里先不详细介绍,来看一下 createPatchFunction 的实现,它定义在 src/core/vdom/patch.js 中
export function createPatchFunction (backend) {
    // ...
    return function patch (oldVnode, vnode, hydrating, removeOnly) {
        // create diff patch
        // ...
    }
}
复制代码

createPatchFunction 内部定义了一系列的辅助方法,最终返回了一个 patch 方法,这个方法就赋值给了 vm._update 函数里调用的 vm.patch

思考一下

在介绍 patch 的方法实现之前,我们可以思考一下

为何 Vue.js 源码绕了这么一大圈,把相关代码分散到各个目录

因为前面介绍过,patch 是平台相关的,在 Web 和 Weex 环境,它们把虚拟 DOM 映射到 “平台 DOM” 的方法是不同的,并且对 “DOM” 包括的属性模块创建和更新也不尽相同。因此每个平台都有各自的 nodeOps 和 modules,它们的代码需要托管在 src/platforms 这个大目录下

而不同平台的 patch 的主要逻辑部分是相同的,所以这部分公共的部分托管在 core 这个大目录下。差异化部分只需要通过参数来区别,这里用到了一个函数柯里化的技巧,通过 createPatchFunction 把差异化参数提前固化,这样不用每次调用 patch 的时候都传递 nodeOps 和 modules 了,这种编程技巧也非常值得学习

在计算机科学中,柯里化(英语:Currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术

在这里,nodeOps 表示对 “平台 DOM” 的一些操作方法,modules 表示平台的一些模块,它们会在整个 patch 过程的不同阶段执行相应的钩子函数,这些代码的具体实现会在之后的分享介绍

回到 patch 方法本身,它接收 4个参数:

  • oldVnode 表示旧的 VNode 节点,它也可以不存在或者是一个 DOM 对象
  • vnode 表示执行 _render 后返回的 VNode 的节点
  • hydrating 表示是否是服务端渲染
  • removeOnly 是给 transition-group 用的

patch 的逻辑看上去相对复杂,因为它有着非常多的分支逻辑,为了方便理解,我们并不会在这里介绍所有的逻辑,仅会针对我们之前的例子分析它的执行逻辑

结合我们的例子,我们的场景是首次渲染,所以在执行 patch 函数的时候,传入的 vm.el 对应的是例子中 id 为 app 的 DOM 对象,这个也就是我们在 index.html 模板中写的 <div class="app">,
// initial render
vm.el = vm.patch(vm.$el, vnode, hydrating, false /* removeOnly */)

在__patch__函数内,调用了createElm。 createElm 的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中,然后还有一些类似遍历生成子节点的过程,这里就不详细介绍了

最后在patch内,将之前的node替换。那么至此我们从主线上把模板和数据如何渲染成最终的 DOM 的过程分析完毕了,那我们可以通过代码实现一遍基本的逻辑

(demo3)

思维导图

生命周期

image

Vue实例化过程的关键步骤

image

源码调试

DEBUG DEMO1

总结

思考点

  • 跨平台代码组织形式
  • 函数科里化减少参数传递
  • ...