手写Vue2源码

手写Vue2

使用rollup搭建开发环境

使用rollup打包第三方库会比webpack更轻量,速度更快

首先安装依赖

npm init -y

npm install rollup rollup-plugin-babel @babel/core @babel/preset-env --save-dev

然后添加 rollup 的配置文件 rollup.config.js

import babel from "rollup-plugin-babel"

export default {
    input:"./src/index.js", // 配置入口文件
    output:{
        file:"./desc/vue.js", // 配置打包文件存放位置以及打包后生成的文件名
        name:"Vue",// 全局挂载一个Vue变量
        format:"umd", // 兼容esm es6模块
        sourcemap:true, // 可以调试源代码
    },
    plugins:[
        babel({
            exclude:"node_modules/**", // 排除node_modules文件夹下的所有文件
        })
    ]
}

添加 babel 的配置文件 .babelrc

{
  "presets": [
    "@babel/preset-env"
  ]
}

修改 package.json

{
  "name": "vue2",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "rollup -cw"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.23.2",
    "@babel/preset-env": "^7.23.2",
    "rollup": "^4.3.0",
    "rollup-plugin-babel": "^4.4.0"
  },
  "type": "module"
}

记得在 package.json 后面添加 "type": "module",否则启动时会提示 import babel from "rollup-plugin-babel" 错误

准备完成后运行启动命令

npm run dev

image-20231106213102696

出现上图表示启动成功,并且正在监听文件变化,文件变化后会自动重新打包

查看打包出来的文件

image-20231106213146041

然后新建一个 index.html 引入打包出来的 vue.js

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="./vue.js"></script>
</head>
<body>
<script>
    console.log(Vue)
</script>
</body>
</html>

访问这个文件,并打开控制台查看打印

image-20231106213254103

至此,我们准备工作完成,接下来开始实现Vue核心部分。

初始化数据

修改 src/index.js

import {initMixin} from "./init";

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

initMixin(Vue)

export default Vue

添加 init.js ,用于初始化数据操作,并导出 initMixin 方法

import {initStatus} from "./state.js";

export function initMixin(Vue){
    // 给Vue原型添加一个初始化方法
    Vue.prototype._init = function (options){
        const vm = this
        vm.$options = options
        // 初始化状态
        initStatus(vm)
    }
}

state.js 的写法

export function initStatus(vm){
    const opt = vm.$options
    if(opt.data){
        initData(vm)
    }
}

function initData(vm){
    let data = vm.$options.data
    data = typeof data === "function" ? data.call(vm) : data
    console.log(data)
}

我们打开控制台查看打印的东西

image-20231106222427949

可以发现已经正确的得到data数据

实现对象的响应式

现在我们在 initData 方法中拿到了data,接下来就是对data中的属性进行数据劫持

在 initData 中添加 observe 方法,并传递data对象

function initData(vm){
    let data = vm.$options.data
    data = typeof data === "function" ? data.call(vm) : data
    // 拿到数据开始进行数据劫持,把数据变成响应式的
    observe(data)
}

新建 observe/index.js 文件,实现 observe 方法

class Observer{
    constructor(data) {
        this.walk(data)
    }
    walk(data){
        // 循环对象中的每一个属性进行劫持
        Object.keys(data).forEach(key=>{
            defineReactive(data,key,data[key])
        })
    }
}

export function defineReactive(target,key,value){
    // 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持
    observe(value)
    // Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法
    // 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值
    Object.defineProperty(target,key,{
        get(){
            return value
        },
        set(newValue){
            if(newValue === value) return
            value = newValue
        }
    })
}


export function observe(data){
    // 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持
    if(typeof data !== "object" || data === null) return
    return new Observer(data)
}

现在对数据就劫持完成了,但是我们如何获取呢?我们可以吧data方法返回的对象挂载到Vue的实例上即可

还是在 initData 方法内添加代码,并且增加一个 proxy 方法,让我们可以通过 vm.xxx 的方式直接获取data中的属性值

function initData(vm){
    let data = vm.$options.data
    data = typeof data === "function" ? data.call(vm) : data
    // 拿到数据开始进行数据劫持,把数据变成响应式的
    observe(data)
    // 吧data方法返回的对象挂载到Vue的实例上
    vm._data = data
    // 目前取值必须通过 vm._data.xxx 的方式来获取值或者设置值
    // 如果想直接通过 vm.xxx 的方式来设置值,则必须对vm再进行一次代理
    proxy(vm,"_data")
}

function proxy(target,key){
    for (const dataKey in target[key]) {
        Object.defineProperty(target, dataKey,{
            get(){
                return target[key][dataKey]
            },
            set(newValue){
                target[key][dataKey] = newValue
            }
        })
    }
}

现在来打印一下 vm

image-20231113221208916

通过打印发现,vm 自身上就有了data中定义的属性

image-20231113221427297

并且直接通过 vm 来读取和设置属性值也是可以的

实现数组的响应式

实现思路:

  1. 首先遍历数组中的内容,吧数组中的数据变成响应式的
  2. 如果调用的数组中的方法,添加了新的数据,则也要吧新的数据变成响应式的,这里可以劫持7个变异方法来实现

首先在 Observer 类中添加判断,如果data是一个数组,则单独走一个observeArray方法,来实现对数组的响应式处理

import {newArrayProperty} from "./array.js";

class Observer{
    constructor(data) {
        // 定义一个__ob__,值是this,不可枚举
        // 给数据加了一个标识,表示这个数据是一个已经被响应式了的
        Object.defineProperty(data,"__ob__",{
            // 定义这个属性值是当前的实例
            value:this,
            // 定义__ob__不能被遍历,否则会引起死循环
            // 原因:在walk方法中会递归遍历对象中的每一个属性进行响应式处理,因为__ob__表示的当前对象的实例
            // 实例本身又包含__ob__,这样就会导致递归无限往里面找,就造成了死循环,
            // 所以这里要设置成 enumerable:false,不能遍历 __ob__ 这个属性
            enumerable:false
        })
        // 如果data中的某个值定义的是一个数组,则对数组进行劫持,进行响应式处理
        if(Array.isArray(data)){
            data.__proto__ = newArrayProperty
            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))
    }
}

这里在 data 中定义了 __ob__ 属性,并且值等于当前的 Observer 实例,是为了在 array.js 中拿到 Observe 实例中的 observeArray 方法,来实现对新传递进来的数据进行响应式处理

既然有了这个 __ob__ 属性,我们就可以判断一下,如果 data 中有了 __ob__ 属性,则表示这个数据已经被响应式了,则不需要进行再次响应式,所以我们可以在 observe 方法中加一个判断

export function observe(data){
    // 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持
    if(typeof data !== "object" || data === null) return
    // 如果这个对象已经被代理过了,则直接返回当前示例
    if(data.__ob__){
        return data.__ob__
    }
    return new Observer(data)
}

然后下面是 array.js 的实现代码

// 获取原始的数组原型链
let oldArrayProperty = Array.prototype
// 复制一份出来,到新的对象中
export let newArrayProperty = Object.create(oldArrayProperty)

// 声明数组的变异方法有哪些
let methods = ["push", "pop", "unshift", "shift", "reserve", "sort", "splice"]

methods.forEach(method => {
    // 调用新的方法时,接收传递进来的参数,然后再调用一下原来的
    newArrayProperty[method] = function (...args) {
        // 判断如果是下面的几个方法,则要对传递进来的参数继续进行响应式处理
        let inserted;
        // 这里的this指向的是函数的调用者,所以这里的this指向的是data,也就是在Observer类中接收到data
        // 恰好我们给data的__ob__属性设置了值,等于Observe实例,
        // 利用这点就可以拿到Observe中的observeArray方法,来对新数据进行响应式处理
        let ob = this.__ob__
        switch (method) {
            case "push":
            case "unshift":
            case "shift":
                inserted = args;
                break
            case "splice":
                inserted = args.slice(2)
                break
            default:
                break;
        }
        if(inserted){
            // 对传递进来的参数继续进行响应式处理
            ob.observeArray(inserted)
        }
        // 将结果返回
        return oldArrayProperty[method].call(this, ...args);
    }
})

现在看一下效果

image-20231116223803895

可以看到我们新 push 的数据也被响应式了

解析HTML模板

我们可以根据option中的el来获取到根标签,然后获取对应的html,拿到html后开始解析

先写一些测试代码,准备一个html页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="./vue.js"></script>
</head>
<body>
    <div id="app">
        <div style="font-size: 15px;color: blue" data-name = "123">
            {{name}}
        </div>
        <div style="color: red">
            {{age}}
        </div>
    </div>
</body>
<script>
    const vm =  new Vue({
        el:"#app",
        data(){
            return{
                name:"szx",
                age:18,
                address:{
                    price:100,
                    name:"少林寺"
                },
                hobby:['each','write',{a:"tome"}]
            }
        }
    })
</script>
</html>

然后来到 init.js 中的 initMixin 方法,判断一下是否有 el 这个属性,如果有则开始进行模板解析

import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";

export function initMixin(Vue){
    // 给Vue原型添加一个初始化方法
    Vue.prototype._init = function (options){
        const vm = this
        vm.$options = options
        // 初始化状态
        initStatus(vm)
        // 解析模板字符串
        if(vm.$options.el){
            vm.$mount(vm.$options.el)
        }
    }

    // 在原型链上添加$mount方法,用户获取页面模板
    Vue.prototype.$mount = function (el){
        let template;
        const vm = this
        const opts = vm.$options
        // 判断配置中是否已经存在template,如果没有,则根据el获取页面模板
        if(!opts.render){
            if(!opts.template && opts.el){
                // 拿到模板字符串
                template = document.querySelector(el).outerHTML
            }
            if(opts.template){
                template = opts.template
            }
            if(template){
                // 这里拿到模板开始进行模板编译
                const render = compilerToFunction(template)
            }
        }
        // 有render函数后再执行后续操作
        
    }
}

下面就是 compilerToFunction 的代码

const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配到的是结束标签的名字 </xxx>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 匹配属性的正则,第一个分组是key,value则是分组3/分组4/分组5
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 匹配自闭和的标签
const startTagClose = /^\s*(\/?)>/
// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

function parseHtml(html){
    // 前进
    function advance(n){
        html = html.substring(n)
    }
    function parseStart(){
        // 匹配开始标签
        let start = html.match(startTagOpen)
        if(start){
            const match = {
                // 获取到标签名
                tagName:start[1],
                attrs:[]
            }
            // 截取已经匹配到的内容
            advance(start[0].length)
            // 循环匹配剩下的属性,如果不是开始标签的结束位置,则开始匹配属性
            let attr,end;
            while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
                match.attrs.push({
                    name:attr[1],
                    value:attr[3] || attr[4] || attr[5] || true
                })
                // 匹配到一点后就删除一点
                advance(attr[0].length)
            }
            if(end){
                advance(end[0].length)
            }
            console.log(match)
            return match
        }
        return false
    }

    while (html){
        let textEnd = html.indexOf('<');
        // 判断做尖括号的位置,如果是0表示这是一个开始标签
        if(textEnd === 0){
            // 匹配开始标签
            const startTagMatch = parseStart()
            if(startTagMatch){
                continue
            }
            // 匹配结束标签
            let endTagMatch = html.match(endTag)
            if(endTagMatch){
                advance(endTagMatch[0].length)
                continue
            }
        }
        // 进入到这里说明匹配到了文本:{{ xxx }}
        if(textEnd > 0){
            let text = html.substring(0,textEnd)
            if(text){
                advance(text.length)
            }
        }
    }
    console.log(html)
    return ""
}


export function compilerToFunction(template){
    let ast = parseHtml(template)
    return ""
}

这段代码在不断的解析html内容,匹配到开始标签,就会标签名称和属性放在match数组中,并且删除一已经匹配到的内容,如果匹配到文本或者结束版本则删除匹配到的内容,最终html变成空,表示解析过程就结束了。

我们通过打印看一下html被解析的过程

image-20231118161020648

可以看到html的内容再不断减少

接下来,我们只需要在这些方法中添加如果匹配到开始标签,就触发一个方法处理开始标签的内容,如果匹配到文本,就处理文本内容,如果匹配到结束标签,就处理结束标签的内容。

在 parseHtml 方法中添加三个方法如下,分别处理开始标签,文本,结束标签

  • onStartTag
  • onText
  • onCloseTag
const unicodeRegExp = /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 匹配到的是结束标签的名字 </xxx>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
// 匹配属性的正则,第一个分组是key,value则是分组3/分组4/分组5
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 匹配自闭和的标签
const startTagClose = /^\s*(\/?)>/
// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

function parseHtml(html){
    // 处理开始标签
    function onStartTag(tag,attrs){
        console.log(tag,attrs)
        console.log("开始标签")
    }

    // 处理文本标签
    function onText(text){
        console.log(text)
        console.log("文本")
    }

    // 处理结束标签
    function onCloseTag(tag){
        console.log(tag)
        console.log("结束标签")
    }

    // 前进
    function advance(n){
        html = html.substring(n)
    }
    function parseStart(){
        // 匹配开始标签
        let start = html.match(startTagOpen)
        if(start){
            const match = {
                // 获取到标签名
                tagName:start[1],
                attrs:[]
            }
            // 截取已经匹配到的内容
            advance(start[0].length)
            // 循环匹配剩下的属性,如果不是开始标签的结束位置,则开始匹配属性
            let attr,end;
            while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
                match.attrs.push({
                    name:attr[1],
                    value:attr[3] || attr[4] || attr[5] || true
                })
                // 匹配到一点后就删除一点
                advance(attr[0].length)
            }
            if(end){
                advance(end[0].length)
            }
            return match
        }
        return false
    }

    while (html){
        let textEnd = html.indexOf('<');
        // 判断做尖括号的位置,如果是0表示这是一个开始标签
        if(textEnd === 0){
            // 匹配开始标签
            const startTagMatch = parseStart()
            if(startTagMatch){
                onStartTag(startTagMatch.tagName,startTagMatch.attrs)
                continue
            }
            // 匹配结束标签
            let endTagMatch = html.match(endTag)
            if(endTagMatch){
                onCloseTag(endTagMatch[1])
                advance(endTagMatch[0].length)
                continue
            }
        }
        // 进入到这里说明匹配到了文本:{{ xxx }}
        if(textEnd > 0){
            let text = html.substring(0,textEnd)
            if(text){
                onText(text)
                advance(text.length)
            }
        }
    }
    console.log(html)
    return ""
}


export function compilerToFunction(template){
    let ast = parseHtml(template)
    return ""
}

并在在相对应的代码中调用者三个方法

查看打印效果

image-20231118163156963

接下来构建语法树

function parseHtml(html){
    const ELEMENT_TYPE = 1 // 标记这是一个元素
    const TEXT_TYPE = 3 // 标记这是一个文本
    const stack = [] // 声明一个栈
    let currentParent;
    let root;

    function createNode(tag,attrs){
        return{
            tag,
            attrs,
            type:ELEMENT_TYPE,
            children:[],
            parent:null
        }
    }


    // 处理开始标签
    function onStartTag(tag,attrs){
       let node = createNode(tag,attrs)
        if(!root){
            root = node
        }
        if(currentParent){
            node.parent = currentParent
            currentParent.children.push(node)
        }
        stack.push(node)
        currentParent = node
    }

    // 处理文本标签
    function onText(text){
        text = text.replace(/\s/g,"")
        text && currentParent.children.push({
            type:TEXT_TYPE,
            text,
            parent:currentParent
        })
    }

    // 处理结束标签
    function onCloseTag(tag){
        stack.pop()
        currentParent = stack[stack.length -1]
    }
    
    
    // ...省略其他代码
    
    return root
}

然后打印一下生成的ast语法树

export function compilerToFunction(template){
    let ast = parseHtml(template)
    console.log(ast)
    return ""
}

image-20231118171134199

代码生成的实现原理

现在我们已经得到了AST语法树,接下来我们就需要根据得到的AST语法树转化成一段cvs字符串

  • _c 表示创建元素
  • _v 表示处理文本内容
  • _s 表示处理花括号包裹的文本

这里提前吧 parseHtml 方法抽离出来放在 parse.js 文件中并导出

然后在 compilerToFunction 方法中添加 codegen 方法,根据 ast 语法树生成字符串代码

import {parseHtml,ELEMENT_TYPE} from "./parse.js";

function genProps(attrs){
    let str = ``
    attrs.forEach(attr=>{
        // 遍历行内元素,变成 {id:"app",style:{"color":"red","font-size":"20px"}} 这种效果
        if(attr.name === "style"){
            let obj = {}
            attr.value.split(";").forEach(sItem=>{
                let [key,value] = sItem.split(":")
                obj[key] = value.trim()
            })
            attr.value = obj
        }
        str += `${JSON.stringify(attr.name)}:${JSON.stringify(attr.value)},`
    })
    str = `{${str.slice(0,-1)}}`
    return str
}

function genChildren(children){
   return children.map(child=>gen(child)).join(",")
}

// 匹配花括号中的的内容
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;

function gen(child) {
    // 匹配到的是一个元素
    if (child.type === ELEMENT_TYPE) {
        return codegen(child)
    } else {
        let text = child.text
        // 匹配到的是一个纯文本,不是用花括号包裹起来的文本时,会走下面的方法,返回一个 _v 函数,用于创建文本节点
        if (!defaultTagRE.test(text)) {
            return `_v(${JSON.stringify(text)})`
        } else {
            // 如果这个文本元素包含有花括号包裹的数据,就会走else方法
            let token = []
            let match;
            let lastIndex = 0
            // 设置正则的lastIndex从0开始,也就是从文本的最前面开始进行匹配
            defaultTagRE.lastIndex = 0
            // exec方法会匹配到一个花括号数据时就会走一次循环,然后继续往后匹配剩余的花括号
            // 只到匹配结束,会返回null,然后退出循环
            while (match = defaultTagRE.exec(text)) {
                // match.index返回的是当前匹配到的数据的下标
                let index = match.index
                // 如果出现匹配到的数据下标大于lastIndex下标,则表示如下情况
                // hello {{ age }},表示这个花括号前面还有文本,我们需要先把花括号前面的文本放在token中
                if (index > lastIndex) {
                    token.push(JSON.stringify(text.slice(lastIndex, index)))
                }
                // 然后再吧花括号里面的变量放在token中,并且用_s函数接收这个变量
                token.push(`_s(${match[1].trim()})`)
                // 更新lastIndex
                lastIndex = index + match[0].length
            }
            // 当exec匹配完成后,出现lastIndex < text.length的情况
            // 表示最后一个花括号后面还有普通文本,例如:{{ age }} word,则我们需要吧后面的文本也放在token中
            if (lastIndex < text.length) {
                token.push(JSON.stringify(text.slice(lastIndex)))
            }
            // 最后返回一个 _v() 函数,里面的token使用+拼接返回
            return `_v(${token.join("+")})`
        }
    }
}

function codegen(ast) {
    // _c("div",{xxx:xxx,xxx:xxx})
    // 第一个参数是需要创建的元素,第二个是对应的属性
    let code = `_c(
        ${JSON.stringify(ast.tag)},
        ${ast.attrs.length ? genProps(ast.attrs) : "null"},
        ${ast.children.length ? genChildren(ast.children) : "null"}
    )`
    return code
}


export function compilerToFunction(template) {
    // 1. 解析DOM,转化成AST语法树
    let ast = parseHtml(template)

    // 2. 根据AST语法树生成cvs字符串
    let cvs = codegen(ast)

    console.log(cvs)

    return ""
}

效果如下图

image-20231119150538802

生成render函数

现在我们得到了一个字符串,并不是一个函数,下面就是要把这个字符串变成一个render函数

export function compilerToFunction(template) {
    // 1. 解析DOM,转化成AST语法树
    let ast = parseHtml(template)

    // 2. 根据AST语法树生成cvs字符串
    let code = codegen(ast)

    // 3.根据字符串生成render方法
    code = `with(this){return ${code}}`
    let render = new Function(code)

    console.log(render.toString())
    return render
}

这里的 with 方法可以自动从 this 中读取变量值

with (object) {
  // 在此作用域内可以直接使用 object 的属性和方法
  // 无需重复引用 object
  // 例如:
  // property1 // 相当于 object.property1
  // method1() // 相当于 object.method1()
  // ...
  // 注意:如果 object 中不存在某个属性或方法,会向上级作用域查找
  // 如果上级作用域也找不到,则会抛出 ReferenceError
  // 在严格模式下,不允许使用 with 语句
}

简单示例

let testObj = {
    name:"Tome",
    age:18
}
with (testObj) {
    console.log(name + age); // 输出:Tome18
}

现在有了render函数后,将render 返回并添加到 $options 中

在 initMixin 方法中添加

import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {mountComponent} from "./lifecycle.js";

export function initMixin(Vue){
    // 给Vue原型添加一个初始化方法
    Vue.prototype._init = function (options){
        const vm = this
        vm.$options = options
        // 初始化状态
        initStatus(vm)
        // 解析模板字符串
        if(vm.$options.el){
            vm.$mount(vm.$options.el)
        }
    }

    // 在原型链上添加$mount方法,用户获取页面模板
    Vue.prototype.$mount = function (el){
        let template;
        const vm = this
        const opts = vm.$options
        // 判断配置中是否已经存在template,如果没有,则根据el获取页面模板
        if(!opts.render){
            if(!opts.template && opts.el){
                // 拿到模板字符串
                template = document.querySelector(el).outerHTML
            }
            if(opts.template){
                template = opts.template
            }
            if(template){
                // 这里拿到模板开始进行模板编译
                opts.render = compilerToFunction(template)
            }
        }
        // 有了render函数,开始对组件进行挂载
        mountComponent(vm,el)
    }
}

添加 mountComponent 方法,新建一个文件,单独写个这个方法

lifecycle.js

/**
 * Vue 核心流程
 * 1.创造了响应式数据
 * 2.根据模板转化成ast语法树
 * 3.将ast语法树转化成render函数
 * 4.后续每次更新数据都只执行render函数,自动更新页面
 */

export function initLifeCycle(Vue){
    Vue.prototype._render = function (){

    }

    Vue.prototype._update = function (){

    }
}

// 挂载页面
export function mountComponent(vm,el){
    vm.$el = el
    // 1.调用render方法产生虚拟节点
    vm._update(vm._render())
    // 2.根据虚拟DOM产生真实DOM

    // 3.插入到el中去
}

initLifeCycle 方法需要接收一个 Vue,我们可以在 index.js 文件中添加调用

import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";

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

initMixin(Vue)
initLifeCycle(Vue)

export default Vue

创建虚拟节点并更新视图

完善 lifecycle.js 文件的代码

/**
 * Vue 核心流程
 * 1.创造了响应式数据
 * 2.根据模板转化成ast语法树
 * 3.将ast语法树转化成render函数
 * 4.后续每次更新数据都只执行render函数,自动更新页面
 */
import {createElementVNode, createTextVNode} from "./vdom/index.js";

export function initLifeCycle(Vue){
    // _c 的返回值就是 render 函数的返回值
    Vue.prototype._c = function (){
        // 创建一个虚拟DOM
        return createElementVNode(this,...arguments)
    }

    // _v 的返回值给 _c 使用
    Vue.prototype._v = function (){
        return createTextVNode(this,...arguments)
    }

    // _s 的返回值给 _v 使用
    Vue.prototype._s = function (value){
        return value
    }

    // _render函数的返回值会作为参数传递给 _update
    Vue.prototype._render = function (){
        return this.$options.render.call(this)
    }

    // 更新视图方法
    Vue.prototype._update = function (vnode){
        // 获取当前的真实DOM
        const elm = document.querySelector(this.$options.el)
        patch(elm,vnode)
    }
}

function patch(oldVNode,newVNode){
    // 判断是否是一个真实元素,如果是真实DOM会返回1
    const isRealEle = oldVNode.nodeType;
    if(isRealEle){
        // 获取真实元素
        const elm = oldVNode
        // 获取真实元素的父元素
        const parentElm = elm.parentNode
        // 创建新的虚拟节点
        let newRealEl = createEle(newVNode)
        // 把新的虚拟节点插入到真实元素后面
        parentElm.insertBefore(newRealEl,elm.nextSibling)
        // 然后删除之前的DOM
        parentElm.removeChild(elm)
    }
}

function createEle(vnode){
   let {tag,data,children,text} = vnode
    if (typeof tag === "string"){
        vnode.el = document.createElement(tag)
        Object.keys(data).forEach(prop=>{
            if(prop === "style"){
                Object.keys(data.style).forEach(sty=>{
                    vnode.el.style[sty] = data.style[sty]
                })
            }else{
                vnode.el.setAttribute(prop,data[prop])
            }
        })
        // 递归处理子元素
        children.forEach(child=>{
            vnode.el.appendChild(createEle(child))
        })
    }else{
        // 当时一个文本元素是,tag是一个undefined,所以会走else
        vnode.el = document.createTextNode(text)
    }
    return vnode.el
}

// 挂载页面
export function mountComponent(vm,el){
    vm.$el = el
    // 1.调用render方法产生虚拟节点
    // 2.根据虚拟DOM产生真实DOM
    // 3.插入到el中去
    vm._update(vm._render())
}

vnode/index.js

// 创建虚拟节点
export function createElementVNode(vm,tag,prop,...children){
    if(!prop){
        prop = {}
    }
    let key = prop.key
    if(key){
        delete prop.key
    }
    return vnode(vm,tag,prop,key,children,undefined)
}


// 创建文本节点
export function createTextVNode(vm,text){
    return vnode(vm,undefined,undefined,undefined,undefined,text)
}

function vnode(vm,tag,data,key,children,text){
 children = children.filter(Boolean)
 return {
     vm,
     tag,
     data,
     key,
     children,
     text
 }
}

此时我们的页面就可以正常显示数据了

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="./vue.js"></script>
</head>
<body>
    <div id="app" style="color: red;font-size: 20px">
        <div style="font-size: 15px;color: blue" data-name = "123">
            你好 {{ name }} hello {{ age }} word
        </div>
        <span>
            {{ address.name }}
        </span>
    </div>
</body>
<script>
    const vm =  new Vue({
        el:"#app",
        data(){
            return{
                name:"szx",
                age:18,
                address:{
                    price:100,
                    name:"少林寺"
                },
                hobby:['each','write',{a:"tome"}]
            }
        }
    })
</script>
</html>

image-20231121232723039

实现依赖收集

现在初次渲染已经可以吧页面上绑定的数据渲染成我们定义的数据,但是当我们改变data数据时,页面不会发生更新,这里就要使用观察者模式,实现依赖收集。

读取某个属性时,会调用get方法,在get方法中收集watcher(观察者),然后当更新数据时,会调用set方法,通知当前这个属性绑定的观察者去完成更新视图的操作。

在get方法中收集watcher的同时,watcher也要收集这个属性(dept),要知道我当前的这个watcher下面有几个dept。

一个dept对应多个watcher,因为一个属性可能会在多个视图中使用

一个watcher对应多个dept,因为一个组件中会有多个属性

下面的代码实现逻辑

修改 lifecycle.js 文件中的 mountComponent 方法

// 挂载页面
export function mountComponent(vm,el){
    vm.$el = el
    // 1.调用render方法产生虚拟节点
    // 2.根据虚拟DOM产生真实DOM
    // 3.插入到el中去

    const updateComponent = ()=>{
        vm._update(vm._render())
    }
    // 初始化渲染
    new Watcher(vm,updateComponent)
} 

新建 observe/watcher.js

import Dep from "./dep.js";

let id = 0

class Watcher{
    constructor(vm,fn) {
        this.id = id++ // 唯一ID
        this.getter = fn // 这里存放多个dep,一个Watcher对应多个deps
        this.depts = []
        this.deptSet = new Set()
        this.get()
    }
    get(){
        // 在调用getter之前,吧当前的Watcher实例放在Dep全局上
        Dep.target = this
        // 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法
        this.getter()
        //  调用完getter之后,把Dep.target置为null
        Dep.target = null
    }
    // watcher也要知道我自己下面有几个dept,所以这里要收集一下
    addDep(dep){
        // 判断dep的id不能重复
        if(!this.deptSet.has(dep.id)){
            this.depts.push(dep)
            this.deptSet.add(dep.id)
            dep.addSubs(this)
        }
    }
    // 更新视图
    update(){
        this.get()
    }
}

export default Watcher

对应的 observe/dep.js

let id = 0
class Dep{
    constructor() {
        this.id = id++
        this.subs = [] // 存放当前这个属性对应的多个watcher
    }
    depend(){
        // Dep.target 就是当前的 watcher
        // 让watcher记住当前的dep
        Dep.target.addDep(this)
    }
    // 当在watcher函数中添加好dep后会调用dep的addSubs方法,在dep中再保存一下watcher
    addSubs(watcher){
        this.subs.push(watcher)
    }
    // 更新属性时更新这个属性对应的dep上的notify方法,会遍历这个dep对应的所有的watcher进行更新视图
    notify(){
        this.subs.forEach(watcher => watcher.update())
    }
}
export default Dep

dep 就是被观察者,watcher 就是观察者,在属性的 get 和 set 方法中进行依赖收集和更新通知

修改 observe/index.js defineReactive 方法

export function defineReactive(target,key,value){
    // 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持
    observe(value)
    // Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法
    // 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值
    let dep = new Dep();
    Object.defineProperty(target,key,{
        get(){
            // 进行依赖收集,收集这个属性的观察者(watcher)
            if(Dep.target){
                dep.depend()
            }
            return value
        },
        set(newValue){
            if(newValue === value) return
            value = newValue
            // 通知观察者更新视图
            dep.notify()
        }
    })
}

测试视图更新

const vm =  new Vue({
    el:"#app",
    data(){
        return{
            name:"szx",
            age:18,
            address:{
                price:100,
                name:"少林寺"
            },
            hobby:['each','write',{a:"tome"}]
        }
    }
})

function addAge(){
    vm.name = "李四"
    vm.age = 20
}

image-20231122232155435

我们发现,点击更新按钮后视图确实发生了更新。但是控制台打印了两次更新。这是因为我们在 addAge 方法中对两个属性进行了更改,所以触发了两次更新。下面我们来解决这个问题,让他只触发一次更新

实现异步更新

修改 Watch 中的 update 方法,同时新增一个 run 方法,专门用于更新视图操作

import Dep from "./dep.js";

let id = 0

class Watcher{
    constructor(vm,fn) {
        this.id = id++ // 唯一ID
        this.getter = fn // 这里存放多个dep,一个Watcher对应多个deps
        this.depts = []
        this.deptSet = new Set()
        this.get()
    }
    get(){
        // 在调用getter之前,吧当前的Watcher实例放在Dep全局上
        Dep.target = this
        // 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法
        this.getter()
        //  调用完getter之后,把Dep.target置为null
        Dep.target = null
    }
    // watcher也要知道我自己下面有几个dept,所以这里要收集一下
    addDep(dep){
        // 判断dep的id不能重复
        if(!this.deptSet.has(dep.id)){
            this.depts.push(dep)
            this.deptSet.add(dep.id)
            dep.addSubs(this)
        }
    }
    // 更新视图
    update(){
        // 实现异步更新,将多个watcher放在一个队列中,然后写一个异步任务实现异步更新
        queueWatcher(this)
    }
	run(){
        console.log('更新视图')
        this.getter()
    }
}

let queue = []
let watchObj = {}
let padding = false
function queueWatcher(watcher){
    if(!watchObj[watcher.id]){
        watchObj[watcher.id] = true
        queue.push(watcher)
        // 执行多次进行一个防抖
        if(!padding){
            // 等待同步任务执行完再执行异步更新
            nextTick(flushSchedulerQueue,0)
            padding = true
        }
    }
}

function flushSchedulerQueue(){
    let flushQueue = queue.slice(0)
    queue = []
    watchObj = {}
    padding = false
    flushQueue.forEach(cb=>cb.run())
}

let callbacks = []
let waiting = false
export function nextTick(cb){
    callbacks.push(cb)
    if(!waiting){
        // setTimeout(()=>{
        //     // 依次执行回调
        //     flushCallback()
        // })

        // 使用 Promise.resolve进行异步更新
        Promise.resolve().then(flushCallback)
        waiting = true
    }
}

function flushCallback(){
    let cbs = callbacks.slice(0)
    callbacks = []
    waiting = false
    cbs.forEach(cb=>cb())
}

export default Watcher

src/index.js 中挂载全局的 $nexitTick 方法

import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {nextTick} from "./observe/watcher.js";

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

Vue.prototype.$nextTick = nextTick

initMixin(Vue)
initLifeCycle(Vue)

export default Vue

页面使用

function addAge(){
    vm.name = "李四"
    vm.age = 20
    vm.$nextTick(()=>{
        console.log(document.querySelector("#name").innerText)
    })
}

点击更新按钮执行 addAge 方法,可以在控制台看到只触发了一个更新视图,并且获取的页面也是更新后的

image-20231123223906880

实现mixin核心功能

mixin的核心是合并对象,将Vue.mixin中的对象和在Vue中定义的属性进行合并,然后再初始化状态前后调用不同的Hook即可

首先在 index.js 中添加方法调用

index.js 文件增加 initGlobalApi,传入 Vue

import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {nextTick} from "./observe/watcher.js";
+ import {initGlobalApi} from "./globalApi.js";

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

Vue.prototype.$nextTick = nextTick

initMixin(Vue)
initLifeCycle(Vue)
+ initGlobalApi(Vue)

export default Vue

globalApi.js 内容如下

import {mergeOptions} from "./utils.js";

export function initGlobalApi(Vue) {
    // 添加一个静态方法 mixin
    Vue.options = {}
    Vue.mixin = function (mixin) {
        this.options = mergeOptions(this.options, mixin)
        return this
    }
}

utils.js 中实现 mergeOptions 方法

// 定义一些策略,例如 created,beforeCreated 等,不需要写大量的if判断
const strats = {}
const LIFECYCLE = [
    "beforeCreated",
    "created"
]

LIFECYCLE.forEach(key => {
    strats[key] = function (p, c) {
        if (c) {
            if (p) {
                return p.concat(c)
            } else {
                return [c]
            }
        } else {
            return p
        }
    }
})
// 合并属性的方法
export function mergeOptions(parent, child) {
    const options = {}
    // 先获取父亲的值
    for (const key in parent) {
        mergeField(key)
    }
    for (const key in child) {
        // 如果父亲里面没有这个子属性,在进行合并子的
        /**
         * 示例:父亲:{a:1} 儿子:{a:2}
         *      儿子中也有父亲的属性a,所以不会走儿子中的合并方法,但是在取值的时候,优先取的是儿子身上的属性值
         *      所以合并到一个对象中时,儿子会覆盖父亲
         */
        if (!parent.hasOwnProperty(key)) {
            mergeField(key)
        }
    }

    function mergeField(key) {
        if (strats[key]) {
            // {created:fn} {}
            // 合并声明周期上的方法,例如:beforeCreated,created
            options[key] = strats[key](parent[key], child[key])
        } else {
            // 先拿到儿子的值
            options[key] = child[key] || parent[key]
        }
    }

    return options
}

然后在 init.js 中进行属性合并和Hook调用

import {initStatus} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {mountComponent} from "./lifecycle.js";
+import {mergeOptions} from "./utils.js";

export function initMixin(Vue){
    // 给Vue原型添加一个初始化方法
    Vue.prototype._init = function (options){
        const vm = this
+        // this.constructor就是当前的大Vue,获取的是Vue上的静态属性
+        // this.constructor.options 拿到的就是mixin合并后的数据
+        // 然后再把用户写的options和mixin中的进行再次合并
+        vm.$options = mergeOptions(this.constructor.options,options)
+        // 初始化之前调用beforeCreated
+        callHook(vm,"beforeCreated")
+        // 初始化状态
+        initStatus(vm)
+        // 初始化之后调用created
+        callHook(vm,"created")
        // 解析模板字符串
        if(vm.$options.el){
            vm.$mount(vm.$options.el)
        }
    }

    // 在原型链上添加$mount方法,用户获取页面模板
    Vue.prototype.$mount = function (el){
        let template;
        const vm = this
        el = document.querySelector(el)
        const opts = vm.$options
        // 判断配置中是否已经存在template,如果没有,则根据el获取页面模板
        if(!opts.render){
            if(!opts.template && opts.el){
                // 拿到模板字符串
                template = el.outerHTML
            }
            if(opts.template){
                template = opts.template
            }
            if(template){
                // 这里拿到模板开始进行模板编译
                opts.render = compilerToFunction(template)
            }
        }
        // 有了render函数,开始对组件进行挂载
        mountComponent(vm,el)
    }
}

+function callHook(vm,hook){
+    // 拿到用户传入的钩子函数
+    const handlers = vm.$options[hook]
+    if(handlers){
+        // 遍历钩子函数,执行钩子函数
+        for(let i=0;i<handlers.length;i++){
+            handlers[i].call(vm)
+        }
+    }
+}

测试 Vue.mixin

// Vue内部会把minix进行合并,如果有两个created会合并成一个created数组,里面有两个方法,然后依次执行
Vue.mixin({
    beforeCreated() {
        console.log("beforeCreated")
    },
    created() {
        console.log(this.name,"--mixin")
    },
})

const vm = new Vue({
    el: "#app",
    data() {
        return {
            name: "szx",
            age: 18,
            address: {
                price: 100,
                name: "少林寺"
            },
            hobby: ['each', 'write', {a: "tome"}]
        }
    },
    created() {
        console.log(this.name,"--vue")
    }
})

查看控制台打印

image-20231125151910158

实现数组更新

我们之前给每一个属性都加了一个dep,实现依赖收集,但是如果这个属性值是一个对象类型的话,当我们不改变这个属性的引用地址,只是改变对象属性值,比如给数组push一个数据,不会改变原来的引用地址。这样的话页面就无法实现更新。

我们可以判断一下,当属性值是一个对象类型的时候,给这个对象本身也添加一个dep,当读取这个属性值的时候,进行一下依赖收集,如果是一个数组的话,当调用完push等方法时,在我们重写的方法哪里再执行一个更新就可以了。

下面是代码实现

修改 observe/index.js

import {newArrayProperty} from "./array.js";
import Dep from "./dep.js";

class Observer{
    constructor(data) {
        // 给对象类型的数据加一个 Dep 实例
+        this.dep = new Dep()
        // 定义一个__ob__,值是this,不可枚举
        // 给数据加了一个标识,表示这个数据是一个已经被响应式了的
        Object.defineProperty(data,"__ob__",{
            // 定义这个属性值是当前的实例
            value:this,
            // 定义__ob__不能被遍历,否则会引起死循环
            // 原因:在walk方法中会递归遍历对象中的每一个属性进行响应式处理,因为__ob__表示的当前对象的实例
            // 实例本身又包含__ob__,这样就会导致递归无限往里面找,就造成了死循环,
            // 所以这里要设置成 enumerable:false,不能遍历 __ob__ 这个属性
            enumerable:false
        })
        // 如果data中的某个值定义的是一个数组,则对数组进行劫持,进行响应式处理
        if(Array.isArray(data)){
            data.__proto__ = newArrayProperty
            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))
    }
}

+function dependArr(array){
+    array.forEach(item=>{
+        // 数组中的普通类型的值不会有__ob__
+        item.__ob__ && item.__ob__.dep.depend()
+        if(Array.isArray(item)){
+            dependArr(item)
+        }
+    })
+}

export function defineReactive(target,key,value){
    // 这里对当前的值进行判断,如果值还是一个对象,则递归继续进行深度劫持
+    const childOb = observe(value)
    // Object.defineProperty 接收三个参数,第一个是对象,第二是要劫持的key,第三个是一个对象,里面有get和set方法
    // 当读取这个key是,会触发get方法,当设置key时,会触发set方法,并接收新值
    let dep = new Dep();
    Object.defineProperty(target,key,{
        get(){
            // 进行依赖收集,收集这个属性的观察者(watcher)
            if(Dep.target){
                dep.depend()
+                if(childOb){
+                    childOb.dep.depend()
+                    if(Array.isArray(value)){
+                        dependArr(value)
+                    }
+                }
            }
            return value
        },
        set(newValue){
            if(newValue === value) return
            value = newValue
            // 通知观察者更新视图
            dep.notify()
        }
    })
}


export function observe(data){
    // 对这个对象进行劫持,需要判断一下是否是一个对象,如果不是一个对象不能进行劫持
    if(typeof data !== "object" || data === null) return
    // 如果这个对象已经被代理过了,则直接返回当前示例
    if(data.__ob__){
        return data.__ob__
    }
    return new Observer(data)
}

实现效果

const vm = new Vue({
    el: "#app",
    data() {
        return {
            name: "szx",
            age: 18,
            address: {
                price: 100,
                name: "少林寺"
            },
            hobby: ["爬山","玩游戏"]
        }
    },
    created() {
        console.log(this.name,"--vue")
    }
})

function addAge() {
    vm.hobby.push("吃")
}

image-20231125163325570

点击后页面会自动更新,并且控制台打印了一次更新视图

实现计算属性

首先添加computed计算属性

const vm = new Vue({
    el: "#app",
    data() {
        return {
            name: "szx",
            age: 18,
            address: {
                price: 100,
                name: "少林寺"
            },
            hobby: ["爬山","玩游戏"]
        }
    },
    created() {
        console.log(this.name,"--vue")
    },
    computed:{
        fullname(){
            console.log("调用计算属性")
            return this.name + this.age
        }
    }
})

找到 state.js 文件,添加如下代码,添加针对 computed 属性的处理逻辑

import {observe} from "./observe/index.js";

export function initStatus(vm){
    // vm是Vue实例
    const opt = vm.$options
    // 处理data属性
    if(opt.data){
        initData(vm)
    }
    // 处理computed计算属性
    if(opt.computed){
        initComputed(vm)
    }
}

//...省略原有代码

function initComputed(vm){
    let computed = vm.$options.computed
    // 遍历计算属性中的每一个方法,将方法名作为一个key
    Object.keys(computed).forEach(key=>{
        // 劫持每一个属性,将key作为一个新的属性,方法的返回值作为这个属性值添加到vm上
        defineComputed(vm,key,computed)
    })
}

function defineComputed(target,key,computed){
    let getter = typeof computed[key] === "function" ? computed[key] : computed[key].get
    let setter = computed[key].set || (()=>{})
    Object.defineProperty(target,key,{
        get:getter,
        set:setter
    })
}

现在我们就可以在页面上使用

<span>
    {{fullname}} {{fullname}} {{fullname}}
</span>

image-20231125204444268

但是会发现执行了三次计算属性的方法,在真正的vue中,计算属性是带有缓存的。我们可以定义一个标识,当执行完一次计算属性方法后,把这个标识改掉,下次再次调用计算属性时,从缓存获取

修改 initComputed 方法

function initComputed(vm){
    let computed = vm.$options.computed
    const computedWatchers = vm._computedWatchers = {}
    // 遍历计算属性中的每一个方法,将方法名作为一个key
    Object.keys(computed).forEach(key=>{
        let getter = typeof computed[key] === "function" ? computed[key] : computed[key].get
        // 给每个计算属性绑定一个watcher,并且标记状态是lazy
        // 然后再watcher中判断这个状态,决定是否立即执行一次和是否返回缓存的数据
        computedWatchers[key] = new Watcher(vm,getter,{lazy:true})
        // 劫持每一个属性,将key作为一个新的属性,方法的返回值作为这个属性值添加到vm上
        defineComputed(vm,key,computed)
    })
}

function defineComputed(target,key,computed){
    let setter = computed[key].set || (()=>{})
    Object.defineProperty(target,key,{
        get:createComputedGetter(key),
        set:setter
    })
}

// 收集计算属性watcher
function createComputedGetter(key){
    return function (){
        let watcher = this._computedWatchers[key]
        // 这里的dirty默认是true
        if(watcher.dirty){
            // 调用完watcher上的evaluate方法后,会吧这个dirty改成false
            // 同时吧计算属性的方法返回值赋值给当前watcher的value属性上
            watcher.evaluate()
        }
        // 这样在多次调用计算属性的get方法时,只会触发一次真正的get方法
        return watcher.value
    }
}

修改 Watcher.js

import Dep from "./dep.js";

let id = 0

class Watcher{
    constructor(vm,fn,options = {}) {
        this.id = id++ // 唯一ID
        this.vm = vm
        this.getter = fn // 这里存放多个dep,一个Watcher对应多个deps
        this.depts = []
        this.deptSet = new Set()
        this.lazy = options.lazy
        // 用作计算属性的缓存,标记是否需要重新计算
        this.dirty = this.lazy
        // 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染
        // 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面
        this.dirty ? undefined : this.get()
    }
    evaluate(){
        // 在这个方法中调用get方法,会去执行计算属性的方法
        // 这时get中的this指向的计算属性watcher,同时会这这个计算属性watch加入栈中
        this.value = this.get()
        this.dirty = false
    }
    get(){
        // 在调用getter之前,吧当前的Watcher实例放在Dep全局上
        // Dep.target = this
        pushWatcher(this)
        // 调用getter就相当于调用的render函数,调用了render函数就会触发每个属性的get方法
        let value = this.getter.call(this.vm)
        //  调用完getter之后,把Dep.target置为null
        // Dep.target = null
        popWatcher()
        //  把计算属性的值赋值给value
        return value
    }
    // ... 省略其他代码
}

// ... 省略其他代码

let stack = []
function pushWatcher(watcher){
    stack.push(watcher)
    Dep.target = watcher
}
function popWatcher(){
    stack.pop()
    Dep.target = stack[stack.length-1]
}

export default Watcher

上面我们给每一个计算属性绑定了一个计算watcher,并且添加了一个lazy标记,然后再watcher中吧dirty的值默认等于这个标记,同时添加一个evaluate方法,专门处理计算属性的返回值

现在我们页面上使用三次计算属性,但是只会执行一次

image-20231125211339957

现在当我们更改依赖的属性时,页面不会发生变化

image-20231125211449274

这是为什么呢?这是因为目前计算属性中依赖的属性中的dep绑定是的计算Watcher,并不是渲染Watcher,当我们改变了计算属性依赖值时,通知的只是计算属性Watcher,所以不会引起页面的渲染。这就需要同时去触发渲染Watcher。

createComputedGetter 方法中增加一个判断,判断Dep.target是否还有值,有值就表示计算属性的watcher出栈后,还有一个渲染watcher,调用watcher中的depend方法,获取计算属性watcher所有的属性,也就是每一个dep,遍历这些dep,去同时吧渲染watcher添加到这些计算属性所依赖的dep的订阅者中,这样当这些依赖的值发生变化时,就会通知到渲染watcher,从而去更新页面。

// 收集计算属性watcher
function createComputedGetter(key){
    return function (){
        let watcher = this._computedWatchers[key]
        // 这里的dirty默认是true
        if(watcher.dirty){
            // 调用完watcher上的evaluate方法后,会吧这个dirty改成false
            // 同时吧计算属性的方法返回值赋值给当前watcher的value属性上
            watcher.evaluate()
        }
        // 判断Dep.target是否还有值,有值就表示计算属性的watcher出栈后,还有一个渲染watcher
        if(Dep.target){
            // 调用watcher中的depend方法,获取计算属性watcher所有的属性,也就是每一个dep
            // 遍历这些dep,去同时吧渲染watcher添加到这些计算属性所依赖的dep的订阅者中
            // 这样当这些依赖的值发生变化时,就会通知到渲染watcher,从而去更新页面
            watcher.depend()
        }
        // 这样在多次调用计算属性的get方法时,只会触发一次真正的get方法
        return watcher.value
    }
}

然后再 Watcher 中添加 depend 方法

import Dep from "./dep.js";

let id = 0

class Watcher{
    constructor(vm,fn,options = {}) {
        this.id = id++ // 唯一ID
        this.vm = vm
        this.getter = fn // 这里存放多个dep,一个Watcher对应多个deps
        this.depts = []
        this.deptSet = new Set()
        this.lazy = options.lazy
        // 用作计算属性的缓存,标记是否需要重新计算
        this.dirty = this.lazy
        // 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染
        // 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面
        this.dirty ? undefined : this.get()
    }
    evaluate(){
        // 在这个方法中调用get方法,会去执行计算属性的方法
        // 这时get中的this指向的计算属性watcher,同时会这这个计算属性watch加入栈中
        this.value = this.get()
        this.dirty = false
    }
    depend(){
        let i = this.depts.length
        while (i--){
            this.depts[i].depend()
        }
    }
    // ...省略其他代码
}

// ...省略其他代码

export default Watcher

image-20231125221305019

现在当修改了计算属性所依赖的属性值时,会更新视图。然后重新调用一次计算属性

实现watch监听

watch可以理解为一个自定义的观察者watcher,当观察的属性发生变化时,执行对应的回调即可

首先新增一个全局 $watch

src/index.js

import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import Watcher, {nextTick} from "./observe/watcher.js";
import {initGlobalApi} from "./globalApi.js";

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

Vue.prototype.$nextTick = nextTick

initMixin(Vue)
initLifeCycle(Vue)
initGlobalApi(Vue)

+ Vue.prototype.$watch = function (expOrFn, cb) {
+     new Watcher(this, expOrFn, {user:true},cb)
+ }
export default Vue

然后再初始化状态,增加一个初始化watch的方法

src/state.js

import {observe} from "./observe/index.js";
import Watcher from "./observe/watcher.js";
import Dep from "./observe/dep.js";

export function initStatus(vm){
    // vm是Vue实例
    const opt = vm.$options
    // 处理data属性
    if(opt.data){
        initData(vm)
    }
    // 处理computed计算属性
    if(opt.computed){
        initComputed(vm)
    }
    // 处理watch方法
    if(opt.watch){
        initWatch(vm)
    }
}

// ....省略其他代码

function initWatch(vm){
    // 从vm中获取用户定义的watch对象
    let watch = vm.$options.watch
    // 遍历这个对象获取每一个属性名和属性值
    for (const watchKey in watch) {
        // 属性值
        let handle = watch[watchKey]
        // 属性值可能是一个数组
        /**
         age:[
         (newVal,oldVal)=>{
                    console.log(newVal,oldVal)
                },
         (newVal,oldVal)=>{
                    console.log(newVal,oldVal)
                },
         ]
         */
        if(Array.isArray(handle)){
            for (let handleElement of handle) {
                createWatcher(vm,watchKey,handleElement)
            }
        }else{
            // 如果不是数组可能是一个字符串或者是一个回调
            // 这里先不考虑是字符串的情况
            createWatcher(vm,watchKey,handle)
        }
    }

}

function createWatcher(vm,keyOrFn,handle){
  vm.$watch(keyOrFn,handle)
}

然后修改Watcher类,当所监听的值发生变化时触发回调

src/observe/watcher.js

import Dep from "./dep.js";

let id = 0

class Watcher{
    constructor(vm,keyOrFn,options = {},cb) {
        this.id = id++ // 唯一ID
        this.vm = vm
+        // 如果是一个字符串吗,则包装成一个方法
+        if(typeof keyOrFn === 'string'){
+             this.getter = function (){
+                 return vm[keyOrFn]
+             }
+         }else{
+             this.getter = keyOrFn // 这里存放多个dep,一个Watcher对应多个deps
+         }
        this.depts = []
        this.deptSet = new Set()
        this.lazy = options.lazy
        // 用作计算属性的缓存,标记是否需要重新计算
        this.dirty = this.lazy
        // 由于watcher会默认执行一次get,会渲染一次页面,但是计算属性不需要一上来就执行渲染
        // 所以这里判断,如果dirty为true,则不执行get,只有当dirty为false的时候才执行get,渲染页面
+        this.value = this.dirty ? undefined : this.get()
+        // 区分是否为用户自定义watcher
+        this.user = options.user
+        // 拿到watcher的回调
+        this.cb = cb
    }
    
    // ....省略其他代码
    run(){
        console.log('更新视图')
+        let oldVal = this.value
+        let newVal = this.getter()
+        // 判断是否是用户自定义的watcher
+        if(this.user){
+            this.cb.call(this.vm,newVal,oldVal)
+        }
    }
}

// ....省略其他代码

export default Watcher

实现基本的diff算法

首先吧 src/index.js 中的 $nextTick$watch 放在 src/state.js 文件中,并封装在 initStateMixin 方法内,并且导出

src/state.js

import Watcher, {nextTick} from "./observe/watcher.js";

// ....省略其他代码

export function initStateMixin(Vue){
    Vue.prototype.$nextTick = nextTick
    Vue.prototype.$watch = function (expOrFn, cb) {
        new Watcher(this, expOrFn, {user:true},cb)
    }
}

src/index.js 导出并使用,并且下面添加了diff的测试代码

import {initMixin} from "./init";
import {initLifeCycle} from "./lifecycle.js";
import {initGlobalApi} from "./globalApi.js";
import {initStateMixin} from "./state.js";
import {compilerToFunction} from "./compile/index.js";
import {createEle, patch} from "./vdom/patch.js";

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

initMixin(Vue)
initLifeCycle(Vue)
initGlobalApi(Vue)
initStateMixin(Vue)

//----------测试diff算法---------------
let render1 = compilerToFunction("<div style='color: red'></div>")
let vm1 = new Vue({data:{name:"张三"}})
let prevVNode = render1.call(vm1)
let el = createEle(prevVNode)
document.body.appendChild(el)

let render2 = compilerToFunction(`<div style='background-color: blue;color: white'>
 <h2>{{name}}</h2>
 <h3>{{name}}</h3>
</div>`)
let vm2 = new Vue({data:{name:"李四"}})
let newVNode = render2.call(vm2)

setTimeout(()=>{
    console.log(prevVNode)
    console.log(newVNode)
    patch(prevVNode,newVNode)
},1000)

export default Vue

上面代码生成了两个虚拟节点,然后倒计时1秒后进行更新

src/vdom/patch.js 中对节点进行比较

下面的代码在patchVNode完成新节点和旧节点的对比

import {isSameVNode} from "./index.js";

export function patch(oldVNode,newVNode){
    // 判断是否是一个真实元素,如果是真实DOM会返回1
    const isRealEle = oldVNode.nodeType;
    // 初次渲染
    if(isRealEle){
        // 获取真实元素
        const elm = oldVNode
        // 获取真实元素的父元素
        const parentElm = elm.parentNode
        // 创建新的虚拟节点
        let newRealEl = createEle(newVNode)
        // 把新的虚拟节点插入到真实元素后面
        parentElm.insertBefore(newRealEl,elm.nextSibling)
        // 然后删除之前的DOM
        parentElm.removeChild(elm)
    }else{
        // 对比新旧节点
        patchVNode(oldVNode,newVNode)
    }
}

// 根据虚拟dom渲染真实的dom
export function createEle(vnode){
    let {tag,data,children,text} = vnode
    if (typeof tag === "string"){
        vnode.el = document.createElement(tag)
        // 处理节点的属性
        patchProps(vnode.el,{},data)
        // 递归处理子元素
        children.forEach(child=>{
            child && vnode.el.appendChild(createEle(child))
        })
    }else{
        vnode.el = document.createTextNode(text)
    }
    return vnode.el
}

// 给节点添加属性
function patchProps(el,oldProps = {},props){
    let oldPropsStyle = oldProps.style
    let newPropsStyle = props.style

    // 判断如果新节点上没有旧节点的样式,则应该吧原来的样式清空掉
    for (const key in oldPropsStyle) {
        if(!newPropsStyle[key]){
            el.style[key] = ""
        }
    }

    // 判断旧节点的属性在新节点是否存在
    for (const key in oldProps) {
        if(!props[key]){
            el.removeAttribute(key)
        }
    }

    for (const key in props) {
        if(key === "style"){
            Object.keys(props.style).forEach(sty=>{
                el.style[sty] = props.style[sty]
            })
        }else{
            el.setAttribute(key,props[key])
        }
    }
}

// 对比新旧节点
function patchVNode(oldVNode,newVNode){
    // 进行diff算法,对比新老节点
    // 对比两个节点的tag和key是否一样,如果不一样,则直接吧老的替换掉,换成新的节点
    if(!isSameVNode(oldVNode,newVNode)){
        let el = createEle(newVNode)
        oldVNode.el.parentNode.replaceChild(el,oldVNode.el)
        return el
    }
    // 一样的情况对DOM元素进行复用
    let el = newVNode.el = oldVNode.el  
    // 如果一样,则还需要判断一下文本的情况
    if(!oldVNode.tag){
        if(oldVNode.text !== newVNode.text){
            el.textContent = newVNode.text
        }
    }
    // 比较新节点和旧节点的属性是否一致
    patchProps(el,oldVNode.data,newVNode.data)
    // 然后比较新旧节点的儿子节点
    let oldVNodeChildren = oldVNode.children || []
    let newVNodeChildren = newVNode.children || []

    if(oldVNodeChildren.length > 0 && newVNodeChildren.length > 0){
        // 进行完整的diff算法
        console.log("进行完整的diff算法")
    }else if(newVNodeChildren.length > 0){
        // 这里表示老节点没有儿子,但是新节点有,需要遍历新节点的每一个儿子,放在新节点中
        mountChildren(el,newVNodeChildren)
    }else if(oldVNodeChildren.length > 0){
        // 这里表示新节点没有儿子,但是老节点有,需要吧老节点的儿子删除掉
        unMountChildren(el,oldVNodeChildren)
    }

    return el
}

function mountChildren(el,children){
    for (const child of children) {
        el.appendChild(createEle(child))
    }
}

function unMountChildren(el,children){
    // 直接删除老节点的子元素
    el.innerHTML = ""
}

实现完整的diff算法

这里我们来完成当旧节点和新节点都有子元素时,进行互相对比。

在Vue2中使用了双指针来进行子元素之间的对比,一个指针指向第一个节点,一个指针指向最后一个节点,比较一次后,首指针往后移动一位,当首指针大于尾指针时,比较结束

patch.js

import {isSameVNode} from "./index.js";

export function patch(oldVNode,newVNode){
    // 判断是否是一个真实元素,如果是真实DOM会返回1
    const isRealEle = oldVNode.nodeType;
    // 初次渲染
    if(isRealEle){
        // 获取真实元素
        const elm = oldVNode
        // 获取真实元素的父元素
        const parentElm = elm.parentNode
        // 创建新的虚拟节点
        let newRealEl = createEle(newVNode)
        // 把新的虚拟节点插入到真实元素后面
        parentElm.insertBefore(newRealEl,elm.nextSibling)
        // 然后删除之前的DOM
        parentElm.removeChild(elm)
    }else{
        patchVNode(oldVNode,newVNode)
    }
}

export function createEle(vnode){
    let {tag,data,children,text} = vnode
    if (typeof tag === "string"){
        vnode.el = document.createElement(tag)
        // 处理节点的属性
        patchProps(vnode.el,{},data)
        // 递归处理子元素
        children.forEach(child=>{
            child && vnode.el.appendChild(createEle(child))
        })
    }else{
        vnode.el = document.createTextNode(text)
    }
    return vnode.el
}

function patchProps(el,oldProps = {},props = {}){
    let oldPropsStyle = oldProps.style
    let newPropsStyle = props.style

    // 判断如果新节点上没有旧节点的样式,则应该吧原来的样式清空掉
    for (const key in oldPropsStyle) {
        if(!newPropsStyle[key]){
            el.style[key] = ""
        }
    }

    // 判断旧节点的属性在新节点是否存在
    for (const key in oldProps) {
        if(!props[key]){
            el.removeAttribute(key)
        }
    }

    for (const key in props) {
        if(key === "style"){
            Object.keys(props.style).forEach(sty=>{
                el.style[sty] = props.style[sty]
            })
        }else{
            el.setAttribute(key,props[key])
        }
    }
}

function patchVNode(oldVNode,newVNode){
    // 进行diff算法,对比新老节点
    // 对比两个节点的tag和key是否一样,如果不一样,则直接吧老的替换掉,换成新的节点
    if(!isSameVNode(oldVNode,newVNode)){
        console.log(oldVNode,'oldVNode')
        console.log(newVNode,'newVNode')
        let el = createEle(newVNode)
        oldVNode.el.parentNode.replaceChild(el,oldVNode.el)
        return el
    }
    // 一样的情况对DOM元素进行复用
    let el = newVNode.el = oldVNode.el
    // 如果一样,则还需要判断一下文本的情况
    if(!oldVNode.tag){
        if(oldVNode.el.text !== newVNode.text){
            oldVNode.el.text = newVNode.text
        }
    }
    // 比较新节点和旧节点的属性是否一致
    patchProps(el,oldVNode.data,newVNode.data)
    // 然后比较新旧节点的儿子节点
    let oldVNodeChildren = oldVNode.children || []
    let newVNodeChildren = newVNode.children || []

    if(oldVNodeChildren.length > 0 && newVNodeChildren.length > 0){
        // 声明两个指针,分别指向头节点和尾节点
        // 然后进行对比,当旧node的头节点和新node的头节点相同时,则进行头指针往后移动
        // 当头指针大于尾指针时停止循环
        let oldStartIndex = 0
        let oldEndIndex = oldVNodeChildren.length - 1
        let oldStartNode = oldVNodeChildren[0]
        let oldEndNode = oldVNodeChildren[oldEndIndex]

        let newStartIndex = 0
        let newEndIndex = newVNodeChildren.length - 1
        let newStartNode = newVNodeChildren[0]
        let newEndNode = newVNodeChildren[newEndIndex]

        // 添加一个映射表
        let nodeMap = {}
        oldVNodeChildren.forEach((child,index)=>{
            nodeMap[child.key] = index
        })

        while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex){
            if(!oldStartNode){
                oldStartNode = oldVNodeChildren[++oldStartIndex]
            }else if(!oldEndNode){
                oldEndNode = oldVNodeChildren[--oldEndIndex]
            }
            // 1.进行头头对比
            else if(isSameVNode(oldStartNode,newStartNode)){
                // 递归更新子节点
                patchVNode(oldStartNode,newStartNode)
                oldStartNode = oldVNodeChildren[++oldStartIndex]
                newStartNode = newVNodeChildren[++newStartIndex]
            }
            // 2.进行尾尾对比
            else if(isSameVNode(oldEndNode,newEndNode)){
                // 递归更新子节点
                patchVNode(oldEndNode,newEndNode)
                oldEndNode = oldVNodeChildren[--oldEndIndex]
                newEndNode = newVNodeChildren[--newEndIndex]
            }
            // 3.进行尾头
            else if(isSameVNode(oldEndNode,newStartNode)){
                patchVNode(oldEndNode,newStartNode)
                // 如果旧节点的尾节点和新节点的头节点相同,则吧把旧节点的尾节点放在头节点之前
                // 然后把旧的尾指针往前移动,新节点的头指针往后移动
                el.insertBefore(oldEndNode.el,oldStartNode.el)
                oldEndNode = oldVNodeChildren[--oldEndIndex]
                newStartNode = newVNodeChildren[++newStartIndex]
            }
            // 4.进行头尾对比
            else if(isSameVNode(oldStartNode,newEndNode)){
                patchVNode(oldStartNode,newEndNode)
                // 如果旧节点的头和新节点的尾相同,则吧旧节点的头节点放在尾节点后
                // 然后把旧的头指针往后移动,新节点的尾指针往前移动
                el.insertBefore(oldStartNode.el,oldEndNode.el.nextSibling)
                oldStartNode = oldVNodeChildren[++oldStartIndex]
                newEndNode = newVNodeChildren[--newEndIndex]
            }else{
                // 5.进行乱序查找
                let oldNodeIndex = nodeMap[newStartNode.key]
                if(oldNodeIndex !== undefined){
                    let moveNode = oldVNodeChildren[oldNodeIndex]
                    el.insertBefore(moveNode.el,oldStartNode.el)
                    oldVNodeChildren[oldNodeIndex] = undefined
                    patchVNode(moveNode,newStartNode)
                }else{
                    el.insertBefore(createEle(newStartNode),oldStartNode.el)
                }
                newStartNode = newVNodeChildren[++newStartIndex]
            }
        }

        // 循环结束后,如果新节点的头指针小于等于新节点的尾指针
        // 说明新节点是有多出来的内容,则要把新节点多出来的push到现有节点的后面
        if(newStartIndex <= newEndIndex){
            console.log("1")
            for (let i = newStartIndex; i <= newEndIndex; i++) {
                // 如果尾指针的下一个节点有值,说明是新节点的前面有多出来的节点
                // 需要吧新的节点插入到前面去
                let anchor = newVNodeChildren[newEndIndex + 1] ? newVNodeChildren[newEndIndex + 1].el : null
                // 吧新节点插入到anchor的前面
                el.insertBefore(createEle(newVNodeChildren[i]),anchor)
            }
        }
        // 如果旧节点的头指针小于等于旧节点的尾指针,则说明旧的有多的节点,需要删除掉
        if(oldStartIndex <= oldEndIndex){
            for (let i = oldStartIndex; i <= oldEndIndex; i++) {
               let chilEl = oldVNodeChildren[i]
               chilEl && el.removeChild(chilEl.el)
            }
        }
    }else if(newVNodeChildren.length > 0){
        // 这里表示老节点没有儿子,但是新节点有,需要遍历新节点的每一个儿子,放在新节点中
        mountChildren(el,newVNodeChildren)
    }else if(oldVNodeChildren.length > 0){
        // 这里表示新节点没有儿子,但是老节点有,需要吧老节点的儿子删除掉
        unMountChildren(el,oldVNodeChildren)
    }
    return el
}

function mountChildren(el,children){
    for (const child of children) {
        el.appendChild(createEle(child))
    }
}

function unMountChildren(el,children){
    // 直接删除老节点的子元素
    el.innerHTML = ""
}

测试一下,手动的编写两个虚拟节点进行比对

//----------测试diff算法---------------
let render1 = compilerToFunction(`<div style='color: red'>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</div>`)
let vm1 = new Vue({data:{name:"张三"}})
let prevVNode = render1.call(vm1)
let el = createEle(prevVNode)
document.body.appendChild(el)

let render2 = compilerToFunction(`<div style='background-color: blue;color: white'>
<li key="f">f</li>
<li key="e">e</li>
<li key="c">c</li>
<li key="n">n</li>
<li key="a">a</li>
<li key="m">m</li>
<li key="j">j</li>
</div>`)
let vm2 = new Vue({data:{name:"李四"}})
let newVNode = render2.call(vm2)

setTimeout(()=>{
    console.log(prevVNode)
    console.log(newVNode)
    patch(prevVNode,newVNode)
},1000)

image-20231209155828082

倒计时一秒后会自动变成新的

image-20231209155858400

但是我们肯定不能使用这种方式来实现页面的更新和diff,需要在修改完数据后,在update中进行新旧节点的diff

修改 lifecycle.js 文件中的 update 方法

// 更新视图方法
Vue.prototype._update = function (vnode){
    const vm = this
    const el = vm.$el
    const preVNode = vm._vnode
    vm._vnode = vnode
    if(preVNode){
        // 第二个渲染,用两个虚拟节点进行diff
        vm.$el = patch(preVNode,vnode)
    }else{
        // 第一次渲染页面,用虚拟节点直接覆盖真实DOM
        vm.$el = patch(el,vnode)
    }
}

查看效果

通过动画我们可以看到每次更新时只有里面的文字变化,其他元素并不会重新渲染

自定义组件实现原理

vue中可以声明自定义组件和全局组件,当自定义组件和全局组件重名时,会优先使用自定义组件。

在源码中,主要靠 Vue.extend 方法来实现

例如如下写法:

Vue.component("my-button",{
    template:"<button>全局的组件</button>"
})

let Sub = Vue.extend({
    template:"<button>子组件 <my-button></my-button></button>",
    components:{
        "my-button":{
            template:"<button>子组件自己声明的button</button>"
        }
    }
})

new Sub().$mount("#app")

页面展示的效果

image-20231209232538794

我们来实现这个源码

globalApi.js 文件中添加方法

import {mergeOptions} from "./utils.js";

export function initGlobalApi(Vue) {
    // 添加一个静态方法 mixin
    Vue.options = {
        // 添加一个属性,记录Vue实例
        _base:Vue
    }
    Vue.mixin = function (mixin) {
        this.options = mergeOptions(this.options, mixin)
        return this
    }

    Vue.extend = function (options){
        function Sub(options = {}){
            this._init(options)
        }
        Sub.prototype = Object.create(Vue.prototype)
        Sub.prototype.constructor = Sub
        Sub.options = mergeOptions(Vue.options,options)
        return Sub
    }

    Vue.options.components = {}
    Vue.component = function (id,options){
        options = typeof options === "function" ? options : Vue.extend(options)
        Vue.options.components[id] = options
    }
}

utils.js 文件中添加组件合并策略,实现先找自身声明的组件,找不到再去原型链上找全局声明的组件

// 定义一些策略,例如 created,beforeCreated 等,不需要写大量的if判断
const strats = {}
const LIFECYCLE = [
    "beforeCreated",
    "created"
]

LIFECYCLE.forEach(key => {
    strats[key] = function (p, c) {
        if (c) {
            if (p) {
                return p.concat(c)
            } else {
                return [c]
            }
        } else {
            return p
        }
    }
})

// 添加组件合并策略
strats.components = function (parentVal, childVal){
    const res = Object.create(parentVal)
    if(childVal){
        for (const key in childVal) {
            res[key] = childVal[key]
        }
    }
    return res
}

// 合并属性的方法
export function mergeOptions(parent, child) {
    const options = {}
    // 先获取父亲的值
    for (const key in parent) {
        mergeField(key)
    }
    for (const key in child) {
        // 如果父亲里面没有这个子属性,在进行合并子的
        /**
         * 示例:父亲:{a:1} 儿子:{a:2}
         *      儿子中也有父亲的属性a,所以不会走儿子中的合并方法,但是在取值的时候,优先取的是儿子身上的属性值
         *      所以合并到一个对象中时,儿子会覆盖父亲
         */
        if (!parent.hasOwnProperty(key)) {
            mergeField(key)
        }
    }

    function mergeField(key) {
        if (strats[key]) {
            // {created:fn} {}
            // 合并声明周期上的方法,例如:beforeCreated,created
            options[key] = strats[key](parent[key], child[key])
        } else {
            // 先拿到儿子的值
            options[key] = child[key] || parent[key]
        }
    }

    return options
}

这一步实现了组件按照原型链查找,通过打断点可以看到

image-20231209233451718

接着修改 src/vdom/index.js 文件,增加创建自定义组件的虚拟节点

// 判断是否是原生标签
let isReservedTag = (tag) => {
    return ["a", "div", "span", "button", "ul", "li", "h1", "h2", "h3", "h4", "h5", "h6", "p", "input", "img"].includes(tag)
}

// 创建虚拟节点
export function createElementVNode(vm, tag, prop, ...children) {
    if (!prop) {
        prop = {}
    }
    let key = prop.key
    if (key) {
        delete prop.key
    }
    if (isReservedTag(tag)) {
        return vnode(vm, tag, prop, key, children, undefined)
    } else {
        // 创建组件的虚拟节点
        return createTemplateVNode(vm, tag, prop, key, children)
    }
}

function createTemplateVNode(vm, tag, data, key, children) {
    let Core = vm.$options.components[tag]
    // 这里有两种情况,如果是自己组件本身定义的一个子组件,则拿到的直接是一个对象,里面有一个template
    // 否则会往原型链上找,找到的是通过 Vue.component 定义的组件,拿到的是一个Sub构造函数
    if (typeof Core === "object") {
        // 需要将对象变成Sub构造函数
        Core = vm.$options._base.extend(Core)
    }
    data.hook = {
        init() {
           
        }
    }
    return vnode(vm, tag, data, key, children = [], undefined, Core)
}


// 创建文本节点
export function createTextVNode(vm, text) {
    return vnode(vm, undefined, undefined, undefined, undefined, text)
}

function vnode(vm, tag, data, key, children = [], text, componentsOptions) {
    children = children.filter(Boolean)
    return {
        vm,
        tag,
        data,
        key,
        children,
        text,
        componentsOptions
    }
}

// 判断两个节点是否一致
export function isSameVNode(oldVNode, newVNode) {
    // 对比两个节点的tag和key是否都一样,如果都一样,就认为这两个节点是一样的
    return oldVNode.tag === newVNode.tag && oldVNode.key === newVNode.key
}

实现组件渲染功能

上面我们根据tag判断是否是一个组件,并且添加了一个 createTemplateVNode 方法,返回组件的虚拟节点vnode。

然后需要在 src/vdom/patch.js 文件的 createEle 生成真实节点的方法中添加判断,是否是虚拟节点

function createComponent(vnode){
    let i = vnode.data
    if((i=i.hook) && (i=i.init)){
        i(vnode)
    }
    if(vnode.componentsInstance){
        return true
    }
}

export function createEle(vnode){
    let {tag,data,children,text} = vnode
    if (typeof tag === "string"){
        // 判断是否是组件
        if(createComponent(vnode)){
            return vnode.componentsInstance.$el
        }
        vnode.el = document.createElement(tag)
        // 处理节点的属性
        patchProps(vnode.el,{},data)
        // 递归处理子元素
        children.forEach(child=>{
            child && vnode.el.appendChild(createEle(child))
        })
    }else{
        vnode.el = document.createTextNode(text)
    }
    return vnode.el
}

在 createComponent 方法中就会去调用在上面 createTemplateVNode 方法中定义的 init 方法,并把当前的 vnode 传递过去

这时需要在init方法中接收这个vnode,并去new 这个 vnode 的 componentsOptions 中的 Core,这里的Core也就是 Vue.extend

修改 src/vdom/index.js 中的 createTemplateVNode 方法

function createTemplateVNode(vm, tag, data, key, children) {
    // 从全局中的component中获取对应的组件,应为之前已经合并过了,所以这里可以直接获取
    let Core = vm.$options.components[tag]
    // 这里有两种情况,如果是自己组件本身定义的一个子组件,则拿到的直接是一个对象,里面有一个template
    // 否则会往原型链上找,找到的是通过 Vue.component 定义的组件,拿到的是一个Sub构造函数
    if (typeof Core === "object") {
        // 需要将对象变成Sub构造函数
        Core = vm.$options._base.extend(Core)
    }
    data.hook = {
        init(vnode) {
            // 从返回的vnode上获取componentsOptions中的Core
            let instance = vnode.componentsInstance = new vnode.componentsOptions.Core
            instance.$mount()
        }
    }
    return vnode(vm, tag, data, key, children = [], undefined, {Core})
}

new 完 Core 后返回的实例同时赋值给当前vnode的componentsInstance上和局部变量instance

然后使用 instance 再去调用 $mount 方法,会触发 patch 方法,但是这里并没有传递参数,所以就需要在 patch 方法中添加一个判断,如果没有旧节点,直接创建新的节点并返回

export function patch(oldVNode,newVNode){
+    if(!oldVNode){
+        return createEle(newVNode)
+    }
    // 判断是否是一个真实元素,如果是真实DOM会返回1
    const isRealEle = oldVNode.nodeType;
    // 初次渲染
    if(isRealEle){
        // 获取真实元素
        const elm = oldVNode
        // 获取真实元素的父元素
        const parentElm = elm.parentNode
        // 创建新的虚拟节点
        let newRealEl = createEle(newVNode)
        // 把新的虚拟节点插入到真实元素后面
        parentElm.insertBefore(newRealEl,elm.nextSibling)
        // 然后删除之前的DOM
        parentElm.removeChild(elm)
        // 返回渲染后的虚拟节点
        return newRealEl
    }else{
        return patchVNode(oldVNode,newVNode)
    }
}

这是用我们自己的vue.js来看一下实现的效果

<body>
    <div id="app">
        <ul>
            <li>{{age}}</li>
            <li>{{name}}</li>
        </ul>
        <button onclick="updateAge()">更新</button>
    </div>
</body>

<script src="./vue.js"></script>
Vue.component("my-button",{
    template:"<button>全局的组件</button>"
})

let Sub = Vue.extend({
    template:"<button>子组件 <my-button></my-button></button>",
    components:{
        "my-button":{
            template:"<button>子组件自己声明的button</button>"
        }
    }
})

new Sub().$mount("#app")

image-20231213221850414

总结:

  • 创建子类构造函数的时候,会将全局的组件和自己身上定义的组件进行合并。(组件的合并,会优先查找自己身上的,找不到再去找全局的)
  • 组件的渲染:开始渲染的时候组件会编译组件的模板(template 属性对应的 html)变成render函数,然后调用 render函数
  • createrElementVnode 会根据 tag 类型判断是否是自定义组件,如果是组件会创造出组件对应的虚拟节点(给组件增加一个初始化钩子,增加componentOptions选项 { Core })
  • 然后再创建组件的真实节点时。需要 new Core,然后使用返回的实例在去调用 $mount() 方法就可以完成组件的挂载

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/267824.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

react 路由v6

这里是区别&#xff1a;V5 vs V6 这里是官网&#xff1a;可以查看更多高级属性 一、基本使用&#xff1a; 1、配置文件 src/routes/index import React from "react";const Home React.lazy(() > import("../Pages/Home")); const About React.laz…

探索 HTTP 请求的世界:get 和 post 的奥秘(上)

&#x1f90d; 前端开发工程师&#xff08;主业&#xff09;、技术博主&#xff08;副业&#xff09;、已过CET6 &#x1f368; 阿珊和她的猫_CSDN个人主页 &#x1f560; 牛客高级专题作者、在牛客打造高质量专栏《前端面试必备》 &#x1f35a; 蓝桥云课签约作者、已在蓝桥云…

html之如何设置音频和视频

文章目录 前言一、音频标签&#xff1a;audio1.audio简介2.常用属性controlsautoplayloop代码演示&#xff1a; 二、视频标签&#xff1a;video1.video2.常用的视频元素controlsautoplayloop代码演示&#xff1a; 总结视频元素总结音频元素总结 前言 html中插入音频和视频的方…

超维空间S2无人机使用说明书——51、使用yolov8进行目标跟踪

引言&#xff1a;为了提高yolo识别的质量&#xff0c;提高了yolo的版本&#xff0c;改用yolov8进行物体识别&#xff0c;同时系统兼容了低版本的yolo&#xff0c;包括基于C的yolov3和yolov4&#xff0c;以及yolov7。 简介&#xff0c;为了提高识别速度&#xff0c;系统采用了G…

14章总结

一.lambda表达式 1.lambda表达式简介 lambda表达式不能独立执行&#xff0c;因此必须实现函数式接口&#xff0c;并且会返回一个函数式接口的对象。 语法&#xff1a; ()->结果表达式 参数->结果表达式 (参数1&#xff0c;参数2&#xff0c;...&#xff0c;参数n)->…

老鹰目标检测数据集VOC格式60张

老鹰是天空中的王者&#xff0c;它们拥有极佳的飞行能力。它们能以惊人的速度在天空中翱翔&#xff0c;尤其擅长高空俯冲捕食。老鹰的视力非常敏锐&#xff0c;能够准确地发现地面上的猎物&#xff0c;并迅速下落抓取。它们的爪子强而有力&#xff0c;足以击倒比自己体型庞大的…

顶级旗舰ET9出道,蔚来还是那个「最不计成本」的中国车品牌

作者 |张祥威 编辑 |德新 2008年&#xff0c;李斌和新浪的曹国伟几人一起喝酒&#xff0c;发了第一条微博&#xff0c;「天冷带围巾&#xff0c;心冷发微博」&#xff0c;一晚上涨了2000多个粉丝&#xff0c;他偶尔还会针砭时事&#xff0c;很快积累了最早一波粉丝。 创立蔚来…

各种边缘检测算子的比较研究

边缘检测算子比较研究 文章目录 边缘检测算子比较研究一、引言1.1 边缘检测的重要性1.2 研究背景与意义1.3 研究目的和论文结构 二、文献综述2.1 边缘检测概述2.2 Roberts、Prewitt、Sobel、Laplacian 和 Canny 算子的理论基础和历史2.2.1 **Roberts算子&#xff1a;**2.2.2 **…

全部没有问题 (一.5)

java mooc练习 基础练习&#xff1a; 进阶练习&#xff1a; final 赋值一次 局部 必须赋值 抽象类 多态测试 package com.book;public class moocDraft1 {static int variable1;public void fatherMethod(moocDraft1 a){System.out.println(variable);}public static void…

leetcode——背包问题汇总

本章来汇总一下leetcode中做过的背包问题&#xff0c;包括0-1背包和完全背包。 背包问题的通常形式为&#xff1a;有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i]&#xff0c;得到的价值是value[i] 。求解将哪些物品装入背包里物品价值总和最大。0-1背包和…

1981-2020年全国各省银行金融机构分布数据、银行金融机构数据

1981-2020年全国各省银行金融机构分布数据/银行金融机构数据 1、时间&#xff1a;1981-2020年 2、指标&#xff1a;统计年度、地区代码、地区名称、金融机构分类代码、金融机构分类名称、营业网点机构个数、营业网点就业人数、营业网点资产总额、法人机构数目、每万人拥有的网…

嵌入式软件工程师常用的

最近我换工作了&#xff0c;看见不同嵌入式软件工程师用的平台都不一样&#xff0c;所以我整理了一下。 PlatformIO: 多平台支持&#xff1a; PlatformIO支持多种嵌入式平台&#xff0c;包括Arduino、ESP8266、ESP32、STM32等&#xff0c;通过一致的开发接口实现平台无关性。 内…

使用ClickHouse UDF与OpenAI模型集成

本文字数&#xff1a;14683&#xff1b;估计阅读时间&#xff1a;37 分钟 作者&#xff1a;Dale McDiarmid 审校&#xff1a;庄晓东&#xff08;魏庄&#xff09; 本文在公众号【ClickHouseInc】首发 Meetup活动 ClickHouse Shenzhen User Group第1届 Meetup 火热报名中&#x…

计算机提示找不到vcruntime140.dll,无法继续执行代码怎么办?如何修复

“找不到vcruntime140.dll&#xff0c;无法继续执行代码”。这个问题可能会让你感到困惑&#xff0c;不知道如何解决。那么&#xff0c;vcruntime140.dll是什么文件&#xff1f;它为什么会丢失&#xff1f;又该如何解决这个问题呢&#xff1f;本文将为你详细介绍vcruntime140.d…

教师未来前景发展

教师是一个光荣而重要的职业&#xff0c;他们承担着培养下一代的责任和使命。随着社会的不断发展和变化&#xff0c;教师的前景也在不断扩大和改变。本文将探讨教师未来的前景发展&#xff0c;并提供一些思考和建议。 首先&#xff0c;教师的就业前景将继续扩大。随着人口的增长…

自定义Springboot项目启动横幅⭐️ 附平平淡淡的周末日常

2023/12/24 天气晴 温度适宜 一觉睡到九点半&#xff0c;谁是神仙&#xff0c;我是神仙日常三联&#xff0c;喂鸡&#xff0c;刷博&#xff0c;肝任务今阳光甚好&#xff0c;遂寻吾之莆田&#xff0c;翻其面&#xff0c;光得以入之&#xff0c;余卧炕&#xff0…

单片机原理及应用

一、任务说明 1.主要任务 本实践环节“51单片机商用电子计价秤设计”要求收集市场电子秤的应用场景的功能列表&#xff0c;给出本系统各功能的参数范围&#xff0c;分析质量检测功能的实现方法&#xff0c;设计单片机仿真系统并通过Proteus进行测试&#xff0c;电子秤是利用物…

注意:国内发生多起Oracle 勒索病毒!

摘要&#xff1a;近期&#xff0c;国内发生多起针对Oracle 数据库的勒索病毒案例&#xff0c;通过分析&#xff0c;该勒索病毒通过网络流传的“PL/SQLDeveloper破解版”进行传播。 1.病毒发起的原因及问题现象 近期&#xff0c;国内发生多起针对Oracle 数据库的勒索病毒案例&…

池化层(pooling)

目录 一、池化层 1、最大池化层 2、平均池化层 3、总结 二、代码实现 1、最大池化与平均池化 2、填充和步幅(padding和strides) 3、多个通道 4、总结 一、池化层 1、最大池化层 2、平均池化层 3、总结 池化层返回窗口中最大或平均值环节卷积层对位置的敏感性同样有窗口…

每日一题——LeetCode888

方法一 个人方法&#xff1a; 交换后要达到相同的数量&#xff0c;那么意味着这个相同的数量就是两个人总数的平均值&#xff0c;假设A总共有4个&#xff0c;B总共有8个&#xff0c;那么最后两个人都要达到6个&#xff0c;如果A的第一盒糖果只有1个&#xff0c;那么B就要给出6…
最新文章