为了明确我们要解决什么问题,我们不妨从实际案例入手,这样显得更直观:
假设我们用jQuery实现上面这样一个商品列表,大概代码如下:
<table class="goods-list">
<thead>
<tr>
<th>商品名称</th>
<th>商品分类</th>
<th>商品价格</th>
<th>商品规格</th>
<th>操作</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
init()
function init () {
getGoodsData()
}
function getGoodsData () {
// 生成数据的代码,省略
}
function createTable (goodsList) {
let $goodsTableBody = $('.goods-list tbody')
$goodsTableBody.find('*').remove()
$.each(goodsList, function (i, d) {
let $specList = $('<ul>').addClass('spec-list-wrap')
$.each(d.specs, function (i, spec) {
let $item = $('<li>').addClass('spec-list').append(
$('<div>').addClass('item').append(
$('<div>').addClass('title').html(spec.name)
)
)
$.each(spec.value, function (i, item) {
$item.find('.item').append(
$('<div>').addClass('value').html(item.value)
)
})
$specList.append(
$item
)
})
$goodsTableBody.append(
$('<tr>').append(
$('<td>').append(
d.isNameEdit ? $('<input>').addClass('name-input').val(d.name) : $('<div>').addClass('name').html(d.name)
).append(
$('<button>').html(d.isNameEdit ? '完成' : '编辑').click(function () {
if (d.isNameEdit) {
// 发送请求改名字,此处模拟一下
var _this = this
setTimeout(function () {
d.name = $(_this).parent().find('.name-input').val()
d.isNameEdit = !d.isNameEdit
createTable(goodsList)
}, 1000)
} else {
d.isNameEdit = !d.isNameEdit
createTable(goodsList)
}
})
)
).append(
$('<td>').html(d.category.name)
).append(
$('<td>').html(d.price / 1000)
).append(
$('<td>').append(
$specList
)
).append(
$('<td>').append(
$('<button>').html(d.isEnable ? '下架' : '上架').click(function () {
let res = prompt('您确实要将该商品' + (d.isEnable ? '下架' : '上架') + '吗?')
if (res !== null) {
setTimeout(function () {
d.isEnable = !d.isEnable
createTable(goodsList)
}, 1000)
}
})
)
)
)
})
}
这种开发模式下,最明显的问题就是DOM相关的操作过于繁琐,具体体现为:
1、不断获取节点、不停给节点起名字:
由于要找到HTML中某个节点创建一堆元素往里放,所以需要频繁的获取节点、给节点命名
2、dom操作繁琐
dom对象在创建时往往要经过多层遍历,像上面的例子中,我们在遍历goodsList生成table的时候,遍历到商品规格字段发现它很复杂,于是在遍历函数最开始单独做了若干层循环处理好一个$specList,再把它插入,这种操作极其影响后期的可读性
3、条例不清晰
得益于jQuery的连用写法,我们可以创建完dom之后,为了偷懒,把绑定事件、发起请求混在一起,例如上面的最后一部分上下架就是这样,这也导致代码杂乱无章,如果某一天想要在某个dom元素上新加一个事件,光找到这个dom可能都得花两三分钟
4、代码块疏于管理
对于复杂的dom对象(例如此处的商品规格),光这部分的实现就可能要好几十、上百行代码,这个时候我们通常是把它抽成一个单独的插件,通常每个插件都是以一个script标签的形式引入,插件抽的多了引一堆script,script之间依赖关系很复杂,而且页面刚上来的时候加载这么多script必然导致性能很差
5、性能问题
在生成第一列数据时,根据isNameEdit来确定是生成div还是input,进一步确定按钮的文字是“完成”还是“编辑”,每次点击按钮之后又remove掉所有dom,再重新生成,如此频繁的操作dom也造成严重的性能问题
总体感受:维护成本极高,排查定位困难,无论是对于用户、还是对于开发非常不友好
其实回顾这个过程,会发现,我们做的事情大概就是以下几类:
1、数据处理、绑定dom
通过请求拿到商品列表数据,有时自己再给数据附加一些属性(例如isNameEdit),然后根据数据生成dom,完成可视化的工作
2、添加各种事件、请求
之后用户会点击或移入某个dom,触发某些事件,这些事件会再改变某些数据或通过网络请求再获取某些数据
3、重新渲染
这些数据改变之后或获取回来以后需要再次修改一些dom节点,使其展示新的需要的内容
4、封装插件
对于某些复杂的模块,例如上面的商品规格操作,通常单独封装一个js插件,这个插件可能接受一些参数,根据对应的数据生成相应的dom结构、绑定好事件
结论:
无论是整个大的商品列表,还是每个商品的商品规格,在开发过程中我们所做的工作可以抽象为下面的流程:
获取数据 --> 根据数据生成DOM结构 --> 给DOM绑定事件 --> 事件触发时改变数据 --> 数据改变后移除旧的DOM、生成新的DOM
可以发现,一组dom往往会和特定的一组事件,特定的一组数据产生关联,实际上,还有一个隐晦的东西在其中,那就是一组规则,即拿到数据之后按什么样的“规则”去生成dom,是判断这个数据(例如isNameEdit)为真就生成某个dom,为假就生成另一个dom,还是遍历这个数据(如果是数组的话),生成一个列表,数据可以根据不同的规则生成不同的dom
因此,有人提出,我们可以把这一组dom、一组事件、一组数据、一组规则抽出来形成一个类,这个类就表征页面上这组dom的各种表现、行为,这个类就是——组件,而页面上每一组dom的集合其实都可以看成一个组件
function ViewComponent (options) {
this.$el = options.el
this.data = options.data
this.methods = options.methods
this.template = options.template
this._init()
}
ViewComponent.prototype._init = function () {}
按照组件的思路再重新审视我们做的商品列表,可以这样做一下抽象: 最外层的商品列表可以看成一个组件、每个商品的名称以及编辑功能可以看成一个组件、每个商品的商品规格展示可以看成一个组件
我们先把最外层的商品列表用组件类ViewComponent描述出来:
let oGoodsList = new ViewComponent({
$el: 'body',
data: {
goodsList: []
},
methods: {},
template: `
<table class="goods-list">
<thead>
<c-goods-list-head></c-goods-list-head>
</thead>
<tbody>
<c-good-item></c-good-item>
<c-good-item></c-good-item>
</tbody>
</table>
`
})
接下来我们分别解释一下这几个参数的定义:
首先,data和methods很容易理解,不再多说
$el 似乎 也很容易理解,就是把这个组件要描述的这些dom放到哪个元素中
值得重点关注的是template参数
可以看到,我们用template这样一个很长的字符串来描述这个组件包含的dom元素集合,于是我们大概可以想象出ViewComponent类内部干的事情:
将template的内容作为innerHTML给$el
组件中的确会做这样的操作,但是具体在哪做,我们会随着后续分析慢慢展开
不过,仅仅这样做的话有如下问题:
1、自定义标签的渲染,渲染到什么地方?
我们可以发现oGoodsList的template里面除了普通的table、thead等浏览器可以直接识别的标签外,还有c-goods-list-head、c-good-item等自定义标签(标签的开头用c表示Component),我们希望用这些自定义标签分别代表商品表头组件、商品条目组件,那如何将这些自定义标签转化成真正的浏览器可以识别的dom呢?一个大概的思路就是:和oGoodsList这个组件一样,我们也可以为c-goods-list-head、c-good-item各自定义一个组件:
let oGoodsListHead = new ViewComponent({
data: {},
methods: {},
template: `
<tr>
<th>商品名称</th>
<th>商品分类</th>
<th>商品价格</th>
<th>商品规格</th>
<th>操作</th>
</tr>
`
})
let oGoodItem = new ViewComponent({
data: {
good: null,
isEdit: false
},
methods: {},
template: `
<tr>
<td>
<div class="name">衬衣</div>
<input class="name-input" />
<button>编辑</button>
</td>
<td>服装</td>
<td>39</td>
<td>
<c-spec-list></c-spec-list>
</td>
<td><button>上架</button></td>
</tr>
`
})
然后在最外层根组件商品列表组件oGoodsList解析时,识别到这些自定义标签,然后找到对应的组件中的template,将其替换
不过,我在此想说另外一个问题,请各位注意观察oGoodsListHead、oGoodItem和oGoodsList的区别:
oGoodsList有$el参数,而oGoodsListHead、oGoodItem并没有$el参数,之前说了,我们定义$el参数就是为了想告诉组件这一堆dom渲染到哪个元素中,但是对于oGoodsListHead、oGoodItem来说,我们是没法确定到底渲染在哪个元素中的
当然在这个例子中,有些朋友可能会说,$el应该分别是tHead或tBody啊,明显是渲染到这里面的嘛
恩,似乎有一定道理,但是我们设计组件时难道只希望这个组件用在这一个地方吗?组件还有一个很大的特点就是它可以复用,就跟之前抽jQuery插件的时候,我们总是在调用插件的时候,通过传一个dom作为参数进去告诉这个插件,从而确定这个插件渲染到哪里,插件的定义当中是不会有某个具体的dom对象的,所以组件的定义,也是一样的道理
因此,这些dom渲染到哪里,取决于哪些地方用了<c-goods-list-head>、<c-good-item>标签,在用这些标签的地方才是它真正需要渲染的地方
但是对于oGoodsList就不一样了,oGoodsList是整个页面的根组件,它是一定知道自己要渲染到哪里的,所有的dom都可以追溯到根组件,根组件总得挂在某个元素上才行
这也是我刚才为什么说$el参数 “貌似” 很容易理解的意思,因为很多人忽略了这个细节
因此,我们还有了一个逻辑:我们只实例化最外层的根组件,根组件再分析自己的template,用到哪些组件就实例化哪些组件,进而触发整个系统的实例化
所以,实际开发中,我们不会像上面那样一连new这么多个对象,而是只new出来根组件,而其他对象,只需要给出实例化时的参数,供将来实例化即可:
let oGoodsList = new ViewComponent({...})
// 表格头部组件实例化时的参数
let oGoodsListHeadOptions = {
data: {},
methods: {},
template: `...`
}
// 商品条目组件实例化时的参数
let oGoodItemOptions = {
data: {
good: null,
isEdit: false
},
methods: {},
template: `...`
})
注意:
现在我们的ViewComponent类中,init什么都没有做,将来我们会慢慢将收集template中的规则(Vue中叫指令)、创建更改dom等一系列工作放到init中,因此实例化也就意味着把这个组件上的所有dom都创建了出来、加上了事件、添加到了父级中
2、数据和DOM的关联
我们期望达到一个目的:data参数中每个数据都和某个dom有对应,而且数据变的时候期望它对应的dom也跟着变,而且数据和dom的对应关系有一定规则,数据变的时候dom怎么变也有一定规则
但是,观察我们的template参数,可以发现template里面的dom没有什么和数据有什么关联关系,因此template肯定需要修改,那该如何修改才能体现出关联关系呢? 我们以oGoodItem这个组件为例,如果我们把template模板做如下修改,就能感觉到数据和dom的关联了:
<tr>
<td>
<div class="name" r-if="this.data.isEdit" r-text="this.data.good.name"></div>
<input class="name-input" r-else r-value="this.data.good.name" />
<button m-click="this.methods.switchNameStatus" r-text="this.data.good.isEdit ? '完成' : '编辑'"></button>
</td>
<td r-text="this.data.good.category"></td>
<td r-text="this.data.good.price"></td>
<td>
<c-spec-list></c-spec-list>
</td>
<td><button m-click="this.methods.switchGoodStatus" r-text="this.data.good.isEnable ? '下架' : '上架'"></button></td>
</tr>
整体上看,我们在HTML上加了很多自定义的属性,例如r-if、r-else、r-text、r-value、m-click等等,我们的目的是想要在html中使用data、methods中定义的数据、方法,不过我们很清楚的是template只是一个字符串,所以如果不作任何处理,直接按照上文中说的那样,将template插入到它的父级,浏览器只会把这些我们自己定义的属性简单存起来,并不会在页面上有任何的体现
可以想象的是,我们每引入一种新的自定义属性,一定是希望它完成一定的功能,例如r-if属性的值如果是true的话就生成这个dom,反之则不生成
因此我们一定需要一种机制,将这些自定义属性都识别出来,并且对我们的原始dom做一定转换,最终生成浏览器可以解析的dom创建出来
而每一个自定义属性各自对应的特定功能,就引出了一个重要的概念,我们在上文中提到的——规则,r-if、r-else、r-text、r-value中的“r”就是rule的首字母,m-click的首字母m就是methods的首字母,在此我们把methods当成一种特殊的rule,之后我们在实现框架的过程中会看到,组件Component也是rule,只不过它是一种特殊的rule,因为组件其实也可以看做某些数据和某些dom之间规则的描述
3、属性名的精简以及带来的问题
不过,从以上我们修改过的模板看来,每个属性的值好像有点过于冗长,实际上我们想表达的是去组件的data、methods里取对应的数据、方法用在这些地方,每个规则、方法值中都加了this,其实完全可以去掉,一个很浅显的道理——对于命名空间namespace,都加了统一个前缀,就相当于都没加
但是,为了更简洁,我们在此把this后面的data、methods也都去掉,因此模板就变成了:
<tr>
<td>
<div class="name" r-if="isEdit" r-text="good.name"></div>
<input class="name-input" r-else r-value="good.name" />
<button m-click="switchNameStatus" r-text="good.isEdit ? '完成' : '编辑'"></button>
</td>
<td r-text="good.category"></td>
<td r-text="good.price"></td>
<td>
<c-spec-list></c-spec-list>
</td>
<td><button m-click="switchGoodStatus" r-text="this.data.good.isEnable ? '下架' : '上架'"></button></td>
</tr>
这样一来,就显得直观很多
但问题是,那我们要如何区分属性值到底是从data上取的,还是从methods上取的呢?
或许有朋友可能会认为:可以根据属性名前缀来判断啊,r前缀的,像r-xxx就取data,m前缀的,像m-xxx就取methods,这样的确可以,我们平时在Vue组件中也可以通过:和@来区分数据、方法,不过Vue的内部不是这样做的
此外,像这种包含了若干规则、方法、组件的dom集合,我们将其称为——模板 对模板的解析将成为整个框架的一大核心
4、属性和数据的关系及区别
接下来我们把目光转向data参数
组件oGoodItem中,有两个数据good、isEdit(注:isEdit已经不再是good对象的一个属性了,而是data里和good对象同一层级的变量),我们可以思考一下这两个数据的区别?
good其实是需要父组件传给它的,但isEdit的值则没有必要(请注意是“没有必要”而不是“不能”)由父组件来给它,它完全可以是自己组件内部存储的一个变量
我们为何要刻意强调并严格将这些数据分类,以明确到底是从父组件给的,还是子组件就可以确定的呢?
思考如下问题:每个组件中的数据都会和某些dom有关联,当父组件给子组件传递数据时,就意味着这个数据会和父组件、子组件中的某些dom同时都有关系,前文也提到了,将来我们会实现一个重要的功能就是数据变化时更新dom,那么在现在这种场合,父组件或子组件的数据变化时,正确的做法是父子组件的dom都更新,而要做到这一点,就必须保证父子组件之间公用的这部分数据一定要同步,也就是父组件数据变了、子组件也要跟着变,子组件数据变了,父组件也要跟着变
如果父子双方任何一方数据变化都不需要通知另一方,一方变了另一方就自动变化,那这种数据变化形式就叫双向数据流动,反之叫单向数据流动
在Vue中,我们实现的是单向数据流动,因为很明显,父组件的数据改变(也就是父组件直接good = xxx赋值)时,子组件不需要做什么处理就会跟着改,进而触发dom的改动
但如果我们在子组件中(good = xxx)直接更改数据的话,父组件是不会随之更新的,在Vue中这种数据就叫——属性,这也是为什么Vue一再强调不要直接给子组件属性赋值,因为直接赋值之后子组件的dom的确更新了,但是父组件由于没有检测到数据的变动,是不会更新的
当然,父组件传给子组件普通类型的值和引用类型的值,情况又有一定差异,我们到时候再说
现在,我们要做的就是将这两类数据分开处理:
let oGoodItemOptions = {
data: {
isEdit: false
},
props: ['good']
methods: {},
template: `...`
})
可以看到,我们将good放到了props中,我们不需要给它初始值,因为我们在使用这个组件时父级会给它的:
<c-good-item r-bind-good="good">
从调用方法来看,我们又引入了一个新的规则:r-bind,而且这个规则的名字r-bind还需要再附加一部分(good)才完整可用
5、进一步理解规则
组件、规则基本上说的差不多了,还有一个问题,那就是’规则‘既然可以用在普通dom元素上,同时也可以用在组件这种代表若干dom集合的特殊元素上,例如:
<c-good-item r-if="goodsList.length > 0" r-bind-good="good">
这种细节问题为什么也要拿出来单独提一下呢?
前面曾提到:组件也是一种特殊的“规则”,这个逻辑很重要,因为有这个逻辑,所以出现了“二次编译”,至于具体什么是二次编译,我们之后会详细说明
6、组件初始化完成的钩子函数
可以看到,在每个组件的options中,我们的data里面的各个数据都是只给了初始值(空数组、null、空字符串等),我们的框架,也就是ViewComponent类目前为止只定义了一个init方法,里面什么内容也没有
但data里面的goodsList一定有一个被赋值的过程,即从空数组到对象数组的变化过程,总得有一个地方获取请求拿到数据,然后赋值给data,进而再按照data中的数据生成对应的dom
那啥时候干这个事情呢?
首先需要明确的是我们期望:data里面的对象每被赋一次值,dom就要跟着变一次——最开始goodsList初始化为空数组时dom先变一次(这次其实就是创建dom),请求回来之后goodsList被重新赋值时,dom又变一次
我们可以暂时定为组件按照“goodsList为空数组时的数据”对应的dom创建完、事件绑定完、当前组件对应的dom集合添加到父级以后,来发请求获取数据重新赋值,我们不妨单独给ViewComponent加一个原型方法来做这个事情:
function ViewComponent (options) {
this.$el = options.el
this.data = options.data
this.methods = options.methods
this.template = options.template
this.created = options.created
this._init()
}
ViewComponent.prototype.created = function () {
this.options.created()
}
可以看到,我们从options中取到created回调来执行
因此,组件的options就可以添加created回调了:
let oGoodsList = new ViewComponent({
$el: 'body',
data: {
goodsList: []
},
methods: {},
template: `
<table class="goods-list">
<thead>
<c-goods-list-head></c-goods-list-head>
</thead>
<tbody>
<c-good-item></c-good-item>
<c-good-item></c-good-item>
</tbody>
</table>
`,
created: function () {
let _this = this
Request.get('/goods/list', function (res) {
this.goodsList = res.data
})
}
})
注:在框架实现过程中,我们会故意使用老一些的语法来解释本质原理,例如此处的请求回调我们就完全可以用箭头函数替换,而不必暂存this
7、循环指令
有一个很重要的“规则”我们没有介绍,这个规则就是r-for,我们可以给这个规则一个数组,它将遍历该数组,然后根据数组中的内容生成多个dom,每个dom都对应数组里的一项
小结:
目前我们暂且罗列这些问题,实际上有很多朋友一定会想到更多的问题,例如类似Vue中的插槽、父组件给子组件回调、虚拟dom、diff算法等等,这些我们并没有提到
其实一个框架的产生都是由问题出发,以我们目前的分析,暂且还没遇到这种问题,而且这个时候引入插槽的话题有点过早,虚拟dom、diff算法等也是为了提升性能而做的优化,我们需要单独认真分析
等主体框架完成之后,我们对全盘有了了解之后,再想要精进,加一些更丰富的功能,那就是水到渠成的事,相信到时候不用我分析,大家自己就可以搞定
在接下来,我们逐步完善框架的过程中,会逐一解决以上问题,希望大家也能够带着问题,有目的地去思考如何在框架中解决它们