从零写一个 Vue(一)主流程实现

402 阅读4分钟

写在前面

vue3 马上要来了,vue2 学会了吗?

最近看到了不少类似标题的文章,虽然 vue 的双向绑定、虚拟dom、diff算法等等面试常见问题你可能在几年前就学过了,不过让从零开始实现一个 vue,你可以吗。

本着学习的最好方法就是自己实现一次的原则,趁着疫情无法返校,计划实现一个尽量完整的 vue,删掉了 flow 和很多的类型判断,只保留各功能的主流程,旨在为直接阅读 vue 源码提供过渡。

毕竟 vue 源码还是比较难啃的,看网上的文章也很难将各个模块联系起来。而跟着我一个功能一个功能的实现则很轻松,学完之后再去看 vue 源码就可以游刃有余啦。

仅供学习交流使用,觉得看文章太慢的可以直接看源码:github.com/buppt/YourV…

本篇文章是从零实现 vue2 系列第一篇,vue 主流程实现,先不要管双向绑定、虚拟dom 等等,后面会一点一点加上来。文章会最先更新在公众号:BUPPT。

正文

我们按照 vue 的方式,实现功能,一个数字和一个按钮,点击按钮数字加一。

// main.js
import YourVue from './instance'
new YourVue({
  el: '#app',
  data: {
      count: 0,
  },
  template: `
      <div>
        <div>{{count}}</div>
        <button @click="addCount">addCount</button>
      </div>
  `,
  methods:{
      addCount(){
          const count = this.count + 1
          this.setState({  // 没有双向绑定,先通过setState更新
              count
          })
      }
  }
})

实现

首先初始化一个 class,这里需要关注的问题有三个

  1. 第一个是如何实现 data 和 methods 中的变量通过 this 直接访问
  2. 第二个如何将 template 模版转换成 dom 元素
  3. 第三个是如何将事件绑定到 dom 元素上面

先上 YourVue 定义。

export default class YourVue{
    constructor(options){
        this._init(options)
    }
    _init(options){
        this.$options = options
        if (options.data) initData(this)
        if (options.methods) initMethod(this)
        if (options.el) {
            this.$mount()
        }
    }
    $mount(){
        this.update()
    }
    update(){
        let el = this.$options.el
        el = el && query(el)
        if(this.$options.template){
            this.el = templateToDom(this.$options.template, this)
            el.innerHTML = ''
            el.appendChild(this.el)
        }
    }
    setState(data){
        Object.keys(data).forEach(key => {
            this[key] = data[key]
        })
        this.update()
    }
}

问题一

​如何实现 data 和 methods 中的变量通过 this 直接访问?

vue 是通过Object.defineProperty修改了 this 的 get 和 set 函数,这样当访问this.count的时候,其实访问的就是this._data.count

function initData(vm){
    let data = vm.$options && vm.$options.data
    vm._data = data
    data = vm._data = typeof data === 'function'
        ? data.call(vm, vm)
        : data || {}
    Object.keys(data).forEach(key => {
        proxy(vm, '_data', key)
    })
}
function proxy (target, sourceKey, key) {
    const sharedPropertyDefinition = {
        enumerable: true,
        configurable: true
    }
    sharedPropertyDefinition.get = function proxyGetter () {
        return this[sourceKey][key]
    }
    sharedPropertyDefinition.set = function proxySetter (val) {
        this[sourceKey][key] = val
    }
    Object.defineProperty(target, key, sharedPropertyDefinition)
}

而 methods 就是直接在 this 对象上创建了一个 key 指向 this.methods 中的函数。

function initMethod(vm){
    const event = vm.$options.methods
    Object.keys(event).forEach(key => {
        vm[key] = event[key].bind(vm)
    })
}

这样就可以通过 this 直接访问到 data 和 methods 中的变量和函数啦,当然这里应该判断 data 和 methods 中的变量是否重复,为了简化代码就省掉了。

问题二

如何将 template 模版转换成 dom 元素?

先解析 template,将 template 解析成语法树,然后再根据 ast 生成 dom树插入到 new 时传入的元素位置。至于是如何从 template 解析成 ast 的,可以看我的上一篇文章,链接:github.com/buppt/Video… ast 就可以了。

{
    type: 1
    tag: "div"
    children: [{…}, {…},
        {
            type: 1 
            tag: "button"
            attrsMap: {@click: "addCount"}
            children: [{type: 3, text: "addCount", parent: button}]
            events: {click: ƒ}
            parent: div
        }
    ]
}

ast 中的 type 分为三种,type 为1表示 dom节点,type 为3表示纯文本节点,type 为2表示带有变量的文本节点。

然后将 ast 转换为 dom 元素并不是 vue 的思路,这里为了实现功能的闭环先这样实现了,后面实现虚拟 dom 之后会改为通过 render 函数生成 vnode,再通过 vnode 生成 dom 的形式。

export function templateToDom(template, app){
    const ast = parse(template, app)
    const root = createDom(ast, app)
    return root
}

function createDom(ast, app){
    if(ast.type === 1){
        const root = document.createElement(ast.tag)
        ast.children.forEach(child => {
            child.parent = root
            createDom(child, app)
        })
        if(ast.parent){
            ast.parent.appendChild(root)
        }
        if(ast.events){
            updateListeners(root, ast.events, {}, app)
        }
        return root
    }else if(ast.type === 3 && ast.text.trim()){
        ast.parent.textContent = ast.text
    }else if(ast.type === 2){
        let res = ''
        ast.tokens.forEach(item => {
            if(typeof item === 'string'){
                res += item
            }else if(typeof item === 'object'){
                res += app[item['@binding']]
            }
        })
        ast.parent.textContent = res
    }
}

问题三

第三个是如何将事件绑定到 dom 元素上面?

上面生成 dom 时候这段代码就是给 dom 绑定事件用的。

if (ast.events) {
    updateListeners(root, ast.events, {}, app)
}

生成的 ast 中会记录这个元素上的事件和事件对应的函数{click: ƒ},但是并不是直接把这个函数添加到事件上,而是包装了一层invoker函数,这样当绑定的函数发生变化的时候,不用重新解绑再绑定。而是每次执行该函数的时候去寻找要执行的函数。

function updateListeners(elm, on, oldOn, context){
    for (let name in on) {
        let cur = context[on[name].value]
        let old = oldOn[name]
        if(isUndef(old)){
            if (isUndef(cur.fns)) {
                cur = on[name] = createFnInvoker(cur)
            }
            elm.addEventListener(name, cur)
        }else if(event !== old){
            old.fns = cur
            on[name] = old
        }
    }
    for (let name in oldOn) {
        if (isUndef(on[name])) {
        elm.removeEventListener(name, oldOn[name])
        }
    }
}

function createFnInvoker(fns){
    function invoker () {
        const fns = invoker.fns
        return fns.apply(null, arguments)
    }
    invoker.fns = fns
    return invoker
}

这篇文章就到这里了,你可能会感觉这都很简单啊,有位大佬说得好“会的不难,难的不会”,希望你每次读完文章都有 so easy 的感觉。

本篇代码:github.com/buppt/YourV… 求 star ~