题要
在参与【腾讯课堂,暑期早起团】活动开发的过程中,涉及到了课程卡片的展示。具体效果如下:
图中每个白底的框框是一个课程卡片,课程卡片组合在一起形成课程面板。本文主要记录对课程卡片和课程面板的优化过程。包含 "原始版本 > 优化组件step1 > 优化组件step2 > 优化组件step3",每一步都有相应的目录和代码说明,最后的step3给出了完整的js代码,所以本文中代码较多。。。
原始版本
最初的实现形式也是想进行组件化,主要文件和目录如下:
代码语言:javascript复制./src
├── js
│ └── main.js
├── css
│ └── main.scss
├──lego_modules //组件目录
│ └──courseCard //课程面板组件
│ ├── _courseCard.scss //组件样式
│ ├── courseCard.js //组件逻辑
│ └── courseCard.tpl //组件模板
├── fis-config.js //FIS配置文件
└──index.html
最初的组件实现和调用方式如下:
代码语言:javascript复制//组件实现概要 -- courseCard.js
var $container = $('#course-wrapper'),
boundEvents = false;
function bindEvents() {
if (boundEvents) { //防止重复绑定事件
return;
}
$(document).on('changeNav', function(newCourseList) { //changeNav是自定义事件,在课程面板上面的tab切换的时候触发
render(newCourseList);
})
}
function render(courseList) { //渲染模板
$container.html(tpl(courseList));
}
function init(courseList) { // 初始化
render(courseList);
bindEvents();
}
module.exports = { //对外接口
init: init
};
/********************* 分割线 *********************/
// 组件调用 -- main.js
var CourseCard = require('courseCard');
CourseCard.init(courseList);
$(document).trigger('changeNav', newCourseList)//课程面板内容切换
当前的组件实现存在的问题:
- 可配置参数不够。导致组件与其它组件(changeNav事件需要与“标题tab组件”切换时触发的事件对接),组件与调用环境(需要原html中有id为course-wrapper的空div标签)耦合比较严重。
- 可操作的接口不够。在
CourseCard.init(courseList);
执行完后无返回值,也无后续操作(添加/删除一个课程卡片、隐藏/显示/销毁组件对象);
组件优化step1 >
针对原始版本的两个问题,我尝试的第一步优化是拓展组件的可配置参数,以及对象提供的接口。本步优化后的代码如下:
代码语言:javascript复制//组件实现概要 -- courseCard.js
var $container,
boundEvents = false,
opts = { //默认配置
container: '#course-wrapper', //组件渲染目标DOM
courseList: [] //保存课程列表,
onclick: function(item) { // 点击单个课程时的回调,可在其中执行跳转、上报等操作
}
};
function bindEvents() {
if (boundEvents) { //防止重复绑定事件
return;
}
$container.on('click', '.c-main', function(){
opts.onclick(this); //点击单个课程卡片回调函数
})
}
function render(courseList) { //渲染模板
$container.html(tpl(courseList));
}
function init(extOpts) { // 初始化
$container = $(opts.container);
$.extend(opts, extOpts); //传入参数
render(opts.courseList);
bindEvents();
return this; //链式调用
}
module.exports = { //对外接口
init: init,
reload: function(newCourseList) { //通过提供reload方法来更新组件列表,取代之前的与其它组件耦合的changeNav事件
render(newCourseList);
return this;
},
add: function(course) { //添加单个课程
opts.courseList.push(course);
$container.append(tplSingle(course)); //需要用到单课程卡片模板文件
return this;
},
hide: function() {
$container.toggle(false);
return this;
},
show: function() {
$container.toggle(true);
return this;
}
// ...还有delete, find, getCourseList等接口这里不详细展示
};
/********************* 分割线 *********************/
// 组件调用 -- main.js
var CourseCard = require('courseCard');
var card = CourseCard.init({
container: '#course-wrapper',
courseList: courseList,
onclick: function(item){
report(item);
jumpToCourse(item);
}
});
card.hide();
.show();
.add(cardItem);
.reload(newCardList);
为了实现添加单个课程卡片到课程面板中,这里在目录中新加了单个课程卡片的tpl模板文件:singleCourse.tpl,文件的内容是从courseCard.tpl中的循环体中提取出的单个<li>
标签,课程卡片目录的结构变为:
courseCard //课程面板组件
├── _courseCard.scss //组件样式
├── courseCard.js //组件逻辑
├── singleCourse.tpl //**新增** 单个课程卡片模板
└── courseCard.tpl //组件模板
本步的优化解决上原始版本中的问题,将与外界存在耦合的$container
变量和changeNav
事件,分别通过参数配置和新增接口的形式来消除。但是经过与jero的交流,发现这一步优化后的组件仍然有如下问题:
- 输出(module.exports)的组件为单例,不能实现同时存在多个courseCard组件(全局变量$container、opts等等是公用的)。
组件优化 step2 >
为了解决上一步的问题,这里尝试将组件模式从返回单例,改为能过构造函数来创建的模式。代码调整如下:
代码语言:javascript复制//组件实现概要 -- courseCard.js
var default_opts = { //默认配置
container: '#course-wrapper', //组件渲染目标DOM
courseList: [] //保存课程列表,
onclick: function(item) { // 点击单个课程时的回调,可在其中执行跳转、上报等操作
}
};
function courseCard(extOpts) {
this.opts = $.extend({}, default_opts, extOpts);
this.$container = $(this.opts.container);
this.inited = false;
}
function bindEvents() {
var that = this;
$container.on('click', '.c-main', function(){
that.opts.onclick(this); //点击单个课程卡片回调函数
})
}
function render(courseList) { //渲染模板
this.$container.html(tpl(this.opts.courseList));
}
function init(extOpts) { // 初始化
if (this.inited) {
return this;
}
render.call(this);
bindEvents.call(this);
this.inited = true;
return this; //链式调用
}
$.extend(courseCard.prototype, { //原型上的对外接口
init: init,
reload: function(newCourseList) { //通过提供reload方法来更新组件列表,取代之前的与其它组件耦合的changeNav事件
render(newCourseList);
return this;
},
add: function(course) { //添加单个课程
this.opts.courseList.push(course);
this.$container.append(tplSingle(course)); //需要用到单课程卡片模板文件
return this;
},
hide: function() {
this.$container.toggle(false);
return this;
},
show: function() {
this.$container.toggle(true);
return this;
}
// ...还有delete, find, getCourseList等接口这里不详细展示
});
/********************* 分割线 *********************/
// 组件调用 -- main.js
var CourseCard = require('courseCard');
var card = new CourseCard({
container: '#course-wrapper',
courseList: courseList,
onclick: function(item){
report(item);
jumpToCourse(item);
}
}).init();
card.hide();
.show();
.add(cardItem);
.reload(newCardList);
本步的优化将组件的构建模式从单例对象转换为了构造函数创建的方式。将$container、opts等全局变量转变为了对象的属性。在本步优化完成后,与jero和lqlong交流后发现有如下问题:
- 单课程卡片的模板与课程面板的模板有冗余。组合考虑jero和lqlong的意见后,理想实现应该是将单个课程卡片也提取为组件,然后在课程面板中调用单课程卡片组件。
- 需要一个组件基类来承载组件的常用属性和方法。进而从基类扩展出其它组件。
组件优化 step3 >
本步的更改比较大,首先lego_modules目录的更改如下:
代码语言:javascript复制lego_modules //组件目录
├──componentBase //组件基类
│ └── componentBase.js
├──courseCard //单课程卡片组件,之前课程面板叫这个名字并不合适
│ ├── _courseCardscss
│ ├── courseCard.js
│ └── courseCard.tpl
└──coursePannel //课程面板组件,这里名字改成这个更合适
├── _coursePannel.scss
├── coursePannel.js
└── coursePannel.tpl
以上目录结构中,componentBase是简单实现的组件基类,具有同类型组件一些通用的属性($container)、配置(opts)和方法(hide,show,init)。 courseCard现在为单课程卡片的组件,可以单独使用,效果是渲染出单个课程卡片append到$container中。也可以被coursePannel课程面板组件使用,添加多个到课程面板中。 现在的courseCard和coursePannel的scss样式和tpl模板是从之前的courseCard里分离提取出来的。 -- 基类 componentBase
代码语言:javascript复制// componentBase.js
function ComponentBase(params, data) { //基类默认配置和属性
this.opts = {
container: '',
onInit: function() {
}
};
$.extend(this.opts, params);
this.inited = false;
this.$container = null;
this.init(data);
}
ComponentBase.prototype = { //基类原型方法
constructor: ComponentBase, //修复constructor
show: function() {
if (this.inited){
this.$container.toggle(true);
}
return this;
},
hide: function() {
if (this.inited){
this.$container.toggle(false);
}
return this;
},
init: function(data) {
if (!this.inited){
this.$container = $(this.opts.container);
this.inited = true;
}
return this;
},
destroy: function() {
if (this.inited){
this.$container.empty();
this.opts = null;
this.$container = null;
}
},
/**
* 扩展出子类(组件)
* @param {Object} extOpts {
* special: Object, //扩展配置属性this.opts
* share: Object //扩展原型
* }
* @return {Function} 子类(组件)
*/
extend: function(extOpts) {
var base = this;
var ComponentChild = function(params, data) {
// 继承父类,子类可继续作为父类向下继承
base.call(this, $.extend((extOpts && extOpts.special) || {}, params), data);
}
ComponentChild.prototype = Object.create(base.prototype); // 继承父类原型
$.extend(ComponentChild.prototype, (extOpts && extOpts.share) || {}); // 扩展配置参数中的原型
ComponentChild.prototype._super = base.prototype; // 保留父类方法调用入口
ComponentChild.prototype.constructor = ComponentChild; // 修复原型上的constructor
ComponentChild.extend = ComponentChild.prototype.extend; // 可以直接在构造函数上调用extend
return ComponentChild;
}
};
ComponentBase.extend = ComponentBase.prototype.extend; // 可以直接在构造函数上调用extend
module.exports = ComponentBase;
--单课程卡片组件 courseCard
代码语言:javascript复制// courseCard.js
var tpl = require('courseCard.tpl'),
ComponentBase = require('componentBase'),
$ = require('zepto');
var CourseCard = ComponentBase.extend({ //继承组件基类
special: { //配置属性
courseInfo: {},
/**
* @param {Object Array} cardArray - 初始化课程数组
* @description 初始化课程卡片组件时操作,在渲染到dom之前执行
*/
onInit: function(courseInfo) {
},
/**
* @param {event} e - 点击事件
* @param {Object} courseInfo - 被点击课程信息
* @description 点击某个课程卡片后的回调函数
*/
onCourseClick: function(e, courseInfo) {
}
},
share: { //原型
/**
* @param {Object} courseInfo - 初始化课程对象
* @return {Object} this
* @description 组件初始化
*/
init: function(courseInfo) {
if (!this.inited){
this._super.init.call(this);
this.opts.courseInfo = $.extend({}, courseInfo);
this._renderCourse();
this.opts.onInit(courseInfo);
this._bindEvents();
}
return this;
},
/**
* @return {Object} 课程对象
* @description 获取课程对象
*/
getCourseInfo: function() {
if (this.inited){
return this.opts.courseInfo;
}
},
/**
* @description 删除本课程卡片
*/
destroy: function() {
if (this.inited) {
this.$self.remove();
this.opts = null;
}
},
/**
* @description 渲染课程卡片
*/
_renderCourse: function() {
if (this.inited) {
this.$self = $(tpl({
course: this.opts.courseInfo
}));
this.$container.append(this.$self);
}
},
/**
* @description 绑定事件
*/
_bindEvents: function() {
var self = this;
this.$container.on('click', '.c-main', function(e) {
var $this = $(this),
id = $this.data('id');
$this.addClass('active');
setTimeout(function() {
$this.removeClass('active');
}, 300);
self.opts.onCourseClick(e, self.opts.courseInfo);
});
}
}
});
module.exports = CourseCard;
--课程面板组件 coursePannel
代码语言:txt复制// coursePannel.js
var tpl = require('coursePannel.tpl'),
ComponentBase = require('componentBase'),
CourseCard = require('courseCard'),
$ = require('zepto');
var CoursePannel = ComponentBase.extend({ //继承组件基类
special: { //属性
courseInfoList: [],
/**
* @param {Object Array} cardArray - 初始化课程数组
* @description 初始化课程卡片组件时操作,在渲染到dom之前执行
*/
onInit: function(cardArray) {
},
/**
* @param {Object Array} oldList - 旧课程对象列表
* @description 重新加载课程列表后执行的回调函数
*/
onReload: function(oldList) {
},
/**
* @param {Object} courseInfo - 新增课程对象
* @description 新增课程对象后执行的回调函数
*/
onAdd: function(courseInfo) {
},
/**
* @param {Object} courseInfo - 被删除课程信息
* @description 删除某个课程后的回调函数
*/
onDel: function(courseInfo) {
},
/**
* @param {event} e - 点击事件
* @param {Object} courseInfo - 被点击课程信息
* @description 点击某个课程卡片后的回调函数
*/
onCourseClick: function(e, courseInfo) {
}
},
share: { //原型
/**
* @param {Object Array} cardArray - 初始化课程数组
* @return {Object} this
* @description 组件初始化
*/
init: function(cardArray) {
if (!this.inited){
this._super.init.call(this);
this.opts.courseInfoList = cardArray.slice(0);
this._renderCourse();
this.opts.onInit(cardArray);
}
return this;
},
/**
* @param {Object Array} newList - 新课程对象数组
* @return {Object} this
* @description 用新的课程列表来渲染课程卡片
*/
reload: function(newList) {
if (this.inited){
var oldList = this.opts.courseInfoList.slice(0);
this.opts.courseInfoList = newList.slice(0);
this._renderCourse();
this.opts.onReload(oldList);
}
return this;
},
/**
* @param {Number} id - 要查找的课程的id
* @return {Object} 查找到的课程对象
* @description 查找课程对象
*/
findCourse: function(id) {
if (this.inited){
for (var i = 0; i < this.opts.courseInfoList.length; i ) {
if (id === parseInt(this.opts.courseInfoList[i].course_id)) {
return this.opts.courseInfoList[i];
}
}
}
return null;
},
/**
* @return {Object Array} 课程对象数组
* @description 获取课程对象列表
*/
getCourseList: function() {
if (this.inited){
return this.opts.courseInfoList;
}
},
/**
* @param {Object} courseInfo - 添加课程对象
* @return {Object} this
* @description 在最后添加一个课程并且在dom上更新
*/
addCourse: function(courseInfo) {
if (this.inited){
var courseItem;
courseItem = new CourseCard({
container: '#js-courses',
onCourseClick: this.opts.onCourseClick
}, courseInfo);
courseInfo.$item = courseItem;
this.opts.courseInfoList.push(courseInfo);
this.opts.onAdd(courseInfo);
}
return this;
},
/**
* @param {Number} id - 删除课程对象
* @return {Object} this
* @des
本步的优化做了以下几件事:
- 实现组件基类,基类中定义了组件应该具有的基本属性和方法,通过基类扩展出来的子类保留有基类的基本特性(可覆盖),并能像基类一样再扩展出子类;
- 将单课程卡片提取为组件,课程面板组件调用单课程卡片组件来实现。courseCard和coursePannel都基于componentBase扩展而来。
总结
从原始版本的组件到组件优化step3(当前版本),有如下感想和收获:
- 更加理解了组件化开发的重要性,以及好的组件应该具备哪些特性:
1.内部实现(包括依赖)对使用者透明; 2.提供的接口足够灵活(方便配置); 3.有完备的文档或者注释(方便使用或二次开发); 4.去耦合(组件内部,包括css,js,html,不要包含组件外部元素的操作,除了组件内部明确声明引入的依赖之外)。
- 熟练了面向对象的一些操作。后话 -- 组件模式初探 到目前的组件版本,并没有结束,只是开始。当组件基类出现的时候,实际上已经出现了组件模式的概念。我目前所理解的组件模式是这样的:
组件模式,是一组包含组件的定义、调用、通信和构建的规范。同一种组件模式中的组件可以很方便地配合,并在项目中以相同的方式调用、组合。
组件的规范可在组件实现时通过代码风格和格式来约束,也可通过基类扩展来强制规范。所以,当组件都是通过同一个基类扩展而来时,在那个基类上就可以很方便地统一组件规范,进而形成组件模式。当组件模式被大众认可,并有人愿意按照这种模式来使用甚至开发新组件时,就形成了组件生态。 目前前端没有原生的组件模式,而组件模式在实际开发中又是很有必要的,所以我们只能按照自己的需求,定义出(或者选择已有 的)适用于自己项目的组件模式,这种自定义的组件模式通常需要搭配依赖分析(amd,commonjs等)和构建工具(FIS, grunt等)来落地实现。 后续将学习现有的不同的组件模式(积木系统,KISSY,Ques,Polymer),并初步探索Web Components。