当前位置:首页 > 随笔 > 正文内容

Vue.js核心原理解析

访客 随笔 2026年6月8日 1

使用Vue.js一段时间后,对其底层原理有了更深入的理解,本文将总结Vue.js的核心实现机制。

Vue.js包含两个核心功能:响应式数据绑定和组件系统。大多数MVC框架实现了单向数据绑定,而双向绑定则是在单向绑定的基础上,为可输入元素添加change事件,从而动态更新model和view。

1. MVC、MVP与MVVM架构模式

1.1 MVC模式

MVC模式将软件系统分为三个核心部分:

1.视图(View):用户界面呈现 2.控制器(Controller):业务逻辑处理 3.模型(Model):数据存储与管理

各组件间的通信流程如下:

1.视图向控制器发送用户指令 2.控制器完成业务逻辑后请求模型更新状态 3.模型将新数据发送到视图,界面相应更新

所有通信均为单向流动。接收用户指令时,MVC有两种方式:通过视图传递指令给控制器,或用户直接向控制器发送指令。

实际应用中可能更加灵活,以Backbone.js为例:

1.用户可向视图(View)发送指令(DOM事件),再由View直接请求Model改变状态。 2.用户也可向Controller发送指令(改变URL触发hashChange事件),再由Controller传递给View。 3.Controller层较薄主要起路由作用而View层较厚业务逻辑集中在View。因此Backbone简化了Controller,只保留Router(路由器)。

MVC模式体现了"关注点分离"的设计原则,将人机交互应用的功能分为三部分:Model对应应用状态和业务功能的封装,可理解为包含数据和行为的领域模型;View负责界面呈现和用户交互;Controller作为Model和View之间的连接器,控制应用流程。

1.2 MVP模式

MVP模式将Controller重命名为Presenter,并改变了通信方向:

1.各组件间通信变为双向。 2.视图(View)与模型(Model)不直接交互,所有通信通过**表现器(Presenter)**进行。 3.View层较薄,不包含业务逻辑,称为被动视图(Passive View),而Presenter层较厚,包含所有逻辑。

MVP适用于事件驱动的应用架构,如ASP.NET Web Form、Windows Forms应用。

1.3 MVVM模式

MVVM模式将Presenter层替换为ViewModel,其他方面与MVP基本一致。其与MVP的关键区别是采用双向绑定视图层(View)的变更自动反映在ViewModel,反之亦然。Angular、Vue和React均采用这种方式。

MVVM源于WPF,主要用于分离应用界面层和业务逻辑层。在MVVM中,一个ViewModel与一个View匹配,完全绑定,View中的任何修改都会更新到ViewModel,同时ViewModel的任何变化都会同步到View。这种自动同步的实现依赖于ViewModel中的属性实现了observable接口,即当使用属性的set方法时,会触发属性修改事件,使绑定的UI自动刷新。

2. JavaScript访问器属性

访问器属性是一种特殊属性,不能直接在对象中定义,必须通过Object.defineProperty()方法定义。

Object.defineProperty()方法直接在对象上定义或修改属性,并返回该对象。该方法允许精确添加或修改对象的属性。通过赋值操作添加的普通属性是可枚举的,而使用Object.defineProperty()添加的属性默认不可修改。

该方法原型如下:

Object.defineProperty(obj, prop, descriptor)

参数说明:

  • obj: 要在其上定义属性的对象
  • prop: 要定义或修改的属性名
  • descriptor: 将被定义或修改的属性描述符

对象属性描述符分为两类:数据描述符存取描述符。数据描述符是一个具有值的属性,存取描述符则由getter和setter函数描述。描述符必须是这两类之一,不能同时兼具。

数据描述符存取描述符共有以下可选键值:

  1. configurable: 当且仅当该属性的configurable为true时,该属性描述符才能被修改,同时该属性也能从对象上删除。默认为false。
  2. enumerable: 当且仅当该属性的enumerable为true时,该属性才能出现在对象的枚举属性中。默认为false。

数据描述符额外具有以下可选键值:

  1. value: 该属性对应的值。可以是任何有效的JavaScript值。默认为undefined。
  2. writable: 当且仅当该属性的writable为true时,value才能被赋值运算符改变。默认为false。

存取描述符额外具有以下可选键值:

  1. get: 一个提供属性getter的方法,访问属性时执行。默认为undefined。
  2. set: 一个提供属性setter的方法,属性值被修改时触发。默认为undefined。

如果一个描述符没有value、writable、get或set中的任何一个,它将被视为数据描述符。如果一个描述符同时包含(value或writable)和(get或set),将抛出异常。

2.1 定义访问器属性

以下示例展示如何使用Object.defineProperty()定义访问器属性:

var obj = {};
Object.defineProperty(obj, 'hello', {
    get: function() {
        console.log('get方法被调用');
    },
    set: function(v) {
        console.log("set方法被调用了,参数是" + v);
    }
});
obj.hello; // get方法被调用
obj.hello = 'abc'; // set方法被调用了,参数是abc

访问器属性可以像普通属性一样读取和设置,实际上是通过内部get和set方法操作属性。为属性赋值就是调用set方法并使用参数赋值。get和set方法内部的this指针指向对象本身,意味着它们可以操作对象内部的值。访问器属性会覆盖同名的普通属性,因为访问器属性优先级更高。

2.2 实现自存档对象

以下示例展示如何实现一个自存档对象,当设置temperature属性时,archive数组会记录日志:

function Archiver() {
    var temperature = null;
    var archive = [];
    Object.defineProperty(this, 'temperature', {
        get: function() {
            console.log('get!');
            return temperature;
        },
        set: function(value) {
            temperature = value;
            archive.push({val: temperature});
        }
    });
    this.getArchive = function() {
        return archive;
    };
}

var arc = new Archiver();
console.log(arc.temperature); // 输出get,但arc.temperature为null
arc.temperature = 11; // 触发archive.push({val: 11})
arc.temperature = 13; // 触发archive.push({val: 13})
console.log(arc.getArchive()); // 输出[{val: 11}, {val: 13}]

3. Vue.js双向绑定实现

3.1 基本双向绑定

Vue.js最重要的概念是数据双向绑定,也是MVVM的主要特点。以下是一个极简实现:

HTML代码:

<input type="text" id="a">
<span id="b"></span>

JavaScript代码:

var obj = {};
Object.defineProperty(obj, 'hello', {
    set: function(newVal) {
        document.getElementById('a').value = newVal;
        document.getElementById('b').innerHTML = newVal;
    }
});
document.addEventListener('keyup', function(e) {
    obj.hello = e.target.value;
});

这个实现在文本框中输入的值会显示在旁边的标签中。为了实现更接近Vue.js的写法,我们通常使用以下方式:

HTML代码:

<input type="text" v-model="text">
{{ text }}

JavaScript代码:

var vm = new Vue({
    el: 'app',
    data: {
        text: 'hello world'
    }
});

Vue.js背后做了大量工作来实现这种简洁的写法,主要包括:

  1. 输入框及文本节点与data中的数据绑定显示
  2. 输入框变化时,data中的数据同步变化(MVVM中view=>viewmodel)
  3. data中数据变化时,文本节点显示内容同步更新(MVVM中viewmodel=>view)

3.2 数据初始化绑定

在实现数据初始化绑定前,需要了解DocumentFragment。DocumentFragment(文档片段)可视为节点容器,包含多个子节点,插入DOM时只有其子节点会被插入目标节点,可视为一组节点容器。使用DocumentFragment处理节点速度和性能优于直接操作DOM。Vue进行编译时,会将挂载目标的所有子节点劫持到DocumentFragment中,处理后再将DocumentFragment整体返回到挂载目标。

示例代码:

var dom = nodeToFragment(document.getElementById("app"));
console.log(dom);

function nodeToFragment(node, vm) {
    var flag = document.createDocumentFragment();
    var child;
    while (child = node.firstChild) {
        flag.appendChild(child); // 劫持node的所有节点
    }
    return flag;
}
document.getElementById("app").appendChild(dom);

HTML代码:

<div id="app">
    <input type="text" v-model="text">
    {{text}}
</div>

JavaScript代码:

function compile(node, vm) {
    var reg = /\{\{(.*)\}\}/;
    // 节点类型为元素
    if (node.nodeType === 1) {
        var attr = node.attributes;
        // 解析属性
        for (var i = 0; i < attr.length; i++) {
            if (attr[i].nodeName === 'v-model') {
                var name = attr[i].nodeValue; // 获取v-model绑定的属性名
                node.value = vm.data[name]; // 将data的值赋给该node
                node.removeAttribute('v-model');
            }
        }
    }
    // 节点类型为文本
    if (node.nodeType === 3) {
        if (reg.test(node.nodeValue)) {
            var name = RegExp.$1; // 获取匹配到的字符串
            name = name.trim();
            node.nodeValue = vm.data[name]; // 将该data的值赋给该node
        }
    }
}

function nodeToFragment(node, vm) {
    var flag = document.createDocumentFragment();
    var child;
    while (child = node.firstChild) {
        compile(child, vm);
        // 将子节点劫持到文档片段中
        flag.appendChild(child);
    }
    return flag;
}

// 构造函数
function Vue(options) {
    this.data = options.data;
    var id = options.el;
    var dom = nodeToFragment(document.getElementById(id), this);
    // 编译完成后把dom返回到app中
    document.getElementById(id).appendChild(dom);
}

var vm = new Vue({
    el: 'app',
    data: {
        text: 'hello world'
    }
});

3.3 响应式数据绑定

初始化绑定仅实现了第一步,接下来需要实现文本框中输入内容时,Vue实例中的属性值也跟着变化。思路是在文本框输入数据时触发input事件(也可用keyup或change),在事件处理程序中获取输入内容赋值给Vue实例vm的text属性。这里利用Object.defineProperty()方法给Vue实例中data的属性重新定义为访问器属性,添加get和set存取描述符,这样给vm.text赋值时就会触发set方法,在set方法中更新Vue实例属性的值。

HTML代码:

<div id="app">
    <input type="text" v-model="text"/>
    {{ text }}
</div>

JavaScript代码:

/**
 * 使用defineProperty将data中的text设置为vm的访问器属性
 * @param obj 对象
 * @param key 属性名
 * @param val 属性值
 */
function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        get: function() {
            return val;
        },
        set: function(newVal) {
            if (newVal === val) return;
            val = newVal;
            // 输出日志
            console.log(`set方法触发属性值变化${val}`);
        }
    });
}

/**
 * 给vue实例定义访问器属性
 * @param obj vue实例中的数据
 * @param vm vue对象
 */
function observe(obj, vm) {
    Object.keys(obj).forEach(function(key) {
        defineReactive(vm, key, obj[key]);
    });
}

/**
 * 编译过程,给子节点初始化绑定vue实例中的属性值
 * @param node 子节点
 * @param vm vue实例
 */
function compile(node, vm) {
    let reg = /\{\{(.*)\}\}/;
    // 节点类型为元素
    if (node.nodeType === 1) {
        let attr = node.attributes;
        // 解析属性
        for (let i = 0; i < attr.length; i++) {
            if (attr[i].nodeName === 'v-model') {
                // 获取v-model绑定的属性名
                let name = attr[i].nodeValue;
                // 添加监听事件
                node.addEventListener('input', function(e) {
                    // 给相应的data属性赋值,进而触发该属性的set方法
                    vm[name] = e.target.value;
                });
                // 将data的值赋给该node
                node.value = vm.data[name];
                node.removeAttribute('v-model');
            }
        }
    }
    // 节点类型为文本
    if (node.nodeType === 3) {
        if (reg.test(node.nodeValue)) {
            // 使用正则表达式获取匹配到的字符串
            let name = RegExp.$1;
            name = name.trim();
            // 将data的值赋给该node.nodeValue
            node.nodeValue = vm.data[name];
        }
    }
}

/**
 * DocumentFragment文档片段,可以看作节点容器
 * @param node 节点
 * @param vm vue实例
 */
function nodeToFragment(node, vm) {
    var flag = document.createDocumentFragment();
    var child;
    while (child = node.firstChild) {
        compile(child, vm);
        flag.appendChild(child);
    }
    return flag;
}

/*vue类*/
function Vue(options) {
    this.data = options.data;
    let data = this.data;
    // 给vue实例的data定义访问器属性,覆盖原来的同名属性
    observe(data, this);
    let id = options.el;
    let dom = nodeToFragment(document.getElementById(id), this);
    // 编译,劫持完成后将dom返回到app中
    document.getElementById(id).appendChild(dom);
}

/*定义一个vue实例*/
let vm = new Vue({
    el: 'app',
    data: {
        text: 'hello world!'
    }
});

set方法被触发后,Vue实例的text属性跟着变化,但内容没有更新。接下来使用"订阅/发布模式"解决这个问题。

3.4 订阅/发布模式

订阅/发布模式(观察者模式)定义一对多关系,让多个观察者同时监听一个主题对象,主题对象状态改变时所有观察者都会收到通知。

发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操作

示例代码:

/**
 * 定义一个发布者publisher
 */
var pub = {
    publish: function() {
        dep.notify();
    }
}

/**
 * 三个订阅者
 */
var sub1 = {
    update: function() {
        console.log(1);
    }
};
var sub2 = {
    update: function() {
        console.log(2);
    }
};
var sub3 = {
    update: function() {
        console.log(3);
    }
}

/**
 * 一个主题对象
 */
function Dep() {
    this.subs = [sub1, sub2, sub3];
}
Dep.prototype.notify = function() {
    this.subs.forEach(function(sub) {
        sub.update();
    });
}

// 发布者发布消息,主题对象执行notify方法,触发所有订阅者响应
var dep = new Dep();
pub.publish();

3.5 完整双向绑定实现

实现完整双向绑定的关键是利用订阅/发布模式,将Watcher添加到关联属性的dep中。

HTML代码:

<div id="app">
    <input type="text" v-model="text">
    {{ text }}
</div>

JavaScript代码:

/**
 * 使用defineProperty将data中的text设置为vm的访问器属性
 * @param obj 对象
 * @param key 属性名
 * @param val 属性值
 */
function defineReactive(obj, key, val) {
    // 发布者对象
    var dep = new Dep();
    Object.defineProperty(obj, key, {
        get: function() {
            // 依赖收集
            if (Dep.target) dep.addSub(Dep.target);
            return val;
        },
        set: function(newVal) {
            if (newVal === val) return;
            val = newVal;
            // 通知变更
            dep.notify();
        }
    });
}

/**
 * 给vue实例定义访问器属性
 * @param obj vue实例中的数据
 * @param vm vue对象
 */
function observe(obj, vm) {
    Object.keys(obj).forEach(function(key) {
        defineReactive(vm, key, obj[key]);
    });
}

/**
 * DocumentFragment文档片段
 * @param node 节点
 * @param vm vue实例
 */
function nodeToFragment(node, vm) {
    var flag = document.createDocumentFragment();
    var child;
    while (child = node.firstChild) {
        compile(child, vm);
        flag.appendChild(child);
    }
    return flag;
}

/**
 * 编译过程,给子节点初始化绑定vue实例中的属性值
 * @param node 子节点
 * @param vm vue实例
 */
function compile(node, vm) {
    var reg = /\{\{(.*)\}\}/;
    // 节点类型为元素
    if (node.nodeType === 1) {
        var attr = node.attributes;
        // 解析属性
        for (var i = 0; i < attr.length; i++) {
            if (attr[i].nodeName === 'v-model') {
                var name = attr[i].nodeValue;
                node.addEventListener('input', function(e) {
                    vm[name] = e.target.value;
                });
                node.value = vm[name];
                node.removeAttribute('v-model');
            }
        }
    }
    // 节点类型为文本
    if (node.nodeType === 3) {
        if (reg.test(node.nodeValue)) {
            var name = RegExp.$1;
            name = name.trim();
            new Watcher(vm, node, name, 'text');
        }
    }
}

/**
 * 观察者
 * @param vm vue实例
 * @param node 节点
 * @param name 属性名
 * @param nodeType 节点类型
 * @constructor
 */
function Watcher(vm, node, name, nodeType) {
    // 将当前对象赋值给全局变量Dep.target
    Dep.target = this;
    this.name = name;
    this.node = node;
    this.vm = vm;
    this.nodeType = nodeType;
    // 更新
    this.update();
    // 设置为空,避免重复添加订阅者
    Dep.target = null;
}

Watcher.prototype = {
    update: function() {
        this.get();
        if (this.nodeType === 'text') {
            this.node.nodeValue = this.value;
        }
        if (this.nodeType === 'input') {
            this.node.value = this.value;
        }
    },
    get: function() {
        this.value = this.vm[this.name];
    }
}

/**
 * 主题对象
 * @constructor
 */
function Dep() {
    this.subs = [];
}

Dep.prototype = {
    // 添加订阅者
    addSub: function(sub) {
        this.subs.push(sub);
    },
    // 通知变化
    notify: function() {
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
};

/**
 * Vue构造函数
 * @param options Vue参数选项
 * @constructor
 */
function Vue(options) {
    this.data = options.data;
    var data = this.data;
    observe(data, this);
    var id = options.el;
    var dom = nodeToFragment(document.getElementById(id), this);
    document.getElementById(id).appendChild(dom);
}

// 定义Vue实例
var vm = new Vue({
    el: 'app',
    data: {
        text: 'hello world'
    }
});

4. 总结

本文介绍了Vue.js的核心实现原理及相关知识,包括MVC、MVP、MVVM架构模式,JavaScript访问器属性,DocumentFragment文档片段,以及观察者模式(订阅/发布模式)。Vue.js的实现主要涉及数据编译(compilation)、通过DocumentFragment实现数据劫持挂载,以及通过观察者模式实现数据双向绑定等核心机制。

相关文章

可以按小时收费的VPS

很多 VPS 提供商都支持 按小时计费(hourly billing),想短期试用 / 临时搭建节点、测试网络、短期项目等场景非常合适。下面是当前最主流且靠谱的按小时 VPS 选项,分别按不同需求场景整理: 1. Vultr(全球节点,包括日本) 按小时计费 可选机房:东京 / 大阪 / 洛杉矶 / 法兰克福 / 伦敦 … 支持 PayPal(部分情况),但更常用信用卡/PayPal+卡价格参考$...

在 iPhone 上下载国外App

地区/国家限制App Store 会根据 Apple ID 的国家或地区限制应用下载。如果你的 Apple ID 绑定的是中国大陆,就可能无法下载 OpenAI 官方的 ChatGPT 应用,因为它在大陆 App Store 不上架。解决办法:换成美国、加拿大、香港等地区的 Apple ID。或者在现有 Apple ID 上更改地区。注册一个国外 Apple ID(推荐)比如注册 美国区 Appl...

Node.js 中的异步编程:回调与 Promise

Node.js 是一个基于 JavaScript 构建的单线程、非阻塞运行环境,它通过异步编程机制来高效处理多个操作。在执行如文件读取、API 请求或数据库查询等任务时,Node.js 不会等待这些操作完成,而是使用回调函数和 Promise 来避免阻塞主线程。 回调方式实现异步 那么当异步操作完成后,Node.js 如何知道接下来要做什么呢?这就要用到 回调函数(callback)。 回调本质上...

Selenium自动化测试入门指南

Selenium自动化测试入门指南

什么是自动化测试? 自动化测试是指利用软件工具自动执行测试用例,模拟用户操作,如打开网页、点击链接、输入文本等,并验证结果是否符合预期。 其主要优点包括: 大幅减少人工成本 测试速度快 可以在非工作时间运行 支持持续集成和交付 然而,它也存在一些局限性,例如开发成本较高、不适合快速变化的项目、依赖稳定的UI界面等。 自动化测试的应用条件 适合引入自动化测试的情况包括: 手动测试耗时且需要大量...

MariaDB Galera集群故障快速恢复指南

OpenStack控制节点采用三节点MariaDB Galera集群架构。当数据库集群因故障重启时,有时会出现Galera集群无法正常启动的问题。虽然有多种方法可以恢复数据库服务,但如何实现快速启动同时确保数据完整性呢? 通过分析日志发现,MariaDB Galera集群节点宕机时会在日志中输出以下信息: [Note] WSREP: 新集群视图:全局状态: 874d8e7e-5980-11e8-8...

Android 中 EventBus 的通信机制与实现原理深度解析

EventBus 核心设计思想 EventBus 是一个基于观察者模式的事件总线框架,广泛应用于 Android 平台以实现组件解耦。它通过中心化的消息分发机制,使不同层级、不同线程的对象能够以"发布-订阅"方式通信,避免了传统接口回调或广播带来的强依赖问题。 核心角色说明 事件(Event):任意 Java 对象,作为数据载体,如网络状态变更通知、用户登录信息等。 发布者(Publi...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。