从自研走向开源的 TinyVue 组件库
TinyVue 的开源征程
OpenTiny 提供企业级的 Web 利用前端开发套件,包含 TinyVue/TinyNG 组件库、TinyPro 管理系统模板、TinyCLI 命令行工具以及 TinyTheme 主题配置零碎等。这些前端开发的基础设施和技术已在华为外部积攒和积淀多年,其中 TinyVue 组件库更是历经九年的磨难,从最后的关闭自研逐渐走向社区开源。
TinyVue 九年的开源征程大抵分为三个阶段:第一阶段走齐全自研的线路,过后称为 HAE 前端框架;第二阶段开始引入开源的 Vue 框架,更名为 AUI 组件库;第三阶段对架构进行从新设计,并逐渐演变为当初开源的 TinyVue 组件库。本文将围绕 TinyVue 三个阶段的技术倒退历程,深刻代码细节解说不同阶段的外围竞争力。
全文蕴含以下章节:
-
齐全自研的 HAE 前端框架
- 实现数据的双向绑定
- 实现面向对象的 JS 库
- 配置式开发的注册表
-
迁徙到 Vue 的 AUI 组件库
- 封装成 Vue 组件
- 后端服务适配器
- 标签式与配置式
-
全新架构的 TinyVue 组件库
- 开发组件库面临的问题
- 面向逻辑编程与无渲染组件
- 跨端跨技术栈 TODO 组件示例
齐全自研的 HAE 前端框架
工夫回到 2014 年,彼时的我刚退出公共技术平台部,参加 HAE 前端框架的研发。HAE 的全称是 Huawei Application Engine,即华为利用引擎。过后咱们部门负责团体 IT 零碎的基础设施建设,在布局 HAE 时咱们对行业和技术趋势进行了剖析,并得出结论:云计算、大数据牵引 IT 架构变动,并带来商业模式转变和产品改革,而云计算和大数据须要新的 IT 基础架构的撑持。
基于这个背景,咱们提出 IT 2.0 架构的指标:利用互联网技术打造面向未来的更高效、麻利的下一代 IT。作为云开发平台,HAE 须要反对全面的云化:云端开发、云端测试、云端部署、云端经营,以及利用施行的云化。其中,云端开发由 Web IDE 负责实现,这个 IDE 为用户提供基于配置的前端开发能力,因而须要反对可配置的 HAE 前端框架。
基于配置的开发模式,用户可通过可视化界面来配置前端利用开发中的各种选项,比方定义零碎生命周期、配置页面路由、设置组件的属性等。相比之下,传统的开发模式须要用户手写代码来实现这些性能。过后业界还没有能满足这种需要的前端开发框架,走齐全自研的路是历史必然的抉择。
实现数据的双向绑定
在 2014 年,支流的前端技术仍以 jQuery 为主。传统的 jQuery 开发方式是通过手动操作 DOM 元素来更新和响应数据的变动。开发者须要编写大量的代码来解决数据和 DOM 之间的同步。这种单向的数据流和手动操作的形式,在解决简单利用的数据和视图之间的同步时,可能会导致代码冗余、保护艰难以及出错的可能性减少。
过后刚刚衰亡的 AngularJS 带来了数据双向绑定的概念。数据双向绑定能够主动将数据模型和视图放弃同步,当数据发生变化时,视图会自动更新,反之亦然。这种机制缩小了开发者在手动解决数据和 DOM 同步方面的工作量。通过 AngularJS 开发者只须要关注数据的变动,而不用显式地更新 DOM 元素。这使得开发过程更加简洁、高效,并缩小了出错的可能性。
在 HAE 前端框架的研发初期,为了引入数据双向绑定性能,咱们在本来基于 jQuery 的架构上交融了 AngularJS 框架。然而,通过几个月的尝试,咱们发现这种交融形式存在两个重大问题:首先,当页面绑定数据量较大时,性能显著降落。其次,框架同时运行两套生命周期,导致两者难以协调互相同步。因而,咱们决定移除 AngularJS 框架,但又不想放弃曾经应用的数据双向绑定的个性。
在此情景下,咱们深入研究 AngularJS 的数据双向绑定。它采纳脏读(Dirty Checking)机制,该机制通过定期检查数据模型的变动来放弃视图与模型之间的同步。当数据发生变化时,AngularJS 会遍历整个作用域(Scope)树来查看是否有任何绑定的值产生了扭转。当绑定的数据量较大时,这个过程会耗费大量的计算资源和工夫。
用什么计划替换 AngularJS 的数据双向绑定,并且还要保障性能优于脏读机制?过后咱们把眼光投向 ES5 的 Object.defineProperty
函数,借助它的 getter 和 setter 办法实现了数据双向绑定。这个计划比 Vue 框架早了整整一年,直到 2015 年 10 月,Vue 1.0 才正式公布。
接下来,咱们通过代码来展示该计划的技术细节,以下是 AngularJS 数据双向绑定的应用示例:
<!DOCTYPE html>
<html ng-app="myApp">
<head>
<script src="angular.min.js"></script>
</head>
<body>
<div ng-controller="myController">
<input type="text" ng-model="message">
<p>{{message}}</p>
</div>
<script>
// 创立一个名为 "myApp" 的 AngularJS 模块
var app = angular.module('myApp', []);
// 在 "myApp" 模块下定义一个控制器 "myController"
app.controller('myController', function($scope) {
$scope.message = "Hello AngularJS"; // 初始值
// 监听 message 的变动
$scope.$watch('message', function(newValue, oldValue) {console.log('新值:', newValue);
console.log('旧值:', oldValue);
});
});
</script>
</body>
</html>
咱们的替换计划就是要实现下面示例中的 $scope
变量,该变量领有一个能够双向绑定的 message
属性。从以下 HTML 代码片段:
<input type="text" ng-model="message">
能够得悉 input 输入框的值绑定了 message
属性,而另一段代码:
<p>{{message}}</p>
则表明 P 标签也绑定了 message
属性,当执行以下语句:
$scope.message = "Hello AngularJS";
之后 message
的值就被批改了。此时,与 message
双向绑定的 input 输入框的值和 P 标签的内容也同步被批改为 Hello AngularJS
。
要实现 $scope
变量,就必须实现一个 Scope
类,$scope
变量就是 Scope
类的一个实例。这个类有一个增加属性的办法,假如办法名为 $addAttribute
,并且还有一个监听属性的办法,假如办法名为 $watch
。
咱们来看如何实现这两个办法(为突出外围片段,以下代码有删减):
function createScope() {
var me = this;
var attrs = {};
var watches = {};
function Scope() {}
Scope.prototype = {
/**
* 增加属性
* @param {String} attrName 属性名
* @param {Object} attrValue 属性值
* @returns {Scope}
*/
$addAttribute: function(attrName, attrValue, readOnly) {if (attrName && typeof attrName === "string" && !attrs[attrName] && attrName.indexOf("$") !== 0) {
Object.defineProperty(attrObject, attribute, {get: function() {return attrs[attrName].value;
},
set: function(newValue) {var attr = attrs[attrName];
var oldValue = attr.value;
var result = false;
var watch;
// 判断新旧值是否齐全相等
if (oldValue !== newValue && !(oldValue !== oldValue && newValue !== newValue)) {watch = watches[attrName];
// 是否有监听该属性的回调函数
if (watch && !attr.watching) {
attr.watching = true;
result = watch.callbacks.some(function(callback) {
try {
// 如果监听回调函数返回 false,则终止触发下一个回调函数
return callback.call(scope, newValue, oldValue, scope) === false;
} catch (e) {me.error(e);
}
});
delete attr.watching;
}
if (!result) {if (attr.watching) {
try {
// 如果监听回调函数执行过程中又更改值,则抛出异样
throw new Error("Cannot change the value of'" + attrName + "'while watching it.");
} catch (e) {me.error(e, 2);
}
} else {
// 所有监听回调函数执行完,再赋予新值
attr.value = newValue;
}
}
}
},
enumerable: true,
configurable: true
});
}
return this;
},
/**
* 监听属性变动
* @param {String} attrName 属性名
* @param {Function} callback 监听回调函数
* @param {Number} [priority] 监听的优先级
* @returns {Scope}
*/
$watch: function(attrName, callback, priority) {if (attrName && typeof attrName === "string" && attrs[attrName] && typeof callback === "function") {var watch = watches[attrName] || {callbacks: [],
priorities: [],
minPriority: 0,
maxPriority: 0
};
var callbacks = watch.callbacks;
var priorities = watch.priorities;
var nIndex = callbacks.length;
if (typeof priority !== "number") {priority = me.CALLBACK_PRIORITY;}
// 判断监听回调函数的优先级
if (priority > watch.minPriority) {if (priority > watch.maxPriority) {
// 优先级数值最高的监听回调函数放在队尾
callbacks.push(callback);
priorities.push(priority);
watch.maxPriority = priority;
} else {priorities.some(function(item, index) {if (item > priority) {
nIndex = index;
return true;
}
});
// 依照优先级的数值在队列适当地位插入监听回调函数
callbacks.splice(nIndex, 0, callback);
priorities.splice(nIndex, 0, priority);
}
} else {
// 优先级数值最小的监听回调函数放在队首
callbacks.unshift(callback);
priorities.unshift(priority);
watch.minPriority = priority;
}
watches[attrName] = watch;
}
return this;
}
}
// 返回 Scope 类新建的实例,即 $scope 变量
return new Scope();}
上述代码因篇幅关系并不蕴含数据绑定的性能,仅实现为 $scope
增加属性以及设置监听回调函数,并且思考了多个监听回调函数的执行程序及异样解决的状况。借助 Object.defineProperty
,咱们不再须要遍历整个作用域(Scope)树来查看是否有任何绑定的值产生了扭转。一旦某个值发生变化,就会立刻触发绑定的监听回调函数,从而解决脏读机制的性能问题。
实现面向对象的 JS 库
同样在 2014 年,具备面向对象编程个性的 TypeScript 仍处于晚期阶段,微软在当年 4 月份公布了 TypeScript 1.0 版本。HAE 前端框架在研发初期没有抉择不成熟的 TypeScript 计划,而是利用 JavaScript 原型链机制来实现面向对象的设计模式,即通过共享原型对象的属性和办法,实现对象之间的继承和多态性。
然而,应用 JavaScript 原型链来实现面向对象设计存在两个问题:首先,原型链的应用形式与传统的面向对象编程语言(例如 Java 和 C++)有显著的区别,在过后前端开发人员大多是由 Java 后端转行做前端,因而须要破费较高的学习老本来适应原型链的概念和用法。其次,JavaScript 自身没有提供显式的公有属性和办法的反对,咱们个别通过在属性或办法前增加下划线等约定性命名,来暗示这是一个公有成员。而在理论开发过程中,用户往往会间接拜访和应用这些公有成员,这导致在后续框架的降级过程中必须思考向下兼容这些公有成员,从而减少了框架的开发成本。
为了解决上述问题,咱们自研了 jClass 库,这个库用 JavaScript 模仿实现了面向对象编程语言的根本个性。jClass 不仅反对真正意义上的公有成员,还反对爱护成员、多重继承、办法重载、个性混入、动态常量、类工厂和类事件等,此外还内置自研的 Promise 异步执行对象,提供动静加载类内部依赖模块的性能等。
接下来,咱们通过代码来展示 jClass 面向对象的个性,以下是根本应用示例:
// 定义 A 类
jClass.define('A', {
privates: {name1: '1' // 公有成员,外部能够拜访,但子类不能拜访},
protects: {name2: '2' // 爱护成员,子类能够拜访,类的实例不能拜访},
publics: {name3: '3' // 私有成员,子类能够拜访,类的实例能够拜访}
});
// 定义 B 类,继承 A 类
jClass.define('B', {extend: ['A'],
publics: {
// 私有办法,子类能够拜访,类的实例能够拜访
say: function(str) {alert(str + this.name2); // 能够拜访父类的爱护成员 name2,但无法访问其公有成员 name1
}
}
});
// 创立 B 类的实例
var b = jClass.create('B');
b.say(b.name3); // B 继承 A 的 name3 属性,因为类的实例能够拜访私有成员,因而弹出框内容为 3
咱们再来看 jClass 如何继承多个父类,在上述代码前面增加如下代码:
// 定义 C 类
jClass.define('C', {
// 类的构造函数,初始化 title 属性
init: function(title) {this.title = title; // 设置爱护成员 title 的值},
protects: {title: '' // 爱护成员,子类能够拜访,类的实例不能拜访}
});
// 定义 D 类,同时继承 B 类和 C 类
jClass.define('D', {extend: ['B', 'C'],
publics: {
// 私有办法,类的实例能够拜访
say: function() {alert(this.title + this.name2); // 拜访 C 的爱护成员 title 和 B 的爱护成员 name2
}
}
});
// 创立 D 的实例,传入 C 构造函数所需的参数,弹出框内容为 32
jClass.create('D', ['3']).say();
jClass 的类工厂与类继承有相似之处,而类事件则为类办法的调用、类属性的批改提供监听能力,两者的应用示例如下:
// 创立一个工厂的实例
var tg = jClass.factory('triggerFactory', {
// 类的构造函数,初始化 name 属性
init: function(name) {this.name = name;},
// 私有成员,类的实例能够拜访
publics: {
name: '',
show: function(str) {
this.name += 'x' + str;
return this;
},
hide: function(str) {this.$fire('gone', [str]); // 抛闻名为 gone 的事件,事件参数为 str
}
}
});
// 通过工厂定义一个名为 tg1 的类
tg.define('tg1', {
publics: {name: '1' // 重载工厂的 name 属性值}
});
var resShow = '', resName ='', resGone = '';
// 监听 tg1 类的 show 办法调用,如果该办法被执行,则触发以下函数
tg.on('show', 'tg1', function(str) {resShow = this.name + '=' + str;});
// 监听 tg1 类的 name 属性设置,如果该属性被从新赋值,则触发以下函数
tg.on('name', 'tg1', function(oldValue, newValue) {resName = newValue;});
// 监听所有子类的 gone 事件,如果该事件被抛出,则触发以下函数
tg.on('gone', function(str) {resGone = str;});
// 创立 tg1 的实例,执行该实例的 show 办法和 hide 办法
tg.create('tg1').show('2').hide('3');
alert(resShow + '' + resName +' ' + resGone); // 弹出框内容为 1x2=2 1x2 3
基于 jClass 面向对象的个性,咱们就能够用面向对象的设计模式来开发 HAE 组件库,以下就是定义和扩大组件的示例:
// 定义组件的基类 Widget
jClass.define('Widget', {
// 组件的构造函数,设置宽度和题目
init: function(width, title) {
this.width = width;
this.title = title;
this.setup(); // 初始化组件的属性
this.compile(); // 依据组件属性编译组件模板
this.render(); // 渲染输入组件的 HTML 字符串},
protects: {
width: 0,
title: '',
templet: '',
// 初始化组件的属性
setup: function() {this.width = this.width + 'px';},
// 依据组件属性编译组件模板
compile: function() {this.templet = this.templet.replace(/{{:width}}/g, this.width);
},
// 渲染输入组件的 HTML 字符串
render: function() {this.html = this.templet.replace(/{{:title}}/g, this.title);
}
},
publics: {html: '' // 记录组件的 HTML 字符串}
});
// 定义一个 Button 组件,继承 Widget 基类
jClass.define('Button', {extend: ['Widget'],
protects: {
// 设置 Button 组件的模板
templet: '<BUTTON width="{{:width}}">{{:title}}</BUTTON>'
}
});
// 创立一个 Button 组件的实例
jClass.create('Button', [100, 'OK']).html; // 返回 <BUTTON width="100px">OK</BUTTON>
// 定义一个 LongButton 组件,继承 Button 父类
jClass.define('LongButton', {extend: ['Button'],
protects: {
// 重载父类的 setup 办法,将长度主动加 100
setup: function() {this.width = this.width + 100 + 'px';}
}
});
// 创立一个 LongButton 组件的实例
jClass.create('LongButton', [100, 'OK']).html; // 返回 <BUTTON width="200px">OK</BUTTON>
上述代码只演示了 jClass 局部个性,因篇幅关系没有展现其实现的细节。从 2014 年 10 月开始,jClass 陆续撑持 120 多个组件的研发,累积 30 多万行的代码。通过四年的倒退,作为 HAE 前端框架的基石,jClass 在华为外部 IT 各个领域 1000 多个我的项目中失去广泛应用。通过这些我的项目的一直磨难,jClass 在性能和性能上曾经达到了企业级的要求。
配置式开发的注册表
一个前端框架须要撑持生命周期,次要目标是在 Web 利用的不同阶段提供可控的执行环境和钩子函数,以便开发者能够在适当的机会执行特定的逻辑和操作。通过生命周期的反对,前端框架可能更好地治理 Web 利用的初始化、渲染、更新和销毁等过程,提供更灵便的管制和扩大能力。
在 HAE 前端框架中,存在三个不同档次的生命周期:零碎生命周期、页面生命周期和组件生命周期。
- 零碎生命周期 :零碎生命周期指的是整个前端利用的生命周期,它蕴含了利用的启动、初始化、运行和敞开等阶段。零碎生命周期提供了利用级别的钩子函数,例如利用初始化前后的钩子、利用销毁前后的钩子等。通过零碎生命周期的反对,开发者能够在利用级别执行一些全局的操作,例如加载配置、注册插件、解决全局谬误等。
- 页面生命周期 :页面生命周期指的是单个页面的生命周期,它形容了页面从加载到卸载的整个过程。页面生命周期蕴含了页面的创立、渲染、更新和销毁等阶段。在页面生命周期中,HAE 前端框架提供了一系列钩子函数,例如页面加载前后的钩子、页面渲染前后的钩子、页面更新前后的钩子等。通过页面生命周期的反对,开发者能够在页面级别执行一些与页面相干的逻辑,例如获取数据、解决路由、初始化页面状态等。
- 组件生命周期 :组件生命周期指的是单个组件的生命周期,它形容了组件从创立到销毁的整个过程。组件生命周期蕴含了组件的实例化、挂载到 DOM、更新和卸载等阶段。组件生命周期的钩子函数与页面生命周期相似,通过组件生命周期的反对,开发者能够在组件级别执行一些与组件相干的逻辑,例如初始化状态、解决用户交互、与内部组件通信等。
总的来说,零碎生命周期、页面生命周期和组件生命周期在粒度和范畴上有所不同。零碎生命周期操作整个 Web 利用,页面生命周期操作单个页面,而组件生命周期操作单个组件。通过这些不同档次的生命周期,HAE 前端框架可能提供更精密和灵便的管制,使开发者可能在适合的机会执行相干操作,实现更高效、牢靠和可扩大的前端利用。
基于配置的开发模式,HAE 前端框架要让用户通过配置的形式,而不是通过手写代码来定义生命周期的钩子函数。为此,咱们引入 Windows 注册表的概念,将框架内置的默认配置信息保留在一个 JSON 对象中,并命名为 register.js
。同时,每个利用也能够依据本身需要创立利用的 register.js
,零碎在启动前会合并这两个文件,从而依照用户冀望的形式配置生命周期的钩子函数。
以下是框架内置的注册表中,无关零碎生命周期的定义:
framework: {
load_modules: { // 加载框架所需的模块
hae_runtime: true,
extend_modules: true
},
set_config: { // 初始化框架的服务配置
Hae_Service_Mock: true,
Hae_Service_Environment: true,
Hae_Service_Ajax: true,
Hae_Service_Personalized: true,
Hae_Service_Permission: true,
Hae_Service_DataSource: true
},
init_runtime: { // 框架运行过程中所需的服务
Hae_Service_Router: true,
Hae_Service_Message: true,
Hae_Service_Popup: true,
},
boot_system: { // 启动框架的模板引擎和页面渲染
Hae_Service_Templet: true,
Hae_Service_Page: true
},
start_services: { // 运行用于解决全局谬误和日志信息的服务
Hae_Service_DebugToolbar: true,
Hae_Service_LogPanel: true
}
}
假如用户自定义的利用注册表内容如下:
framework: {load_modules: { // 加载框架所需的模块},
set_config: { // 初始化框架的服务配置
Hae_Service_Mock: false,
Hae_Service_Environment: true,
Hae_Service_Ajax: true,
Hae_Service_BehaviorAnalysis: true
Hae_Service_Personalized: true,
Hae_Service_Permission: true,
Hae_Service_DataSource: true,
},
init_runtime: {// 框架运行过程中所需的服务},
boot_system: {// 启动框架的模板引擎和页面渲染},
start_services: {// 运行用于解决全局谬误和日志信息的服务}
}
能够看到用户在 load_modules
、init_runtime
、boot_system
和 start_services
阶段不做调整,然而在 set_config
阶段禁用了 Hae_Service_Mock
服务,同时在 Hae_Service_Ajax
服务前面插入了 Hae_Service_BehaviorAnalysis
服务。
生命周期的钩子函数在哪里体现?其实答案就在服务外面,以 Hae_Service_Mock
服务为例,上面是该服务的定义:
/**
* 申请模仿服务
* @class Hae.Service.Mock
*/
Hae.loadService({
id: 'Hae.Service.Mock', // 服务 ID
name: 'HAE_SERVICE_MOCK', // 服务名称
callback: function() {} // 服务的钩子函数
});
以上代码须要蕴含在 load_modules
阶段加载的模块中。除了调用 loadService
办法定义服务之外,还能够通过 jClass 定义类的形式定义服务,代码示例如下:
/**
* 申请模仿服务
* @class Hae.Service.Mock
*/
Hae.define('Hae.Service.Mock', { // 服务 ID
services: {
// 服务名称
HAE_SERVICE_MOCK: function() {} // 服务的钩子函数
}
}
下面的 Hae 变量就是加强版的 jClass。咱们再来看一下框架内置的注册表中,无关页面生命周期的定义:
page: {
loadHtml: {Hae_Service_Page: "loadHtml"},
initContext: {Hae_Service_Page: "initContext"},
loadJs: {Hae_Service_Page: "loadJs"},
loadContent: {
Hae_Service_Locale: "translate",
Hae_Service_Page: "loadContent",
Hae_Service_Personalized: "setRoutePath"
},
compile: {
Hae_Service_Permission: "compile",
Hae_Service_DataBind: "compile",
Hae_Service_ActionController: "compile",
Hae_Service_ValidationController: "compile"
},
render: {Hae_Service_Page: "render"},
complete: {
Hae_Service_DataBind: "complete",
Hae_Service_ActionController: "complete",
Hae_Service_ValidationController: "complete"
},
domReady: {
Hae_Service_Page: "domReady",
Hae_Service_DataBind: "domReady",
Hae_Service_TipHelper: "initHelper"
}
}
零碎生命周期各个服务多以开关的模式定义,而页面生命周期各个服务多以名称的模式定义,以 Hae.Service.DataBind
服务为例,其定义如下:
/**
* 数据双向绑定
* @class Hae.Service.DataBind
*/
Hae.define('Hae.Service.DataBind', {
services: {HAE_SERVICE_DATABIND_COMPILE: function(pageScope) {},
HAE_SERVICE_DATABIND_COMPLETE: function(pageScope) {},
HAE_SERVICE_DATABIND_DOMREADY: function(pageScope) {}}
}
最初再简略介绍一下组件的生命周期,借助 jClass 面向对象的个性,组件的生命周期各阶段钩子函数在组件的基类 Widget
中定义的,代码示例如下:
/**
* 所有 Widget 组件的基类
* @class Hae.Widget
* @extends Hae
*/
Hae.define('Hae.Widget', {
protects: {
/**
* 初始化阶段
*/
setup: function() {},
/**
* 编译模板阶段,返回异步对象
* @returns {Hae.Promise}
*/
compile: function() {this.template = Hae.create('Hae.Compile').compile(this.widgetType, this.op);
return Hae.deferred().resolve();
},
/**
* 渲染模板阶段,返回异步对象
* @returns {Hae.Promise}
*/
render: function() {this.dom.html(Hae.create('Hae.Render').render(this.template, this.dataset, this.op));
},
/**
* 绑定事件阶段
*/
bind: function() {},
/**
* 组件实现阶段
*/
complete: function() {},
/**
* 组件销毁
*/
destroy: function() {}
}
}
能够看到组件基类的编译模板阶段和渲染模板阶段都有默认的实现,因为这两个阶段个别须要读取后端数据等提早操作,因而要返回 Hae.Promise
异步对象。这个异步对象是 HAE 框架参照 jQuery 的 Deferred 异步回调从新实现的,次要解决 Deferred 异步性能慢的问题。
迁徙到 Vue 的 AUI 组件库
工夫来到 2017 年,以 Vue 为代表的古代前端工程化开发模式带来了许多改良和改革。与以 jQuery 为代表的传统开发模式相比,这些改良和改革体现在以下方面:
- 申明式编程 :Vue 采纳了申明式编程的思维,开发者能够通过申明式的模板语法编写组件的构造和行为,而无需间接操作 DOM。这简化了开发流程并进步了开发效率。
- 组件化开发 :Vue 激励组件化开发,将 UI 拆分为独立的组件,每个组件具备本人的状态和行为。这样能够实现组件的复用性、可维护性和扩展性,进步了代码的可读性和可维护性。
- 响应式数据绑定 :Vue 采纳了响应式数据绑定的机制,将数据与视图主动放弃同步。当数据发生变化时,自动更新相干的视图局部,大大简化了状态治理的复杂性。
- 自动化流程 :前端工程化引入了自动化工具,例如构建工具(例如 Webpack)、工作运行器(例如 npm)和自动化测试工具,大大简化了开发过程中的重复性工作和手动操作。通过自动化流程,开发者能够主动编译、打包、压缩和优化代码,主动执行测试和部署等,进步了开发效率和一致性。
- 模块化开发 :前端工程化激励应用模块化开发的形式,将代码拆分为独立的模块,每个模块负责特定的性能。这样能够进步代码的可维护性和复用性,缩小了代码之间的耦合性,使团队合作更加高效。
- 规范化与标准化 :前端工程化提倡遵循一系列的标准和规范,包含代码格调、目录构造、命名约定等。这样能够进步团队合作的一致性,缩小沟通和集成的老本,进步我的项目的可读性和可维护性。
- 动态类型检查和测试 :前端工程化激励应用动态类型查看工具(例如 TypeScript)和自动化测试工具(例如 Mocha)来进步代码品质和稳定性。通过动态类型检查和自动化测试,能够提前捕捉潜在的谬误和问题,缩小 Bug 的产生和排查的工夫。
思考到人力老本、学习曲线和竞争力等因素,HAE 前端框架须要向古代前端开源框架与工程化方向演进。因为 HAE 属于自研框架,仅在华为外部应用,新进的开发人员须要投入工夫学习和把握该框架,这对他们的技术能力要求较高。然而,如果抉择采纳开源框架,宏大的社区反对和宽泛的文档资源,使得开发人员能够更疾速地上手和开发。同时,采纳开源框架也使得 HAE 框架可能紧跟业界趋势。开源框架通常由寰球的开发者社区独特保护和更新,可能及时跟进最新的前端技术和最佳实际。这有助于晋升 HAE 框架本身的竞争力,使其具备更好的适应性和可扩展性。
早在 2016 年 10 月上海 QCon 大会上,Vue 框架的作者尤雨溪首次亮相,登台推广他的开源框架,那也是咱们首次接触 Vue。过后 React 作为另一个支流的开源框架也备受业界关注,咱们须要在 Vue 和 React 之间做出抉择。随后,在 2017 年 6 月咱们远赴波兰的佛罗茨瓦夫加入 Vue 首届寰球开发者大会,那次咱们有幸与尤雨溪自己进行了交换。回来后,咱们提交了 Vue 与 React 的比照剖析报告,向下级汇报了咱们的技术选型动向,最终咱们决定抉择 Vue。
封装成 Vue 组件
要全面迁徙到 Vue 框架,摈弃已应用 jQuery 开发的 30 万行代码,在无限的工夫和人力下是一个微小的挑战。为了找到折中的解决方案,咱们采取这样的迁徙策略:将 HAE 前端框架的零碎和页面生命周期进行剥离,只保留与 HAE 组件相干的代码,而后将底层架构替换为 Vue,并引入所有前端工程化相干的能力,最初胜利实现了让用户以 Vue 的形式来应用咱们的组件。这样的迁徙策略在保障我的项目停顿的同时,也可能逐渐融入 Vue 的劣势和工程化的便利性。
为了让用户以 Vue 的形式来应用 jQuery 开发的组件,咱们须要在 Vue 组件的生命周期中动态创建 HAE 组件。过后,咱们正在钻研 Webix 组件库,它底层应用的是纯 JavaScript 技术实现的,没有依赖于其余框架或库,是一个独立的前端组件库。Webix 能够无缝地集成到各种不同的平台和框架中,包含 Vue、React、Angular 等。这使得开发人员可能在现有的我的项目中轻松地引入和应用 Webix 组件,而无需进行大规模的重构。以下是 Webix 官网提供的与 Vue 集成的示例(原文链接):
app.component("my-slider", {props: ["modelValue"],
// always an empty div
template: "<div></div>",
watch: {
// updates component when the bound value changes
value: {handler(value) {webix.$$(this.webixId).setValue(value);
},
},
},
mounted() {
// initializes Webix Slider
this.webixId = webix.ui({
// container and scope are mandatory, other properties are optional
container: this.$el,
$scope: this,
view: "slider",
value: this.modelValue,
});
// informs Vue about the changed value in case of 2-way data binding
$$(this.webixId).attachEvent("onChange", function() {var value = this.getValue();
// you can use a custom event here
this.$scope.$emit("update:modelValue", value);
});
},
// memory cleaning
destroyed() {webix.$$(this.webixId).destructor();},
});
上述示例代码定义了一个名为 my-slider
的 Vue 组件,在该组件生命周期的 mounted
阶段,通过调用 webix.ui
办法动态创建了一个 Webix 组件,而后监听该组件的 onChange
事件并抛出 Vue 的 update:modelValue
事件,并且利用 Vue 的 watch
监听其 value
属性,一旦它发生变化则调用 Webix 的 setValue
办法从新设置 Webix 组件的值,从而实现数据的双向绑定。因为 HAE 组件也反对动态创建,依照这个思路,咱们很快写出 HAE 版本的 Vue 组件:
export default {
name: 'AuiSlider',
render: function (createElement) {
// 渲染一个 div 标签,相似于 Webix 的 template: "<div></div>"
return createElement('div', this.$slots.default)
},
props: ['modelValue', 'op'],
data() {
return {widget: {}
}
},
created() {
// 在 Vue 组件的创立阶段监听 value 属性
this.$watch('value', (value) => {
// 一旦它发生变化则调用 widget 的 setValue 办法从新 HAE 组件的值
this.widget.setValue && this.widget.setValue(value)
})
},
methods: {createcomp() {let dom = $(this.$el)
let fullOp = this.$props['op']
let extendOp = {
// 监听 HAE 组件的 `onChange` 事件
onChange: (val) => {
// 抛出 Vue 的 `update:modelValue` 事件
this.$emit('update:modelValue', val)
}
}
// 获取 Vue 组件的配置参数
let op = fullOp
? Hae.extend({}, fullOp, extendOp, { value: this.$props.value})
: Hae.extend({}, this.$props, extendOp)
this.$el.setAttribute('widget', 'Slider')
// 调用 Hae 的 widget 办法动态创建了一个 HAE 组件
this.widget = Hae.widget(dom, 'Slider', op)
}
},
mounted() {
// 在 Vue 组件的挂载阶段创立 HAE 组件
this.createcomp()}
}
以上的迁徙策略毕竟是折中的长期计划,并没有充分发挥 Vue 的模板和虚构 DOM 劣势,相当于用 Vue 套了一层壳。尽管该计划也提供了数据双向绑定性能,但不会绑定数组的每个元素,并不是真正的基于数据驱动的组件。这个长期计划的益处在于为咱们博得了工夫,以最快速度引入开源的 Vue 框架以及前端工程化的理念,使得业务开发可能尽早受害于前端改革所带来的降本增效。
通过近半年的研发,HAE 组件库胜利迁徙到 Vue 框架,并于 2017 年 12 月正式公布。在 2018 年,为对立用户体验遵循 Aurora 主题标准,咱们对组件库进行降级革新,并改名为 AUI。在撑持了制作、洽购、供给、财经等畛域的大型项目后,到了 2019 年 AUI 进入成熟稳定期,咱们才有工夫去思考如何将 jQuery 的 30 万行代码重构为 Vue 的代码。
后端服务适配器
在 HAE 框架中,组件可能主动连贯后端服务以获取数据,无需开发人员编写申请代码或解决返回数据的格局。例如人员联想框组件,其性能是依据输出的工号返回相应的姓名。该组件曾经事后定义了与后端服务进行通信所需的接口和数据格式,因而开发人员只须要在页面中引入该组件即可间接应用。
这样的设计使得开发人员可能更专一于页面的搭建和性能的实现,而无需关注与后端服务的具体通信细节。HAE 框架会主动解决申请和响应,并确保数据以统一的格局返回给开发人员。通过这种主动连贯后端服务的形式,开发人员可能节俭大量编写申请代码和数据处理逻辑的工夫,放慢开发速度,同时缩小了潜在的谬误和重复劳动。
HAE 框架的后端服务是配套的,组件设计当初没有思考连贯不同的后端服务。降级到 AUI 组件库之后,业务的多样化场景使得咱们必须引入适配器来实现对接不同后端服务的需要。适配器作为一个中间层,其目标和作用如下:
- 解耦前后端 :适配器充当前后端之间的中间层,将前端组件与后端服务解耦。通过适配器,前端组件不须要间接理解或依赖于后端服务的具体接口和数据格式。这种解耦使得前端和后端可能独立地进行开发和演进,而不会相互影响。
- 对立接口 :不同的后端服务可能具备不同的接口和数据格式,这给前端组件的开发带来了艰难。适配器的作用是将不同后端服务的接口和数据格式转化为对立的接口和数据格式,使得前端组件能够统一地与适配器进行交互,而不须要关怀底层后端服务的差别。
- 灵活性和扩展性 :通过适配器,前端组件能够轻松地切换和扩大后端服务。如果须要替换后端服务或新增其余后端服务,只需增加或批改适配器,而不须要批改前端组件的代码。这种灵活性和扩展性使得零碎可能适应不同的后端服务需要和变动。
- 暗藏复杂性 :适配器能够解决后端服务的复杂性和非凡状况,将这些复杂性暗藏在适配器外部。前端组件只需与适配器进行交互,无需关注后端服务的简单逻辑和细节。这种形象和封装使得前端组件的开发更加简洁和高效。
以 AUI 内置的 Jalor 和 HAE 两个后端服务适配器为例,对于雷同的业务服务,咱们来看一下这两个后端服务接口的差别,以下是 Jalor 局部接口的拜访地址:
Setting.services = {
Area: 'servlet/idataProxy/params/ws/soaservices/AreaServlet',
Company: 'servlet/idataProxy/params/ws/soaservices/CompanyServlet',
Country: 'servlet/idataProxy/params/ws/soaservices/CountryServlet',
Currency: 'servlet/idataProxy/params/ws/soaservices/CurrencyServlet',
}
以下是对应 HAE 接口的拜访地址:
Setting.services = {
Area: 'services/saasIdatasaasGetGeoArea',
Company: 'services/saasIdatasaasGetCompany',
Country: 'services/saasIdatasaasGetCountry',
Currency: 'services/saasIdatasaasGetCurrency',
}
这些雷同的业务服务不仅接口拜访地址不同,就连申请的参数格局以及返回的数据格式都有差别。适配器就是为开发人员提供对立的 API 来连贯这些有差别的服务。在具体实现上,咱们首先创立一个核心层接口 @aurora/core
,以下是该接口的示例代码:
class Aurora {
// 注册后端服务适配器
get registerService() {}
// 返回后端服务适配器的实例
get getServiceInstance() {}
// 删除后端服务适配器的实例
get destroyServiceInstance() {}
// 根底服务,次要提供获取和设置环境信息、用户信息、菜单、语言、权限等数据信息的办法
get base() {return getService().base
}
// 通用服务,次要提供和业务(地区、部门等)相干的办法
get common() {return getService().common
}
// 音讯服务,次要用于订阅音讯、公布音讯、勾销订阅
get message() {return getService().message
}
// 网络服务,基于 axios 实现的,用法和 axios 基本相同,只反对异步申请
get network() {return getService().network
}
// 存储服务,默认基于 window.localstorage 办法扩大
get storage() {return getService().storage
}
// 权限服务,校验以后用户是否有该权限点的某个操作权限。计算权限点,反对规范逻辑运算符 |, &
get privilege() {return getService().privilege
}
// 资源服务,次要是资源申请,追加配置资源门路,用于加载依赖库,从公共库,组件目录,或者近程加载
get resource() {return resource}
}
而后咱们为每一个后端服务创立一个适配器,以下是 Jalor 适配器 @aurora/service-jalor
的示例代码:
class JalorService {constructor(config = {}) {this.utils = utils(this)
// 注册服务到全局的适配器实例中
Aurora.registerService(this, config)
this.ajax = ajax(this)
this.init = init(this)
// 初始化适配器的配置信息
config.services = services(this)
config.options = options(this)
config.widgets = widgets(this)
// 须要应用柯里化函数初始化的服务办法
this.fetchEnvService = fetchEnvService(this)
this.fetchLangResource = fetchLangResource(this)
this.fetchArea = fetchArea(this)
this.fetchCompany = fetchCompany(this)
this.fetchCountry = fetchCountry(this)
this.fetchCurrency = fetchCurrency(this)
this.fetchFragment = fetchFragment(this)
// 其余须要初始化的服务办法
fetchDeptList(this)
fetchUser(this)
fetchLocale(this)
fetchLogout(this)
fetchRole(this)
fetchCustomized(this)
fetchEdoc(this)
}
// 服务适配器的名称
get name() {return 'jalor'}
}
export default JalorService
以下面的 fetchArea
办法为例,Jalor 服务的实现代码如下:
export default function (instance) {return ({ label, parent}) => {return new Promise((resolve, reject) => {
// 调用 @aurora/core 的 network 网络服务发送申请
instance.network.get(instance.setting.services.Area, {
params: {
'area_label': label,
'parent': parent
}
})
.then((response) => {resolve(response.data.area)
})
.catch(reject)
})
}
}
而 HAE 服务适配器 @aurora/service-hae
对应的 fetchArea
办法的实现代码如下:
export default function (instance) {return ({ label, parent}) => {return new Promise((resolve, reject) => {
// 调用 @aurora/core 的 network 网络服务发送申请
instance.network.get(instance.setting.services.Area, {
params: {
'geo_org_type': label,
'parent': parent
}
}).then(response => {resolve(response.data.BO)
}).catch(reject)
})
}
}
两者次要区别在于 parmas
参数以及 response.data
数据格式。有了对立的 API 接口,开发人员只需按以下形式调用 getArea
办法就能获取地区的数据,不须要辨别数据来自是 Jalor 服务还是 HAE 服务:
this.$service.common.getArea({label: 'Region', parent: '1072'}).then(data => { console.log(data) })
标签式与配置式
AUI 组件继承了 HAE 框架的特点,即人造反对配置式开发。如何了解这个配置式开发?咱们用后面 封装成 Vue 组件
章节里的代码来解说:
var op = {
min: 0,
max: 100,
step: 10,
range: 'min'
}
// 调用 Hae 的 widget 办法动态创建了一个 HAE 组件
this.widget = Hae.widget(dom, 'Slider', op)
代码中的 op
变量是 option 配置项的缩写,变量的值为一个 JSON
对象,该对象形容了创立 HAE 的 Slider
组件所需的配置信息。这些配置信息在 HAE 框架中通过 Web IDE 的可视化设置面板来收集,这就是配置式开发的由来。相比之下,如果咱们用 Vue 惯例的标签形式申明 AUI 的 Slider
组件,则代码示例如下:
<template>
<aui-slider v-model="value" :min="0" :max="100" :step="10" :range="min"></aui-slider>
</template>
<script>
import {Slider} from '@aurora/vue'
export default {
components: {AuiSlider: Slider},
data() {
return {value: 30}
}
}
</script>
因为 AUI 组件人造反对配置式开发,除了下面的标签式申明,AUI 还提供与上述代码等价的配置式申明:
<template>
<aui-slider v-model="value" :op="op"></aui-slider>
</template>
<script>
import {Slider} from '@aurora/vue'
export default {
components: {AuiSlider: Slider},
data() {
return {
value: 30,
op: {
min: 0,
max: 100,
step: 10,
range: 'min'
}
}
}
}
</script>
可见配置式申明沿用 HAE 的形式,将所有配置信息都放在 op
变量里。以下是这两种申明形式的具体差别:
标签式申明 | 配置式申明 | |
---|---|---|
用法 | 应用一个或多个具备特定性能的标签来定义组件,每个标签都有本人的属性和配置 | 应用单个标签,并通过传递一个蕴含所有配置信息的 JSON 对象来定义组件 |
长处 | 直观易懂,易于了解组件的构造和配置。开发人员能够间接在模板中进行批改和调整 | 配置信息集中在一个对象中,便于整体治理和保护,能够应用变量和动静表达式来动静生成配置信息,灵活性更高 |
毛病 | 如果组件的结构复杂或标签较多,标签式申明可能会显得简短和凌乱 | 对于不相熟配置构造和属性的开发人员来说,可能须要破费一些工夫去了解和编写配置对象 |
开发效率 | 在开发初期可能更疾速,因为能够间接在模板中进行编辑和调试 | 在组件结构复杂或须要动静生成配置信息时,能够缩小反复的标签代码,进步代码复用性和维护性 |
业务场景 | 更实用于动态页面,开发人员更关注组件的构造和外观 | 更实用于动静页面,开发人员更关注组件的数据和行为 |
如果将两者放在特定的业务畛域比拟,比方低代码平台,则配置式申明的劣势更加显著,理由如下:
- 简化 DSL 开发流程 :配置式申明将组件的配置信息集中在一个对象中,低代码 DSL 开发人员能够通过批改对象的属性值来自定义组件的行为和外观。这种形式防止生成繁琐的标签嵌套和属性设置,简化了 DSL 的开发流程。
- 进步配置的可复用性 :配置式申明能够将组件的配置信息形象为一个可重复使用的对象,能够在多个组件实例中共享和复用。低代码平台开发人员能够定义一个通用的配置对象,而后在不同的场景中依据须要进行定制,缩小了反复的代码编写和配置调整。
- 动静生成配置信息 :配置式申明容许低代码平台开发人员应用变量、动静表达式和逻辑管制来低代码组件配置面板生成的配置信息。这样能够依据不同的条件和数据来动静调整组件的配置,加强了组件配置面板的灵活性和适应性。
- 可视化配置界面 :配置式申明通常与可视化配置界面相结合,低代码平台的应用人员能够通过低代码的可视化界面间接批改物料组件的属性值。这种形式使得配置更直观、易于了解,进步了开发效率。
- 适应简单业务场景 :在简单的业务场景中,组件的配置信息可能会十分繁琐和简单。通过配置式申明,低代码物料组件的开发人员能够更不便地治理和保护大量的配置属性,缩小了出错的可能性。
全新架构的 TinyVue 组件库
工夫来到 2019 年,如后面提到的,AUI 进入成熟稳定期,咱们有了工夫去思考如何将 jQuery 的 30 万行代码重构为 Vue 的代码。同年 5 月 16 日,美国商务部将华为列入进口管制“实体名单”,咱们面临前所未有的艰难,保障业务连续性成为咱们首要任务。咱们要做最坏的打算,如果有一天所有的支流前端框架 Angular、React、Vue 都不能再持续应用,那么重构后的 Vue 代码又将何去何从?
因而,咱们组件的外围代码要与支流前端框架解耦,这就要求咱们不仅仅要重构代码,还要从新设计架构。通过一直的打磨和欠缺,领有全新架构的 TinyVue 组件库逐步浮出水面,以下就是 TinyVue 组件的架构图:
在这个架构下,TinyVue 组件有对立的 API 接口,开发人员只需写一份代码,组件就能反对不同终端的展示,比方 PC 端和 Mobile 端,而且还反对不同的 UX 交互标准。借助 React 框架的 Hooks API
或者 Vue 框架的 Composition API
能够实现组件的外围逻辑代码与前端框架解耦,甚至实现一套组件库代码,同时反对 Vue 的不同版本。
接下来,咱们先剖析开发组件库面临的问题,再来探讨面向逻辑编程与无渲染组件,最初以实现一个 TODO 组件为例,来论述咱们的解决方案,通过示例代码展示咱们架构的四个个性:跨技术栈、跨技术栈版本、跨终端和跨 UX 标准。
其中,跨技术栈版本这个个性,曾经为华为外部 IT 带来微小的收益。因为 Vue 框架最新的 3.0 版本不能齐全向下兼容 2.0 版本,而 2.0 版本又将于 2023 年 12 月 31 日达到生命周期终止(EOL)。于是华为外部 IT 所有基于 Vue 2.0 的利用都必须在这个日期之前降级到 3.0 版本,这波及到几千万行代码的迁徙整改,正因为咱们的组件库同时反对 Vue 2.0 和 3.0,使得这个迁徙整改的老本大大降低。
开发组件库面临的问题
目前业界的前端 UI 组件库,个别按其前端框架 Angular、React 和 Vue 的不同来分类,比方 React 组件库,Angular 组件库、Vue 组件库,也能够按面向的终端,比方 PC、Mobile 等不同来分类,比方 PC 组件库、Mobile 组件库、小程序组件库等。两种分类穿插后,又可分为 React PC 组件库、React Mobile 组件库、Angular PC 组件库、Angular Mobile 组件库、Vue PC 组件库、Vue Mobile 组件库等。
比方阿里的 Ant Design 分为 PC 端:Ant Design of React
、Ant Design of Angular
、Ant Design of Vue
,Mobile 端:Ant Design Mobile of React
(官网实现)Ant Design Mobile of Vue
(社区实现)。
另外,因为前端框架 Angular、React 和 Vue 的大版本不能向下兼容,导致不同版本对应不同的组件库。以 Vue 为例,Vue 2.0 和 Vue 3.0 版本不能兼容,因而 Vue 2.0 的 UI 组件库跟 Vue 3.0 的 UI 组件库代码是不同的,即同一个技术栈也有不同版本的 UI 组件库。
比方阿里的 Ant Design of Vue 其 1.x 版本 for Vue 2.0,而 3.x 版本 for Vue 3.0。再比方饿了么的 Element 组件库,Element UI for Vue 2.0,而 Element Plus for Vue 3.0。
咱们将下面不同分类的 UI 组件库汇总在一张图里,而后站在组件库使用者的角度上看,如果要开发一个利用,那么先要从以下组件库中筛选一个,而后再学习和把握该组件库,可见以后多端多技术栈的组件库给使用者带来惨重的学习累赘。
这些 UI 组件库因为前端框架不同、面向终端不同,惯例的解决方案是:不同的开发人员来开发和保护不同的组件库,比方须要懂 Vue 的开发人员来开发和保护 Vue 组件库,须要懂 PC 端交互的开发人员来开发和保护 PC 组件库等等。
很显著,这种解决方案首先须要不同技术栈的开发人员,而市面上大多数开发人员只精通一种技术栈,其余技术栈则只是理解而已。这样每个技术栈就得独立安顿一组人员进行开发和保护,老本天然比繁多技术栈要高得多。另外,因为同一技术栈的版本升级导致的不兼容,也让该技术栈的开发人员必须开发和保护不同版本的代码,使得老本进一步攀升。
面对上述组件开发和保护老本高的问题,业界还有一种解决方案,即以原生 JavaScript 或 Web Component 技术为根底,构建一套与任何开发框架都无关的组件库,而后再依据以后开发框架风行的水平,去适配不同的前端框架。比方 Webix 用一套代码适配任何前端框架,既提供原生 JavaScript 版本的组件库,也提供 Angular、React 和 Vue 版本的组件库。
这种解决方案,其实开发难度更大、保护老本更高,因为这相当于先要自研一套前端框架,相似于咱们以前的 HAE 框架,而后再用不同的前端框架进行套壳封装。显然,套壳封装势必影响组件的性能,而且关闭自研的框架其学习门槛、人力老本要高于支流的开源框架。
面向逻辑编程与无渲染组件
以后支流的前端框架为 Angular、React 和 Vue,它们提供两种不同的开发范式:一种是面向生命周期编程,另一种是面向业务逻辑编程。基于这些前端框架开发利用,页面上的每个局部都是一个 UI 组件或者实例,而这些实例都是由 JavaScript 发明进去的,都具备创立、挂载、更新、销毁的生命周期。
所谓面向生命周期编程,是指基于前端框架开发一个 UI 组件时,依照该框架定义的生命周期,将 UI 组件的相干逻辑代码注册到指定的生命周期钩子函数里。以 Vue 框架的生命周期为例,一个 UI 组件的逻辑代码可能被拆分到 beforeCreate、created、beforeMount、mounted、beforeUnmount、unmounted 等钩子函数里。
所谓面向逻辑编程,是指在前端开发的过程中,尤其在开发大型利用时,为解决面向生命周期编程所引发的问题,提出新的开发范式。以一个文件浏览器的 UI 组件为例,这个组件具备以下性能:
- 追踪以后文件夹的状态,展现其内容
- 解决文件夹的相干操作 (关上、敞开和刷新)
- 反对创立新文件夹
- 能够切换到只展现珍藏的文件夹
- 能够开启对暗藏文件夹的展现
- 解决当前工作目录中的变更
假如这个组件依照面向生命周期的形式开发,如果为雷同性能的逻辑代码标上一种色彩,那将会是下图右边所示。能够看到,解决雷同性能的逻辑代码被强制拆分在了不同的选项中,位于文件的不同局部。在一个几百行的大组件中,要读懂代码中一个性能的逻辑,须要在文件中重复高低滚动。另外,如果咱们想要将一个性能的逻辑代码抽取重构到一个可复用的函数中,须要从文件的多个不同局部找到所需的正确片段。
如果用面向逻辑编程重构这个组件,将会变成上图左边所示。能够看到,与同一个性能相干的逻辑代码被归为了一组:咱们无需再为了一个性能的逻辑代码在不同的选项块间来回滚动切换。此外,咱们能够很轻松地将这一组代码移动到一个内部文件中,不再须要为了形象而从新组织代码,从而大大降低重构老本。
早在 2018 年 10 月,React 推出了 Hooks API
,这是一个重要的里程碑,对前端开发人员乃至社区生态都产生了深远的影响,它扭转了前端开发的传统模式,使得函数式组件成为构建简单 UI 的首选形式。到了 2019 年初,Vue 在研发 3.0 版本的过程中也参考了 React 的 Hooks API
,并且为 Vue 2.0 版本增加了相似性能的 Composition API
。
过后咱们正在布局新的组件架构,在理解 Vue 的 Composition API
后,意识到这个 API 的重要性,它就是咱们始终寻找的面向逻辑编程。同时,咱们也发现业界有一种新的设计模式 —— 无渲染组件,当咱们尝试将两者联合在一起,之前面临的问题随即迎刃而解。
无渲染组件其实是一种设计模式。假如咱们开发一个 Vue 组件,无渲染组件是指这个组件自身并没有本人的模板(template)以及款式。它装载的是各种业务逻辑和状态,是一个将性能和款式拆开并针对性能去做封装的设计模式。这种设计模式的劣势在于:
- 逻辑与 UI 拆散 :将逻辑和 UI 拆散,使得代码更易于了解和保护。通过将逻辑解决和数据转换等工作形象成无渲染组件,能够将关注点拆散,进步代码的可读性和可维护性。
- 进步可重用性 :组件的逻辑能够在多个场景中重用。这些组件不依赖于特定的 UI 组件或前端框架,能够独立于界面进行测试和应用,从而进步代码的可重用性和可测试性。
- 合乎繁多职责准则 :这种设计激励遵循繁多职责准则,每个组件只负责特定的逻辑或数据处理工作。这样的设计使得代码更加模块化、可扩大和可保护,缩小了组件之间的耦合度。
- 更好的可测试性 :因为无渲染组件独立于 UI 进行测试,能够更容易地编写单元测试和集成测试。测试能够专一于组件的逻辑和数据转换,而无需关注界面的渲染和交互细节,进步了测试的效率和可靠性。
- 进步开发效率 :开发人员能够更加专一于业务逻辑和数据处理,而无需关怀具体的 UI 渲染细节。这样能够进步开发效率,缩小反复的代码编写,同时也为团队合作提供了更好的可能性。
比方下图的示例,两个组件 TagsInput A
和 TagInput B
都有类似的性能,即提供 Tags 标签录入、删除已有标签两种能力。尽管它们的外观截然不同,然而录入标签和删除标签的业务逻辑是雷同的,是能够复用的。无渲染组件的设计模式将组件的逻辑和行为与其外观展示拆散。当组件的逻辑足够简单并与它的外观展示解耦时,这种模式十分无效。
单纯应用面向逻辑的开发范式,仅仅只能让雷同的业务逻辑从本来散落到生命周期各个阶段的局部汇聚到一起。无渲染组件的设计模式的实现形式有很多种,比方 React 中能够应用 HOC 高阶函数,Vue 中能够应用 scopedSlot 作用域插槽,但当组件业务逻辑日趋简单时,高阶函数和作用域插槽会让代码变得难以了解和保护。
要实现组件的外围逻辑代码与前端框架解耦,实现跨端跨技术栈,须要同时联合面向逻辑的开发范式与无渲染组件的设计模式。首先,依照面向逻辑的开发范式,通过 React 的 Hooks API
,或者 Vue 的 Composition API
,将与前端框架无关的业务逻辑和状态拆离成绝对独立的代码。接着,再应用无渲染组件的设计模式,将组件不同终端的外观展示,对立连贯到曾经拆离绝对独立的业务逻辑。
跨端跨技术栈 TODO 组件示例
接下来,咱们以开发一个 TODO 组件为例,解说基于新架构的组件如何实现跨端跨技术栈。假如该组件 PC 端的展现成果如下图所示:
对应 Mobile 端的展现成果如下图所示:
该组件的性能如下:
- 增加待办事项:在输入框输出待办事项信息,点击左边的 Add 按钮后,上面待办事项列表将新增一项刚输出的事项信息。
- 删除待办事项:在待办事项列表里,抉择其中一个事项,点击左边的 X 按钮后,该待办事项将从列表里革除。
- 挪动端展现:当屏幕宽度放大时,组件将主动切换成如下 Mobile 的展现模式,性能依然放弃不变,即输出内容间接按回车键增加事项,点击 X 删除事项。
这个 TODO 组件的实现分为 Vue 版本和 React 版本,即反对两个不同的技术栈。以上个性都复用一套 TODO 组件的逻辑代码。这套 TODO 组件的逻辑代码以柯里化函数模式编写。柯里化(英文叫 Currying)是把承受多个参数的函数变换成承受一个繁多参数(最后函数的第一个参数)的函数,并且返回承受余下的参数且返回后果的新函数的技术。举一个简略的例子:
// 一般函数
var add = function(x, y) {return x + y}
add(3, 4) // 返回 7
// 柯里化函数
var foo = function(x) {return function(y) {return x + y}
}
foo(3)(4) // 返回 7
原本应该一次传入两个参数的 add
函数,柯里化函数变成先传入 x 参数,返回一个蕴含 y 参数的函数,最终执行两次函数调用后返回雷同的后果。一般而言,柯里化函数都是返回函数的函数。
回到 TODO 组件,依照无渲染组件的设计模式,首先写出不蕴含渲染实现代码,只蕴含纯业务逻辑代码的函数,以 TODO 组件的增加和删除两个性能为例,如下两个柯里化函数:
/**
* 增加一个标签,给定一个 tag 内容,往已有标签汇合里增加该 tag
*
* @param {object} text - 输入框控件绑定数据
* @param {object} props - 组件属性对象
* @param {object} refs - 援用元素的汇合
* @param {function} emit - 抛出事件的办法
* @param {object} api - 裸露的 API 对象
* @returns {boolean} 标签是否增加胜利
*/
const addTag = ({text, props, refs, emit, api}) => tag => {
// 判断 tag 内容是否为字符串,如果不是则取输入框控件绑定数据的值
tag = trim(typeof tag === 'string' ? tag : text.value)
// 查看已存在的标签汇合里是否蕴含新 tag 的内容
if (api.checkTag({ tags: props.tags, tag})) {
// 如果已存在则返回增加失败
return false
}
// 从组件属性对象获取标签汇合,往汇合里增加新 tag 元素
props.tags.push(tag)
// 清空输入框控件绑定数据的值
text.value = ''
// 从援用元素汇合里找到输出控件,让其取得焦点
refs.input.focus()
// 向外抛出事件,告知已增加新标签
emit('add', tag)
// 返回标签增加胜利
return true
}
/**
* 移除一个标签,给定一个 tag 内容,从已有标签汇合里移除该 tag
*
* @param {object} props - 组件属性对象
* @param {object} refs - 援用元素的汇合
* @param {function} emit - 抛出事件的办法
* @returns {boolean} 标签是否增加胜利
*/
const removeTag = ({props, refs, emit}) => tag => {
// 从组件属性对象获取标签汇合,在汇合里查找 tag 元素的地位
const index = props.tags.indexOf(tag)
// 如果地位不是 -1,则示意能在汇合里找到对应的地位
if (index !== -1) {
// 从组件属性对象获取标签汇合,在汇合的相应地位移除该 tag 元素
props.tags.splice(index, 1)
// 从援用元素汇合里找到输出控件,让其取得焦点
refs.input.focus()
// 向外抛出事件,告知已删除标签
emit('remove', tag)
// 返回标签移除胜利
return true
}
// 如果找不到则返回删除失败
return false
}
// 向下层裸露业务逻辑办法
export {
addTag,
removeTag
}
能够看到这两个组件的逻辑函数,没有内部依赖,与技术栈无关。这两个逻辑函数会被组件的 Vue 和 React 的 Renderless 函数调用。其中 Vue 的 Renderless 函数局部代码如下:
// Vue 适配层,负责承前启后,即引入上层的业务逻辑办法,主动结构规范的适配函数,提供给下层的模板视图应用
import {addTag, removeTag, checkTag, focus, inputEvents, mounted} from 'business.js'
/**
* 无渲染适配函数,依据 Vue 框架的差异性,为业务逻辑办法提供所需的原材料
*
* @param {object} props - 组件属性对象
* @param {object} context - 页面上下文对象
* @param {function} value - 结构双向绑定数据的办法
* @param {function} onMounted - 组件挂载时的办法
* @param {function} onUpdated - 数据更新时的办法
* @returns {object} 返回提供给下层模板视图应用的 API
*/
export const renderless = (props, context, { value, onMounted, onUpdated}) => {
// 通过页面上下文对象获取父节点元素
const parent = context.parent
// 通过父节点元素获取输入框控件绑定数据
const text = parent.text
// 通过父节点元素获取其上下文对象,再拿到抛出事件的办法
const emit = parent.$context.emit
// 通过页面上下文对象获取援用元素的汇合
const refs = context.refs
// 以上为业务逻辑办法提供所需的原材料,根本是固定的,不同框架有所区别
// 初始化输入框控件绑定数据,如果没有定义则设置为空字符串
parent.text = parent.text || value('')
// 结构返回给下层模板视图应用的 API 对象
const api = {
text,
checkTag,
focus: focus(refs),
// 第一次执行 removeTag({props, refs, emit}) 返回一个函数,该函数用来给模板视图的 click 事件
removeTag: removeTag({props, refs, emit})
}
// 在组件挂载和数据更新时须要解决的办法
onMounted(mounted(api))
onUpdated(mounted(api))
// 与后面定义的 API 对象内容进行合并,新增 addTag 和 inputEvents 办法
return Object.assign(api, {// 第一次执行 addTag({ text, props, refs, emit, api}) 返回一个函数,该函数用来给模板视图的 click 事件
addTag: addTag({text, props, refs, emit, api}),
inputEvents: inputEvents({text, api})
})
}
React 的 Renderless 函数局部代码如下,这与 Vue 十分相似:
import {addTag, removeTag, checkTag, focus, inputEvents, mounted} from 'business.js'
export const renderless = (props, context, { value, onMounted, onUpdated}) => {const text = value('')
const emit = context.emit
const refs = context.refs
const api = {
text,
checkTag,
focus: focus(refs),
removeTag: removeTag({props, refs, emit})
}
onMounted(mounted(api))
onUpdated(mounted(api), [context.$mode])
return Object.assign(api, {addTag: addTag({ text, props, refs, emit, api}),
inputEvents: inputEvents({text, api})
})
}
能够看到,TODO 组件的两个逻辑函数 addTag
和 removeTag
都有被调用,别离返回两个函数并赋值给 api
对象的两个同名属性。而这个技术栈适配层代码里的 Renderless 函数,不蕴含组件逻辑,只用来抹平不同技术栈的差别,其外部依照面向业务逻辑编程的形式,别离调用 React 框架的 Hooks API
与 Vue 框架的 Composition API
,这里要保障组件逻辑 addTag
和 removeTag
的输入输出对立。
上述 Vue 和 React 适配层的 Renderless 函数会被与技术栈强相干的 Vue 和 React 组件模板代码所援用,只有这样能力充分利用各支流前端框架的能力,防止反复造框架的轮子。以下是 Vue 页面援用 Vue 适配层 Renderless 函数的代码:
import {renderless, api} from '../../renderless/Todo/vue'
import {props, setup} from '../common'
export default {props: [...props, 'newTag', 'tags'],
components: {TodoTag: () => import('../Tag')
},
setup(props, context) {return setup({ props, context, renderless, api})
}
}
React 页面援用 React 适配层 Renderless 函数,代码如下所示:
import {useRef} from 'react'
import {renderless, api} from '../../renderless/Todo/react'
import {setup, render, useRefMapToVueRef} from '../common/index'
import pc from './pc'
import mobile from './mobile'
import '../../theme/Todo/index.css'
export default props => {const { $mode = 'pc', $template, $renderless, listeners = {}, tags } = props
const context = {
$mode,
$template,
$renderless,
listeners
}
const ref = useRef()
useRefMapToVueRef({context, name: 'input', ref})
const {addTag, removeTag, inputEvents: { keydown, input}, text: {value} } = setup({context, props, renderless, api, listeners, $renderless})
return render({$mode, $template, pc, mobile})({addTag, removeTag, value, keydown, input, tags, ref, $mode})
}
至此已实现 TODO 组件反对跨技术栈、复用逻辑代码。依据无渲染组件的设计模式,后面曾经拆散组件逻辑,当初还要反对组件不同的外观。TODO 组件要反对 PC 端和 Mobile 两种外观展现,即组件构造反对 PC 端和 Mobile 端。所以咱们在 Vue 里要拆分为两个页面文件,别离是 pc.vue
和 mobile.vue
,其中 pc.vue
文件里的 template 组件构造如下:
<template>
<div align="center">
<slot name="header"></slot>
<div align="left" class="max-w-md w-full mx-auto">
<div class="form-group d-flex">
<input ref="input" :value="text" :placeholder="newTag" v-on="inputEvents" class="aui-todo aui-font border border-primary shadow-none rounded-0 d-inline todo-input">
<button class="btn btn-primary shadow-none border-0 rounded-0" @click="addTag">Add</button>
</div>
<div class="list-group">
<div class="list-group-item d-flex justify-content-between align-items-center" v-for="tag in tags" :key="tag">
<todo-tag :$mode="$mode" :content="tag" />
<button class="close shadow-none border-0" @click="removeTag(tag)">
<span>×</span>
</button>
</div>
</div>
</div>
<slot name="footer"></slot>
</div>
</template>
而 mobile.vue
文件里的 template 组件构造如下:
<template>
<div class="todo-mobile" align="center">
<slot name="header"></slot>
<div align="left" class="max-w-md w-full mx-auto">
<div class="tags-input">
<span class="tags-input-tag" v-for="tag in tags" :key="tag">
<todo-tag :$mode="$mode" :content="tag" />
<button type="button" class="tags-input-remove" @click="removeTag(tag)">×</button>
</span>
<input ref="input" :value="text" :placeholder="newTag" v-on="inputEvents" class="aui-todo aui-font tags-input-text">
</div>
</div>
<slot name="footer"></slot>
</div>
</template>
由上可见,PC 端和 Mobile 的组件构造尽管不一样,然而都援用雷同的接口,这些接口就是 TODO 组件逻辑函数输入的内容。
同理,React 也分为两个页面文件,别离是 pc.jsx
和 mobile.jsx
,其中 pc.jsx
文件里的 template 组件构造如下:
import React from 'react'
import Tag from '../Tag'
export default props => {const { addTag, removeTag, value, keydown, input, tags, ref, $mode} = props
return (
<div align="left" className="max-w-md w-full mx-auto">
<div className="form-group d-flex">
<input ref={ref} value={value} onChange={input} onKeyDown={keydown} placeholder="New Tag" type="text" className="aui-todo aui-font border border-primary shadow-none rounded-0 d-inline todo-input" />
<button className="btn btn-primary shadow-none border-0 rounded-0" onClick={addTag}>Add</button>
</div>
<div className="list-group">
{tags.map(tag => {
return (<div key={tag} className="list-group-item d-flex justify-content-between align-items-center">
<Tag content={tag} $mode={$mode} />
<button className="close shadow-none border-0" onClick={() => { removeTag(tag) }}>
<span>×</span>
</button>
</div>
)
})}
</div>
</div >
)
}
而 mobile.jsx
文件里的 template 组件构造如下:
import React from 'react'
import Tag from '../Tag'
import '../../style/mobile.scss'
export default props => {const { removeTag, value, keydown, input, tags, ref, $mode} = props
return (
<div className="todo-mobile" align="center">
<div align="left" className="max-w-md w-full mx-auto">
<div className="tags-input">
{tags.map(tag => {
return (<span key={tag} className="tags-input-tag" >
<Tag content={tag} $mode={$mode} />
<button type="button" className="tags-input-remove" onClick={() => { removeTag(tag) }}>×</button>
</span >
)
})}
<input ref={ref} value={value} onChange={input} onKeyDown={keydown} placeholder="New Tag" className="aui-todo aui-font tags-input-text" />
</div>
</div>
</div>
)
}
由上可见,Vue 和 React 的 PC 端及 Mobile 端的构造根本一样,次要是 Vue 和 React 的语法区别,因而同时开发和保护 Vue 和 React 组件构造的老本并不高。以下是 TODO 组件示例的全景图:
回顾一下咱们开发这个 TODO 组件的步骤,次要分为三步:
- 按无渲染组件的设计模式,首先要将组件的逻辑拆散成与技术栈无关的柯里化函数。
- 在定义组件的时候,借助面向逻辑编程的 API,比方 React 框架的
Hooks API
、Vue 框架的Composition API
,将组件外观与组件逻辑齐全解耦。 - 按不同终端编写对应的组件模板,再利用前端框架提供的动静组件,实现动静切换不同组件模板,从而满足不同外观的展现需要。
总结
尽管在 HAE 自研阶段,咱们实现的数据双向绑定、面向对象的 JS 库、配置式开发的注册表等个性,随着前端技术的高速倒退当初曾经失去存在的意义,然而在 AUI 阶段摸索的新思路新架构,通过大量的业务落地验证,再次推动前端畛域的翻新。TinyVue 继承了 HAE、AUI 的基因,所有的新技术都从业务中来,到业务中去。而且,在这个过程中,咱们通过一直排汇、交融开源社区的最佳实际和翻新,一直晋升本身的外围竞争力。
开源文化在不仅在前端技术,甚至整个软件行业都已失去宽泛承受和遍及,咱们也在开源我的项目中受益匪浅,TinyVue 从自研走向开源也是适应了这一开源文化的趋势。目前 TinyVue 已于 2023 年初正式开源(https://opentiny.design/)。咱们心愿通过开源,让华为内外的开发者能够独特合作改良和欠缺咱们的组件库,共享华为积淀多年的常识和教训。咱们心愿通过开源,与社区用户独特摸索和试验新的技术、框架和模式,推动前端畛域的翻新。咱们心愿通过开源,为开发者提供更多抉择和灵活性,让整个前端社区都能受害。
欢送退出 OpenTiny 开源社区。增加微信小助手:opentiny-official 一起参加共建~
OpenTiny 官网:https://opentiny.design/
OpenTiny 代码仓库:https://github.com/opentiny/
欢送进入 OpenTiny 代码仓库 Star🌟TinyVue、TinyNG、TinyCLI~