本文基于鸿蒙系统源码,逐行解析其 JavaScript 框架的实现原理。文章详细阐述了框架如何构建响应式系统,利用观察者模式实现数据与视图的双向绑定,并深入分析了对象与数组的属性劫持机制及脏检查策略。
鸿蒙 JS 框架概述
此前曾介绍过鸿蒙平台的 JavaScript 框架,近期成功编译了 JS 仓库,期间解决了诸多问题并提交了多个 PR。本文将对该框架的代码进行逐行深入剖析。
文中引用的代码均源自鸿蒙最新代码版本(提交哈希 677ed06,日期 2020-09-10)。
鸿蒙系统采用 JavaScript 开发图形界面,其模式类似于微信小程序或轻应用。在该 MVVM 架构中,视图层(V)由 C++ 实现,而 JavaScript 代码仅负责 ViewModel 层的功能。
鸿蒙 JS 框架本身不依赖外部库,仅在开发打包阶段使用 npm 包,最终生成的代码无任何 npm 依赖。下面先展示基于该框架编写的 JS 代码示例。
export default {
data: {
return { count: 1 };
},
increase() {
++this.count;
},
decrease() {
--this.count;
},
}
若不指明平台,这段代码极易被误认为是 Vue 或小程序代码。若脱离鸿蒙系统单独使用,JS 代码结构如下:
const vm = new ViewModel({
data() {
return { count: 1 };
},
increase() {
++this.count;
},
decrease() {
--this.count;
},
});
console.log(vm.count); // 1
vm.increase();
console.log(vm.count); // 2
vm.decrease();
console.log(vm.count); // 1
仓库内的所有 JS 代码共同构建了一个响应式系统,在 MVVM 架构中充当 ViewModel 的角色。
接下来我们将逐行解析代码逻辑。
源码结构与入口分析
src 目录包含 4 个子目录,共 8 个文件。其中包含 1 个单元测试文件和 1 个性能分析文件。剔除 2 个 index.js 入口文件后,核心代码仅剩 4 个文件,这也是本文的分析重点。
src
├── __test__
│ └── index.test.js
├── core
│ └── index.js
├── index.js
├── observer
│ ├── index.js
│ ├── observer.js
│ ├── subject.js
│ └── utils.js
└── profiler
└── index.js
首先是入口文件 src/index.js,代码仅两行:
import { ViewModel } from './core';
export default ViewModel;
其作用仅为重新导出模块。
另一个类似文件 src/observer/index.js 同样只有两行代码:
export { Observer } from './observer';
export { Subject } from './subject';
observer 和 subject 共同实现了观察者模式。subject 代表主题(被观察者),observer 代表观察者。当 subject 状态发生变化时,需主动通知所有注册的 observer,这便是响应式机制的核心。
这两个文件均依赖 src/observer/utils.js,因此先对该工具文件进行分析,内容分为三部分。
工具函数与栈操作机制
第一部分
export const ObserverStack = {
stack: [],
push(observer) {
this.stack.push(observer);
},
pop() {
return this.stack.pop();
},
top() {
return this.stack[this.stack.length - 1];
}
};
首先定义了一个用于存储观察者的栈,遵循后进先出(LIFO)原则,内部使用 stack 数组进行存储。
- 入栈操作
push,与数组的push函数功能一致,用于在栈顶添加一个观察者 observer。 - 出栈操作
pop,与数组的pop函数一致,用于移除栈顶观察者并返回该被移除的观察者对象。 - 获取栈顶元素
top,与pop操作不同,top仅读取栈顶元素而不将其删除。
第二部分
export const SYMBOL_OBSERVABLE = '__ob__';
export const canObserve = target => typeof target === 'object';
定义了一个字符串常量 SYMBOL_OBSERVABLE,便于后续代码调用。
定义了一个函数 canObserve,用于判断目标对象是否可被观察。由于只有对象类型才能被观察,因此使用 typeof 来检查目标类型。这里存在一个潜在问题:若 target 为 null,函数也会返回 true。如果 null 被视为不可观察,则这可能是一个 Bug(撰写本文时已提交 PR 询问此行为是否为预期设计)。
第三部分
export const defineProp = (target, key, value) => {
Object.defineProperty(target, key, { enumerable: false, value });
};
此处无复杂逻辑,主要是为了简化过长的 Object.defineProperty 代码,通过定义函数来避免重复编写。
接下来分析观察者文件 src/observer/observer.js,内容分为四部分。
观察者(Observer)实现细节
第一部分
export function Observer(context, getter, callback, meta) {
this._ctx = context;
this._getter = getter;
this._fn = callback;
this._meta = meta;
this._lastValue = this._get();
}
构造函数,接收 4 个参数。
context 表示当前观察者所处的上下文,类型为 ViewModel。当第三个参数 callback 被调用时,函数内部的 this 即指向该 context。
getter 类型为函数,用于获取特定属性的值。
callback 类型为函数,当某个值发生变化时执行相应的回调。
meta 为元数据。观察者(Observer)并不关注 meta 元数据的具体内容。
在构造函数的最后一行执行 this._lastValue = this._get()。随后分析 _get 函数的具体实现。
第二部分
Observer.prototype._get = function() {
try {
ObserverStack.push(this);
return this._getter.call(this._ctx);
} finally {
ObserverStack.pop();
}
};
ObserverStack 即前文分析的存储所有观察者的栈。将当前观察者入栈,并通过 _getter 获取当前值。结合第一部分的构造函数,该值被存储在 _lastValue 属性中。
完成上述过程后,观察者初始化结束。
第三部分
Observer.prototype.update = function() {
const lastValue = this._lastValue;
const nextValue = this._get();
const context = this._ctx;
const meta = this._meta;
if (nextValue !== lastValue || canObserve(nextValue)) {
this._fn.call(context, nextValue, lastValue, meta);
this._lastValue = nextValue;
}
};
此部分实现了数据更新时的脏检查(Dirty checking)机制。通过比较更新后的值与当前值,若不同则执行回调函数。若回调用于渲染 UI,则可实现按需渲染。若值相同,则进一步检查新值是否可被观察,以决定是否执行回调。
第四部分
Observer.prototype.subscribe = function(subject, key) {
const detach = subject.attach(key, this);
if (typeof detach !== 'function') {
return;
}
if (!this._detaches) {
this._detaches = [];
}
this._detaches.push(detach);
};
Observer.prototype.unsubscribe = function() {
const detaches = this._detaches;
if (!detaches) {
return;
}
while (detaches.length) {
detaches.pop()();
}
};
涉及订阅与取消订阅逻辑。
前文提到的观察者和被观察者,在另一种说法中称为订阅/发布模式。此部分代码实现了对主题(subject)的订阅功能。
首先调用主题的 attach 方法进行订阅。若订阅成功,subject.attach 方法返回一个函数,调用该函数即可取消订阅。为了后续能取消订阅,必须保存此返回值。
subject 的实现原理不难推测:观察者订阅了 subject,subject 需在数据变化时及时通知观察者。subject 如何感知数据变化?其机制与 Vue2 相同,使用 Object.defineProperty 进行属性劫持。
接下来分析观察者文件 src/observer/subject.js,内容分为七部分。
主题(Subject)与属性劫持
第一部分
export function Subject(target) {
const subject = this;
subject._hijacking = true;
defineProp(target, SYMBOL_OBSERVABLE, subject);
if (Array.isArray(target)) {
hijackArray(target);
}
Object.keys(target).forEach(key => hijack(target, key, target[key]));
}
构造函数,逻辑较为简单。将 _hijacking 属性设置为 true,以标识该对象已被劫持。Object.keys 通过遍历劫持每个属性。若目标为数组,则调用 hijackArray 进行处理。
第二部分
包含两个静态方法。
Subject.of = function(target) {
if (!target || !canObserve(target)) {
return target;
}
if (target[SYMBOL_OBSERVABLE]) {
return target[SYMBOL_OBSERVABLE];
}
return new Subject(target);
};
Subject.is = function(target) {
return target && target._hijacking;
};
Subject 的构造函数不直接对外暴露,而是封装在 Subject.of 静态方法中。
若目标对象不可被观察,则直接返回原目标。
若 target[SYMBOL_OBSERVABLE] 不等于 undefined,说明目标对象已经初始化过。
否则,调用构造函数初始化 Subject。
Subject.is 用于判断目标是否已被劫持。
第三部分
Subject.prototype.attach = function(key, observer) {
if (typeof key === 'undefined' || !observer) {
return;
}
if (!this._obsMap) {
this._obsMap = {};
}
if (!this._obsMap[key]) {
this._obsMap[key] = [];
}
const observers = this._obsMap[key];
if (observers.indexOf(observer) < 0) {
observers.push(observer);
return function() {
observers.splice(observers.indexOf(observer), 1);
};
}
};
该方法似曾相识,即上文 Observer.prototype.subscribe 中调用的函数。其作用是允许观察者订阅主题,而该方法本身则是“主题如何被订阅”的实现。
观察者维护着一个主题的哈希表 _obsMap。哈希表的 key 为需要订阅的属性名。例如,一个观察者订阅了 name 属性的变化,另一个订阅了 age 属性的变化。由于同一属性的变化可被多个观察者同时订阅,哈希表存储的值是一个数组,数组元素为各个观察者。
第四部分
Subject.prototype.notify = function(key) {
if (
typeof key === 'undefined' ||
!this._obsMap ||
!this._obsMap[key]
) {
return;
}
this._obsMap[key].forEach(observer => observer.update());
};
当属性发生变化时,通知所有订阅了该属性的观察者。遍历每个观察者并调用其 update 方法。前文已提及,脏检查逻辑即在此方法内完成。
第五部分
Subject.prototype.setParent = function(parent, key) {
this._parent = parent;
this._key = key;
};
Subject.prototype.notifyParent = function() {
this._parent && this._parent.notify(this._key);
};
此部分用于处理属性嵌套(nested object)问题,例如对象结构如:{ user: { name: 'JJC' } }。
第六部分
function hijack(target, key, cache) {
const subject = target[SYMBOL_OBSERVABLE];
Object.defineProperty(target, key, {
enumerable: true,
get() {
const observer = ObserverStack.top();
if (observer) {
observer.subscribe(subject, key);
}
const subSubject = Subject.of(cache);
if (Subject.is(subSubject)) {
subSubject.setParent(subject, key);
}
return cache;
},
set(value) {
cache = value;
subject.notify(key);
}
});
}
展示了如何使用 Object.defineProperty 进行属性劫持。设置属性时会调用 set(value) 设置新值,随后调用 subject 的 notify 方法。此处不进行任何值比较,只要设置属性即会调用,即使新值与旧值相同。notify 会通知所有观察者。
第七部分
涉及数组方法的劫持。
const ObservedMethods = {
PUSH: 'push',
POP: 'pop',
UNSHIFT: 'unshift',
SHIFT: 'shift',
SPLICE: 'splice',
REVERSE: 'reverse'
};
const OBSERVED_METHODS = Object.keys(ObservedMethods).map(
key => ObservedMethods[key]
);
ObservedMethods 定义了需要劫持的数组函数。大写字母作为 key,小写字母为实际需劫持的方法。
function hijackArray(target) {
OBSERVED_METHODS.forEach(key => {
const originalMethod = target[key];
defineProp(target, key, function() {
const args = Array.prototype.slice.call(arguments);
originalMethod.apply(this, args);
let inserted;
if (ObservedMethods.PUSH === key || ObservedMethods.UNSHIFT === key) {
inserted = args;
} else if (ObservedMethods.SPLICE) {
inserted = args.slice(2);
}
if (inserted && inserted.length) {
inserted.forEach(Subject.of);
}
const subject = target[SYMBOL_OBSERVABLE];
if (subject) {
subject.notifyParent();
}
});
});
}
数组的劫持与对象不同,不能直接使用 Object.defineProperty。
需要劫持 6 个数组方法,分别是:头部添加、头部删除、尾部添加、尾部删除、替换/删除某几项、数组反转。
通过重写数组方法实现了数组劫持。但需注意,数组内的元素虽已被观察,但新添加的元素尚未被观察。因此,若当前方法为 push、unshift 或 splice,代码需将新元素放入观察者队列中进行处理。
另外两个文件分别为单元测试和性能分析,此处不再赘述。
总结
至此,关于鸿蒙系统 JavaScript 框架的逐行分析介绍完毕。如需了解更多相关内容,可搜索本站往期文章或继续浏览后续文章,感谢大家的支持!