鸿蒙系统 JavaScript 框架源码逐行解析

作者:代码拾荒

时间:2026-10-23

浏览:481

本文基于鸿蒙系统源码,逐行解析其 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 框架的逐行分析介绍完毕。如需了解更多相关内容,可搜索本站往期文章或继续浏览后续文章,感谢大家的支持!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章