【手把手系列之】实现一个简易版vue2

737 阅读6分钟

说明

本文主要教大家一步一步实现一个简易的vue2,下一篇将会教大家实现vue3

实现的功能点:

  1. 利用snabbdom实现虚拟dom与patch等(vue的虚拟dom也是参考snabbdom的)
  2. 数据双向绑定(包括data, computed, watch)
  3. 实现绑定methods,以改变数据状态
  4. 实现定义组件
  5. 实现jsx,即我们可以写jsx代码来替代前面的写render函数

项目目录结构与vue2源码一致,通过本项目的学习,你也能对vue的具体实现有一个较全面的了解。相信当你去阅读vue源码时会更得心应手。

通过本文的学习,你可以了解

  1. 如何实现一个mvvm
  2. 如何把jsx代码转为虚拟dom,虚拟dom的结构是怎样的
  3. vue是如何实现计算属性,监听器等
  4. Vue组件是如何工作的
  5. 帮你理解vue的源码,并实现一个vue
  6. ……等等

下面我们就手把手实现一个vue2吧

代码已上传到 github.com/aoping/vue2…

大家可以根据commit来看是如何一步一步实现vue2的(注意从下到上)

一、搭建项目

本节目标是用搭建项目的大概结构,我们用parcel打包我们的应用,入口是index.html

项目结构如下:

package.json 这个没什么好解释的

{
  "name": "snabbdom-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.html",
  "scripts": {
    "start": "parcel index.html --open",
    "build": "parcel build index.html"
  },
  "dependencies": {
    "snabbdom": "0.7.3"
  },
  "devDependencies": {
    "@babel/core": "7.2.0",
    "parcel-bundler": "^1.6.1"
  },
  "keywords": []
}

index.html 这个也不解释

<!DOCTYPE html>
<html>

<head>
	<title>Parcel Sandbox</title>
	<meta charset="UTF-8" />
</head>

<body>
	<div id="app"></div>

	<script src="src/index.js">
	</script>
</body>

</html>

index.js

console.log('sss')

现在通过npm start就可以启动项目了

这样我们就完成了第一步

二、snabbdom实现render

在第一步的基础上修改index.js

实现的功能:

  1. 把data代理到Vue实例上,即我们可以通过this.title来访问data里的title
  2. 把title渲染到页面上
  3. 实现监听click事件,打印log
import { h, init } from 'snabbdom'
// init 方法用来创建 patch 函数
// 注意这里要require这些包,才能监听点击事件等
const patch = init([
  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
])

function someFn() {
  console.log("got clicked");
}

// // 两秒之后重渲染
// setTimeout(() => {
//   // 数据变更,产出新的 VNode
//   const nextVnode = MyComponent({ title: 'next' })
//   // 通过对比新旧 VNode,高效的渲染真实 DOM
//   patch(prevVnode, nextVnode)
// }, 2000)



function Vue(options) {
  debugger
  this._init(options)
}

Vue.prototype._s = function (text) {
  return this[text]
}

Vue.prototype._init = function(options){
  this.$options = options
  initData(this)
  this.$mount(this.$options.el)
}

function initData(vm) {
  let data = vm._data = vm.$options.data
  const keys = Object.keys(data)
  let i = keys.length
  while (i--) {
    const key = keys[i]
    proxy(vm, `_data`, key)
  }
}

function noop () {}

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

function proxy (target, sourceKey, key) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}


Vue.prototype.$mount =function (el) {
  const vnode = this.$options.render.call(this)
  debugger
  patch(document.querySelector(el), vnode)
}


var vm = new Vue({
  el: '#app',
  data: {
    title: 'prev',
  },
  render() {
    return h('button', {on: {click: someFn}}, this.title);
  }
})

执行npm start结果如下

三、调整一下目录

这节的目的就是把我们的目录调整的跟vue源码一致,方便我们以后阅读vue源码能一一对应上

修改后的index.js, 是不是跟vue一模一样

import Vue from './src/platforms/web/entry-runtime-with-compiler'

var vm = new Vue({
  el: '#app',
  data: {
    title: 'prev',
  },
  render(h) {
    return h('button', {on: {click: this.$options.methods.someFn}}, this.title);
  },
  methods: {
    someFn() {
      console.log("got clicked");
    }
  }
})

这里就不贴全部的代码了,大家可以reset到chroe: 调整目录这个commit

四、优化:把methods绑定到Vue实例上

这节的目的是把methods绑定到Vue实例上,这样我们就能直接通过this.someFn来访问方法了,而不用像上一节通过this.$options.methods.someFn

改动如下:

五、实现双向绑定

先讲讲整个原理:

观察data的每个属性

observe(data)

observe实现

data的每个key都有一个dep,这个是用来收集依赖,即watcher的(后面会介绍)

这里主要是给key设置了getter、setter,当我们获取key的时候就把watcher加入到dep里,当我们给key赋值时就通知dep执行依赖

Dep.target是用来保存目前是在哪个watcher里的

import Dep from "./dep";

export function observe(data) {
  if (!data || typeof data !== 'object') {
    return;
  }
  for (var key in data) {
    var dep = new Dep()
    let val = data[key]
    observe(val)
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get() {
        console.log('gggg')
        if (Dep.target) {
          dep.addSub(Dep.target)
        }
        return val
      },
      set(newVal) {
        if (val === newVal) return;
        console.log('sss')
        val = newVal
        dep.notify(); // 通知所有订阅者
      },
    })
  }
}

// function Observer(key) {

// }

实现Dep

在上一步我们引入了一个Dep, 这个用来收集依赖,保存在subs这个数组里, 这里是简化版,目的是让大家先对这个原理有个了解

export default function Dep() {
  this.subs = [];
}
Dep.prototype.addSub = function(sub) {
  this.subs.push(sub);
}

Dep.prototype.notify = function() {
  this.subs.forEach(function(sub) {
      sub.update();
  });
}
Dep.target = null


渲染组件

当我们渲染组件的时候,我们会new一个watcher,这个我们称之为渲染watcher,后面还会介绍user watcher等

patch过程大家可以先不看,就是利用snabbdom来实现的,这里我们主要关心new Watcher(vm, updateComponent)

import { h } from 'snabbdom'
import { noop, } from '../util/index'
import Watcher from '../observer/watcher'
import { patch } from 'web/runtime/patch'

export function mountComponent (vm, el) {
  let updateComponent = () => {
    const vnode = vm.$options.render.call(vm, h)
    if (vm._vnode) {
      patch(vm._vnode, vnode)
    } else {
      patch(document.querySelector(el), vnode)
    }
    vm._vnode = vnode

  }

  new Watcher(vm, updateComponent)
}

实现watcher

这个也很简单,需要注意的是new的时候会执行一次

import Dep from './dep'

export default function Watcher(vm, cb) {
  this.cb = cb;
  this.vm = vm;
  // 此处为了触发属性的getter,从而在dep添加自己,结合Observer更易理解
  this.value = this.get(); 
}

Watcher.prototype.get = function() {
  Dep.target = this
  this.cb.call(this.vm)
  Dep.target = null
}

Watcher.prototype.update = function() {
  return this.get(); 
}

到这里我们就已经实现了一个简易的vue2

改一改render

为了看效果,我们稍微改一下render

import Vue from './src/platforms/web/entry-runtime'

var vm = new Vue({
  el: '#app',
  data: {
    title: 'prev',
    num: 1,
    deep: {
      num: 1
    }
  },
  render(h) {
    return h('button', {on: {click: this.someFn}}, this.deep.num);
  },
  methods: {
    someFn() {
      this.deep.num++
    }
  }
})


查看效果

六、实现计算属性

目标:实现计算属性,改变它依赖的data时,计算属性相应的改变

修改new Vue

增加一个计算属性,并渲染它

import Vue from './src/platforms/web/entry-runtime'

var vm = new Vue({
  el: '#app',
  data: {
    title: 'prev',
    num: 1,
    deep: {
      num: 1
    }
  },
  computed: {
    computedNum() {
      return this.num * 10
    }
  },
  render(h) {
    return h('button', {on: {click: this.someFn}}, this.computedNum);
  },
  methods: {
    someFn() {
      this.num++
    }
  }
})

// setTimeout(() => {
//   vm.deep.num++
// }, 3000)

修改core/instance/state.js

主要改动如下:

export function initState (vm) {
  ……
  + if (opts.computed) initComputed(vm, opts.computed)
  ……
}


function initComputed(vm, computed) {
  vm._computedWatchers = Object.create(null) // 用于保存计算watcher

  for (const key in computed) {
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    vm._computedWatchers[key] = new Watcher(vm, getter, computedWatcherOptions)

    defineComputed(vm, key, userDef)
  }
}

function defineComputed(target, key, userDef) {
  Object.defineProperty(target, key, {
    enumerable: true,
    configurable: true,
    get() {
      debugger

      const watcher = this._computedWatchers && this._computedWatchers[key]
      if (watcher) {
        if (watcher.dirty) {
          watcher.evaluate()
        }
        if (Dep.target) {
          watcher.depend()
        }
        return watcher.value
      }
    },
    set: noop,
  })
}

这里也是给key设置getter,并用_computedWatchers保存一个计算watcher,当获取key时就执行这个watcher,并把当前的Dep.target加入到key依赖的data的dep里(这里有点绕,在这个例子中就是当执行render(这时新建了一个渲染watcher)的时候会获取this.computedNum,这个是根据this.num计算出来的,所以就会把渲染watcher加入到num的dep里)

改造Dep

let uid = 0

export default function Dep() {
  this.id = ++uid // uid for batching
  this.subs = [];
  this.subIds = new Set();

}
Dep.prototype.addSub = function(sub) {
  if (!this.subIds.has(sub.id)) {
    this.subs.push(sub);
    this.subIds.add(sub.id);
  }
}

Dep.prototype.depend = function() {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}

Dep.prototype.notify = function() {
  this.subs.forEach(function(sub) {
      sub.update();
  });
}
Dep.target = null
const targetStack = []

export function pushTarget (target) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

这里targetStack是用来保存Dep.target的

改造watcher

import Dep, {pushTarget, popTarget} from './dep'
let uid = 0

export default function Watcher(vm, expOrFn, options) {
  this.id = ++uid // uid for batching
  this.expOrFn = expOrFn;
  this.vm = vm;
  this.deps = []
  this.depIds = new Set();
  if (options) {
    this.lazy = !!options.lazy
  } else {
    this.lazy = false
  }

  this.dirty = this.lazy // 用于渲染时不把计算watcher设置成Dep.target
  // 此处为了触发属性的getter,从而在dep添加自己,结合Observer更易理解
  this.value = this.lazy ? undefined :this.get(); 
}

Watcher.prototype.get = function() {
  let value;
  pushTarget(this)

  // if (this.dirty) Dep.target = this
  value = this.expOrFn.call(this.vm)
  // if (this.dirty) Dep.target = null
  popTarget()
  return value
}

Watcher.prototype.update = function() {
  if (this.lazy) {
    this.dirty = true;
  } else {
    this.get(); 
  }
}

Watcher.prototype.addDep = function(dep) {
  const id = dep.id
  if (!this.depIds.has(id)) {
    this.deps.push(dep)
    this.depIds.add(id)
    dep.addSub(this)
  }
}

Watcher.prototype.evaluate = function() {
  this.value = this.get()
  this.dirty = false
}

Watcher.prototype.depend = function() {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

到这里我们就实现了计算属性

七、实现watch

目的:改变num,watchMsg的值也改变

修改render

import Vue from './src/platforms/web/entry-runtime'

var vm = new Vue({
  el: '#app',
  data: {
    num: 1,
    watchMsg: 'init msg'
  },
  watch: {
    num(newVal, oldVal) {
      this.watchMsg = newVal + ' apples'
    },
  },
  render(h) {
    return h('button', {on: {click: this.someFn}}, this.watchMsg);
  },
  methods: {
    someFn() {
      this.num++
    }
  }
})

初始化watch

function initWatch(vm, watch) {
  debugger
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

function createWatcher (vm, expOrFn, handler, options) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

export function stateMixin(Vue) {
  Vue.prototype.$watch = function (expOrFn, cb, options) {
    const vm = this
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {}
    options.user = true
    const watcher = new Watcher(vm, expOrFn, cb, options)
    // return function unwatchFn () {
    //   watcher.teardown()
    // }
  }
}

这里主要是new了一个Watcher

改造watcher

import Dep, {pushTarget, popTarget} from './dep'
import { parsePath } from '../util/lang'

let uid = 0

export default function Watcher(vm, expOrFn, cb, options) {
  this.id = ++uid // uid for batching
  this.cb = cb
  this.vm = vm;
  this.deps = []
  this.depIds = new Set();
  if (options) {
    this.user = !!options.user // user表示是否是用户定义的watcher,即我们在new Vue({watch:{}})里的watch
    this.lazy = !!options.lazy
  } else {
    this.user = this.lazy = false
  }

  this.dirty = this.lazy // 用于渲染时不把计算watcher设置成Dep.target
  this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn);

  this.value = this.lazy ? undefined :this.get(); 
}

Watcher.prototype.get = function() {
  let value;
  const vm = this.vm
  pushTarget(this)

  value = this.getter.call(vm, vm)
  popTarget()
  return value
}

Watcher.prototype.update = function() {
  if (this.lazy) {
    this.dirty = true;
  } else {
    this.run(); 
  }
}

Watcher.prototype.addDep = function(dep) {
  const id = dep.id
  if (!this.depIds.has(id)) {
    this.deps.push(dep)
    this.depIds.add(id)
    dep.addSub(this)
  }
}

Watcher.prototype.evaluate = function() {
  this.value = this.get()
  this.dirty = false
}

Watcher.prototype.depend = function() {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

Watcher.prototype.run = function() {
  const value = this.get()
  // 变化时才执行
  if (value !== this.value) {
    const oldValue = this.value
    this.value = value
    if (this.user) {
      try {
        this.cb.call(this.vm, value, oldValue)
      } catch (e) {
        console.error(`callback for watcher "${this.expression}"`)
      }
    } else {
      this.cb.call(this.vm, value, oldValue)
    }
  }
}

Watcher.prototype.teardown = function() {
  
}

八、实现组件系统

到目前为止,我们都不能自定义一个组件,那本节的目的就是实现自定义组件

修改render

这里我们自定义了一个button-counter的组件

import Vue from './src/platforms/web/entry-runtime'

Vue.component('button-counter', {
  data: function () {
    return {
      num: 0
    }
  },
  render(h) {
    return h('button', {on: {click: this.someFn}}, this.num);
  },
  methods: {
    someFn() {
      this.num++
    }
  }
})

var vm = new Vue({
  el: '#app',
  data: {
    msg: 'hello'
  },
  render(h) {
    return h('div', {}, [
      this._c('button-counter'),
      h('span', {}, this.msg)
    ]);
  },
})

实现Vue.component

这个api是通过initGlobalAPI(Vue)挂载到Vue上的

实现在core/global-api/assets.js里

import { ASSET_TYPES } from 'shared/constants'
import { isPlainObject, } from '../util/index'

export function initAssetRegisters (Vue) {
  /**
   * Create asset registration methods.
   */
  ASSET_TYPES.forEach(type => {
    Vue[type] = function (id, definition) {
      if (!definition) {
        return this.options[type + 's'][id]
      } else {
        if (type === 'component' && isPlainObject(definition)) {
          definition.name = definition.name || id
          // 这里组件继承Vue
          definition = this.options._base.extend(definition)
        }
        // TODO:暂时先不实现directive
        // if (type === 'directive' && typeof definition === 'function') {
        //   definition = { bind: definition, update: definition }
        // }
        this.options[type + 's'][id] = definition
        return definition
      }
    }
  })
}

之前我们都是直接渲染根元素,这里我们要考虑怎么渲染一个组件

render组件

其实也是调用组件里的render方法

先拿到构造函数,然后调用render就可以了

import { h } from 'snabbdom'

let cachaComp = {}

export function initRender (vm) {
  vm._c = (tag, options) => {
    var Ctor = vm.constructor.options['components'][tag]
    var sub
    // 缓存组件,避免已初始化的组件被重新初始化
    if (cachaComp[tag]) {
      sub = cachaComp[tag]
    } else {
      sub = cachaComp[tag] = new Ctor(Ctor.options)
    }
    return Ctor.options.render.call(sub, h)
    // const vnode = createComponent(Ctor, data, context, children, tag)
    // return vnode
  }
}

function createComponent(Ctor) {

}

export function renderMixin (Vue) {

  Vue.prototype._render = function () {
    const vm = this
    const { render, _parentVnode } = vm.$options
    vm.$vnode = _parentVnode

    let vnode
    vnode = render.call(vm, h)
    vnode.parent = _parentVnode

    return vnode
  }

}

九、实现compiler

目标:我们可以直接写jsx,即可以直接写 <button onClick={this.someFn}>{this.num}</button> 而不用像之前那样写个h('button', {on: {click: this.someFn}}, this.num)

修改render

import Vue, { compiler } from './src/platforms/web/entry-runtime-with-compiler'

Vue.component('button-counter', {
  data: function () {
    return {
      num: 0
    }
  },
  render(h) {
    var button = <button onClick={this.someFn}>{this.num}</button>
    return button
    // return h('button', {on: {click: this.someFn}}, this.num);
  },
  methods: {
    someFn() {
      this.num++
    }
  }
})

var vm = new Vue({
  el: '#app',
  data: {
    msg: 'hello'
  },
  render(h) {
    return (
      <div>
        {this._c('button-counter')}
        <span>{this.msg}</span>
      </div>
    )
    // return h('div', {}, [
    //   this._c('button-counter'),
    //   h('span', {}, this.msg)
    // ]);
  },
})


这里我们要借助@babel/plugin-transform-react-jsx实现jsx语法

配置@babel/plugin-transform-react-jsx

.babelrc

{
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      {
        "pragma": "compiler"
      }
    ]
  ]
}

这里compiler是我们定义的用来处理jsx的函数

实现compiler函数

其实就是返回h('button', {on: {click: this.someFn}}, this.num)

import Vue from './runtime/index'
import { h } from 'snabbdom'

export function compiler(tag, attrs) {
  let props = attrs || {}
  let children = []
  let options = {
    on: {}
  }
  for (const k in props) {
    if (k[0] === 'o' && k[1] === 'n') {
      options.on[k.slice(2).toLocaleLowerCase()] = props[k]
    }
  }

  for (let i = 2; i < arguments.length; i++) {
    let vnode = arguments[i]
    children.push(vnode)
  }
  return h(tag, options, children)
}

export default Vue

就是这么简单!!!

完结