手写 vue 源码

106 阅读7分钟

一、rollup的环境搭建

是一个js模块打包工具,一般项目的类库打包全部采用rollup,因为打包体积会比webpack小

  1. 首先一个空文件夹,初始化生成packagejson文件

npm init -y

  1. 安装 roullup,用来打包js库

npm i roullup

  1. 需要使用高级语法还要安装 babel,@babel/core是babel默认插件,@babel/preset-env高版本语法转低版本

npm i rollup-plugin-babel @babel/core @babel/preset-env

  1. 创建文件rollup.config.js,执行打包命令时会找这个文件,默认找这个文件
  1. package.json->scripts下写脚本

如:"dev":"rollup -cw" -c为使用第四步的配置文件 w为监控文件变化

  1. 创建入口文件src->index.js
  1. 指定打包入口为步骤6的路径 rollup.config.js
//babel插件
import babel from 'rollup-plugin-babel'
// rollup默认可以导出一个对象,作为打包的配置文件
export default {
    //入口
    input:'./src/index.js',
    //出口
    output:{
        file:'./dist/vue.js',
        //在全局上增加Vue,在global上添加属性叫Vue,在页面上能new Vue
        name:'Vue',
        //打包格式,常见的有esm es6模块 commonjs模块 iife 自执行函数 umd(统一模块规范,兼容amd,commonjs等)
        format:'umd',
        //可以调试源代码
        sourcemap:true
    },
    //插件
    plugins:[
        //babel插件
        //一般用babel都会创建一个配置文件
        babel({
            //排除不需要打包的
            exclude:'node_modules/**'
        })
    ]
}
  1. babel配置文件位于根目录下,可以是js的或者rc的 如rc:.babelrc
{
    "presets":[
        "@babel/preset-env"
    ]
}
  1. 打包测试 npm run dev 步骤6 配置的

A0PREE(Z80DV%AWFXMD8@LT.jpg

GPEE8FQ`32XBEJTQIV63AJ0.png

二、初始化数据

完成上面的步骤后就希望把data做一个代理,因此需要一个Vue的类因为new了它

vue的源码里并没有用class Vue{}的方式创建类,因为这样写的话所有的方法会耦合在一起

因此使用的是构造函数 function Vue(){}

// src/index.js中
function Vue(options){ // options就是用户的选项

}
export default Vue

那么用原型方式去写代码的话,会将代码封装在原型上Vue.prototype.***

function Vue(options){ 
    this._init(options)
}
Vue.prototype._init = function(options){
    //用于数据初始化
}
export default Vue

但是功能一多,还是会不停的加,那就耦合了,我们肯定希望每个功能是独立的,独立成一个文件

1、初始化文件

新建一个文件 如:init.js(做初始化的)

那么上面的这句就不能复制过来,因为文件不同了就没有Vue

Vue.prototype._init = function(options){ //用于数据初始化 }

所以,可以用函数将Vue传过来

export function initMixin(Vue){ // 给Vue增加init方法
    Vue.prototype._init = function(options){
        //用于数据初始化
    }
}

然后在入口文件index.js中直接导入调用、传入Vue即可initMixin(Vue)

import { initMixin } from "./init"
function Vue(options){ 
    this._init(options)
}
initMixin(Vue)// 扩展了init方法
export default Vue

2、扩展其他方法

  1. 那么在初始化文件中,想扩展其他方法
export function initMixin(Vue){ 
    Vue.prototype._init = function(options){
        // 2.那么就要考虑将options放在实例上
        // this.$options = options 为什么要加$,表示是Vue里面的变量
        const vm = this
        vm.$options = options // 3.将用户的选项挂载到实例上
    }
     Vue.prototype.XXX = function(){
        // 1.是拿不到_init的options
    }
}
  1. 然后我们主要做的就是要对数据进行处理
export function initMixin(Vue){ 
    Vue.prototype._init = function(options){
        const vm = this
        vm.$options = options 
        // 初始化状态
        initState(vm)
    }
    
}
function initState(vm){
  
   const opts = vm.$options // 1.获取所有选项
   // 1.1 先做Data,如果有Data属性就做初始化,继续传vm
   if(opts.data){
       initData(vm)
   }
}
function initData(vm){
   // 2. 数据代理
   let data = vm.$options.data // 在vue2中data可能是函数或者对象 ,vue3中data就是函数
   // 2.1 判断data的类型
  data = typeof data === 'function' ? data.call(vm) : data
}

当然initState()、initData()也可以提取出来

三、实现对象的响应式原理、对象属性劫持

完成上面的代码后怎么去做数据劫持呢?在initData()中去实现

function initData(vm){
  let data = vm.$options.data
  data = typeof data === 'function' ? data.call(vm) : data
  // 对数据进行劫持vue2 里采用了一个API defineProperty
  observe(data)
}

这个函数当然也能放在其他文件,做好导入导出即可

class Observer{
    constructor(data){
        // 2.1 Object.defineProperty只能劫持已经存在的属性,后增的或删除的是不知道的(vue2里面会为此单独写一些api)
        this.walk(data)
    }
    walk(data){
        // 循环对象,对属性依次劫持
        // 重新定义属性,定义为响应式对象,因此性能差
        Object.keys(data).forEach(key=>defineReactive(data,key,data[key]))
    }
}
// 属性劫持
export function defineReactive(target,key,value){ // 闭包
    Object.defineProperty(target,key,{
        get(){ // 取值时,会执行get
            return value
        },
        set(newValue){ // 修改时,会执行set
            if(newValue === value) return
            value = newValue
        }
    })
}
export function observe(data){
    // 对这个对象进行劫持
    // 1.判断数据的类型
    if(typeof data!== 'object' || null){
        return // 只对 对象进行劫持
    }
    // 2.如果一个对象被劫持过,那就不需要再被劫持了(要判断一个对象是否劫持过,可以增添一个实例,用实例判断是否被劫持过)
    return new Observer(data)
}

此时在页面中打印vm的时候,并没有刚才劫持过的属性

78XMT8HR~ZRSP_FMQL{PK5O.png

({36L93%4N3$00Q3~%D35.png

问题在于:

function initData(vm){
  let data = vm.$options.data
  data = typeof data === 'function' ? data.call(vm) : data
  vm._data = data//2.我们就考虑在vm上增加一个_data,相当于将对象放在了实例上,并且把对象用下面的函数进行了观测
  observe(data)// 1.在一步的时候,只是将data传进去了
}

78XMT8HR~ZRSP_FMQL{PK5O.png

当在取值的时候要:vm._data.name,那怎么使用 vm.name来取值呢?

function initData(vm){
  let data = vm.$options.data
  data = typeof data === 'function' ? data.call(vm) : data
  vm._data = data
  observe(data)
  // 将 vm._data 用vm来代理就可以了 
  for(let key in data){
      proxy(vm,'_data',key)//自己写的proxy函数
  }
  
}
function proxy(vm,target,key){
    Object.defineProperty(vm,key,{
        get(){
            return vm[target][key]
        },
        set(newValue){
            vm[target][key] = newValue
        }
    })
}

但此时还不能监控复杂类型,如何实现呢?在属性劫持这一步递归调用observe()

// 属性劫持
export function defineReactive(target,key,value){
    observe(value)// 对所有对象进行属性劫持
    Object.defineProperty(target,key,{
        get(){ 
            return value
        },
        set(newValue){
            if(newValue === value) return
            observe(value)//防止修改复杂类型时,传入对象 如:vm.address={num:20}
            value = newValue
            
        }
    })
}

四、实现数组的函数劫持

数组的话,运用上面的数据劫持,会将数组里面的每个值都加上get()、set()

虽然去改数组的值也能触发值的更新,但一旦数组有成千上万个值,那这样此代码在内部循环时就会走多次

而且用户也很少使用 比如:vm.hobby[888] = 100 去更改,很少用索引去操作数组,但内部做劫持会浪费属性,浪费性能

用户一般修改数组都是通过方法修改,如: push shift.....

所以就考虑如果是数组就不要做循环了,性能太差

将前文代码进行如下更改

import { newArrayProto } from './array'
class Observer{
    constructor(data)
        Object.defineProperty(data,'__ob__',{
            value:this,
            enumerable:false // 将__ob__变为不可枚举(循环的时候无法获取)
        })
       // data.__ob__ = this // 好处:1.为下面新文件内容做铺垫,2.给数据加了一个标识,如果数据上有__ob__则说明这个属性被观测过了,缺点:会造成死循环,因此才有了上面不可枚举的代码
        // 判断是不是数组
        if(Array.isArray(data)){
            // 这里重写数组中的方法 7个变异方法,这些方法是可以修改数组本身的
            data.__proto__= newArrayProto // 需要保留数组原有的特性,并可以重写部分方法
            this.observeArray(data)// 数组中放的是对象,监控对象的变化
        }else{
            this.walk(data)
        }
        
    }
    walk(data){
        Object.keys(data).forEach(key=>defineReactive(data,key,data[key]))
    }
    // 观测数组
    observeArray(data){
        data.forEach(item=>observe(item))
    }
}
export function observe(data){
    if(typeof data!== 'object' || null){
        return 
    }
    if(data.__ob__ instanceof Observer){
        // 说明这个对象被代理过了
        return data.__ob__
    }
    return new Observer(data)
}

新文件内容

let oldArrayProto = Array.prototype // 获取数组的原型

// newArrayProto可以通过链拿到oldArrayProto(newArrayProto.__proto__ = oldArrayProto),不用担心会被覆盖掉
export let newArrayProto = Object.create(oldArrayProto)
let methods = [
    // 找到所有的变异方法
    'push',
    'pop',
    'shift',
    'unshift',
    'reverse',
    'sort',
    'splice'
]
methods.forEath(method=>{
    newArrayProto[method] = function(...args){ // 这里重写了数组的方法
       const result = oldArrayProto[method].call(this,...args) // 内部调用原来的方法,函数的劫持,切片编程
       // 我们还需要对新增的对象进行劫持
       let inserted
       let ob = this.__ob__
       switch(method){
           case 'push':
           case 'unshift':
               inserted = args
               break
           case 'splice':
               inserted = args.slice(2)
           default:
               break
       }
       if(inserted){
           // 对新增的内容再次进行观测
           ob.observeArray(inserted)
       }
       return result
    }
})

五、解析模板参数

image.png

如果想去在页面上取值就可以用插值表达式 如:{{ name }} 所以需要对模板进行编译

image.png

那么首先想到的就是要将 模板里面的数据进行替换

方式:

  1. 模板引擎(性能差,需要正则比配替换,vue 1.0 的时候没有引入虚拟DOM的概念)
  2. 采用虚拟DOM(数据变化后比较虚拟DOM的差异,最后更新需要更新的地方)
  3. 核心就是我们需要将模板变成我们的JS语法,通过JS语法生成虚拟DOM

所以从一个东西变成另一个东西,语法之间的转换,涉及到我们需要先变成语法树,再重新组装代码,成为新的语法 将template语法转成render函数

1

装态初始化完了,要看用户传的值中有没有el属性

// init.js
import { initState } from "./state"

export function initMixin(Vue){
  Vue.prototype.__init = function (options){
    const vm = this
    vm.$options = options
    initState(vm)

    if(options.el){
      vm.$mount(options.el)// 实现数据的挂载
    }
  }
  
  Vue.prototype.$mount = function(el){

  }
}

当然也可以不用el,走的是一样的逻辑

image.png

然后会去判断当前用户有没有写template或者render,没有写的话就用#app

  Vue.prototype.$mount = function (el) {
    const vm = this
    el = document.querySelector(el)
    let ops = vm.$options
    if (!ops.render) {// 先找有没有render函数
      let template // 没有render看一下是否写了template,没有就采用外部的#app
      if (!ops.template && el) {
        //  没有模板但是有el,就用#app
        template = el.outerHTML

      } else {
        if (el) {
          template = ops.template // 如果有el,则采用模板的内容
        }
      }
      // 写了就用写了的template
      if(template){
        // 这里需要对模板进行编译
        const render = compileToFunction(template)
        ops.render = render
      }
    }
    // 最终就可以获取render方法
    ops.render
  }

2、

创建src->compiler->index.js

内容为:

// 对模板进行编译处理
export function compileToFunction(template){
  // 1.就是将template  转成ast语法树
  // 2.生成render方法(返回的结果就是虚拟DOM)
}