手写Vue2源码

2023-12-24 17:35:19

手写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() 方法就可以完成组件的挂载

文章来源:https://blog.csdn.net/SongZhengxing_/article/details/135183341
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。