有这么一个需求,原来的一个后台需要重构,前端展示为这样的:
正如你所看到的,这个有添加有删除功能,还需要长成这样。后端给的数据格式经过简化为:
{
"data": {
"path": "data",
"type": "dict",
"showName": "文言文编辑",
"value": null,
"isNecessary": true,
"subDefine": [
{
"path": "data/title",
"type": "string",
"showName": "标题",
"value": "周亚夫军细柳",
"isNecessary": true,
"subDefine": null
},
{
"path": "data/book",
"type": "list",
"showName": "课本",
"value": null,
"isNecessary": true,
"subDefine": [
{
"path": "data/book/book_0",
"type": "dict",
"showName": "1",
"value": null,
"isNecessary": false,
"subDefine": [
{
"path": "data/book/book_0/version",
"type": "string",
"showName": "教材",
"value": "人教新版",
"isNecessary": true,
"subDefine": null
}
]
},
{
"path": "data/book/book_1",
"type": "dict",
"showName": "2",
"value": null,
"isNecessary": false,
"subDefine": [
{
"path": "data/book/book_1/version",
"type": "string",
"showName": "教材",
"value": "部编本",
"isNecessary": true,
"subDefine": null
}
]
}
]
}
]
}
}
在看看各个参数的意义:
-
path
: 当前路径 -
type
: 表示类型 -
showName
: 展示的字 -
value
: 输入框展示的内容 -
isNecessary
: 是否是必须的 -
subDefine
: 子元素,如果有就渲染子元素如果没有就不渲染
后端怎么把数据传给我的,我就需要按这样的格式传给他,中间用户可能修改value
值,然后需要把这些值进行校验,并且传给后端。这个后台是很古老的东西,具体是用jquery
通过字符串拼接的方式,将数据拼接为想要的html
,在往真实的DOM
中插入这些字符串,所以造成重复代码很多,而且字符串相对于html
书写,可读性更差。为了增强可维护性,所以准备对其重构
如何绘制结构图
如何把数据转换为上面的结构图,这种结构图该如何绘制,下面记录我的心理路程~~:
jsx
拿到这个需求的时候那时候还不太了解嵌套组件这种思路,所以首先想到了能否使用jsx
,因为当时认为光靠html
是无法做到这种嵌套结构的。既然这种思路无法做到,首先想到的能不能通过js
递归调用的方式因为js
更加灵活,最后返回一个html
。这样我们就用了js
替代了html
来生成这种嵌套结构的html
。那我们就需要舍弃template
转而使用我们自己定义的render
函数。以前没有用过jsx
,所以先学习了一个下午,准备做点简单的东西先试水一下,先在这个项目尝试一些jsx
的代码,加上以后,发现编译报错,说我差一个loader
,报错如下:
找了一些原因后,发现是在vue.config.js
中加入了
chainWebpack: (config) => {
config.module.rules.delete('js');
}
这样就不会对jsx
进行编译,但是和公司自己组件库的设计有冲突,然后需要组件库成员来修复这个问题,那么这个项目可能就不能按时交付了。同时需要考虑到维护成本,vue
中很少地方是使用jsx
的,那后面的人维护这个是不是需要增加维护成本呢。那么是否可以选择更优秀的方式来解决这个问题呢。
插件
懒人有懒人的思考,我的第一反应就是找个插件啥的,啥都不用操心了,传递数据完事了。本身后台也用的是element-ui
,所以第一想法用一下tree
插件,但是tree
组件长这个样
不符合产品设计的要求,但是我们可以看到的是,需要给tree
传入的参数和后端传给我的参数及其的相似,那是否能从tree
的实现中获取经验呢
tree
组件实现原理
原理可参照element-ui
的实现,这里对其进行简化
<tree>
<tree-node v-for="item in data">
</tree-node>
</tree>
大概就是这个意思,外面是tree
组件,里边是tree-node
,如何实现多层嵌套呢,下面是tree-node
组件的实现:
<template>
<div class="tree-node">
<tree-node
v-if="data.children"
></tree-node>
</div>
</template>
<script>
export default {
name: 'tree-node'
}
</script>
可以看到这样就实现了嵌套组件的效果,但是需要注意的是,必须需要声明当前组件的name
否则在当前文件中使用当前组件。
总结
从tree
的实现中,可以借鉴这种思路实现当前产品的需求,实现一个子组件,如果children
存在,那么就调用tree-node
,如果不存在,就不需要渲染当前组件。首先我们就实现了这种效果,但是这个需求没有终结,我又遇到了新的问题--性能问题.
性能优化
事实上,在我们写代码的时候很少遇到性能问题,但是这次确实发现了当我们将数据传给组件的时候,需要大量的时间上面的结构才能渲染出来。所以需要分析是什么地方造成了这些性能问题。
性能分析
这里借助了chrome
的性能分析工具performance
,进行调用堆栈分析,看最消耗性能的是哪个部分
使用方法很简单
点击这个地方,就可以对当前页面进行性能分析,下面是分析结果
具体各种参数可参考这篇文章chrome-performance页面性能分析使用教程
可以看到scripting
占据了大量的时间,在这个旁边有一个call-tree
可以看到是哪个函数占据的时候最多
在挨个点下去,看看最消耗时间的是哪个环节,最后发现如下:
这个方法是element
的textarea
中用于自适应高度的一个函数,下面就是分析这个函数对性能的影响这么大
组件优化
在需求中我是这么调用的
<el-input
autosize
type="textarea">
</el-input>
autosize
拖垮了页面的性能,下面来看看autosize
的实现。autosize
最后调用的方式是resizeTextarea
,下面来看看具体的实现
resizeTextarea() {
const { autosize } = this;
const { minRows, maxRows } = autosize;
this.textareaCalcStyle = calcTextareaHeight(this.$refs.input, minRows, maxRows);
}
可以看到的是最后调用的是calcTextareaHeight
,具体看看他的实现
function calcTextareaHeight(tragetment, minRows, maxRows) {
if (!hiddenTextarea) {
hiddenTextarea = document.createElement('textarea');
document.body.appendChild(hiddenTextarea)
}
const {
paddingSize,
borderSize,
boxSizing,
contextStyle,
// 获取元素的尺寸信息
} = calculateNodeStyling(targetment)
// 设置隐藏文本域的样式
hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`)
hiddenTextarea.value = targetElement.value || targetElement.placeholder || '';
let height = hiddenTextarea.scrollHeight; // 包含pading的高度
if (boxSizing === 'border-box') {
height += borderSize;
} else if (boxSizing === 'content-box') {
height -= padingSize
}
if (hiddenTextarea.parentNode) {
hiddenTextarea.parentNode.remove(hiddenTextarea)
}
hiddenTextarea = null
return { height: `${height}px`}
}
分析上面的函数,因为组件库需要考虑的元素众多,可以需要加入一些对于我们自身业务无关的代码例如上面的代码就有几个地方可以针对业务进行优化:
- 这里通过
calculateNodeStyling
来获取元素的一些属性,这对于业务来说完全是可控的,padding
、border
我们完全可以设置,而不需要用js
获取,在常说的性能优化中,最重要的就是避免对DOM
的反复操作,如果节省了这一步操作是不是效率能够得到极大的提升呢 - 可以看到这个是如何实现子适应高度的,创建一个我们看不见的
textarea
,并且把现在的textarea
的样式赋值给隐藏的textarea
从而来计算高度。这对于我们只需要使用简单功能的完全没有必要的只需要使用height=scrollHeight
。并且在代码我们又把这个隐藏textarea
从文档流中移除,如果在文档中有1000
个textarea
中,是不是就需要创建textarea
然后将其移除呢,上面提到操作DOM
会造成性能的下降
有两个原因所以准备做一个简单的输入框满足我们的需求
<textarea ref="textarea" v-model="value">
</textarea>
<script>
export default {
mounted() {
this.textarea = this.$refs.textarea;
},
watch: value() {
textarea.height = 0;
textarea.height = textarea.scrollHeight;
}
}
</script>
这样就能够 简单实现输入框的高度随内容改变而改变,而且去除了一些没有必要的操作,使得性能大大的提高。
其他的性能优化
除了更改上面组件的实现方式,这个需求中我们是否有其他的地方可以进行优化
Object.freeze
冻结数据
了解Vue
源码的都知道,当我们对data
中的值进行set
操作,需要对新赋值的数据进行响应式设置,也就是重新定义数据的set
、get
操作。但是在当前业务中,后端的值是一个不会更改的值,我们对其进行响应式是否有必要吗,并且这个数据是非常大的,如果对这个数据递归进行重新定义get
和set
操作是不是本身就是一种消耗的性能,所以我们并不需要对其进行以来收集,使用object.freeze
就不会让vue
对这些数据进行重新定义setter
和getter
this.data = Object.freeze(data);
这里使用了Object.freeze
,这里介绍一下这个的使用方法
例如
const map = {
key: 'value'
}
map.key = '12'
console.log(map.key) // 'value'
当更改map
中key
的时候,修改以后的值后并没有发生变化。
但是如果当前的对象的属性值也是一个对象,除非该对象的属性值也是一个冻结对象,那么该对象的是可以更改的,例如
const map = {
key: {
test: 'value'
}
}
map.key.test = 'test'
console.log(map.key.test) // test
可以看到的是冻结对象以后只有第一层的属性值不能更改,结合Vue
源码,在来看看这种方式在源码中是如何体现的,结合上面例子讲解this.data = Object.freeze(data)
,当对data
进行赋值的时候Vue
会对当前操作进行拦截
Object.defineProperty(obj, key, {
set: function reactiveSetter(newVal) {
childOb = !shallow && observe(newVal);
}
})
function observe(value) {
// ...
if (Object.isExtensible(value)) {
ob = new Observe(value)
}
}
可以看到当满足Object.isExtensible
的时候才会让数据添加响应式,当执行Object.freeze
的时候Object.isExtensible(value)
为false
,所以不会进行重新定义set
、get
操作。
递归组件
Vue
本身的原理决定了父子创建时生命周期子的先后顺序为:
父beforeCreated => 父created => 父beforeMount => 子beforeCreated
=> 子created => 子beforeMount => 子mounted => 父mounted
当数据更新的时候父子周期的先后顺序为:
父beforeUpdate->子beforeUpdate->子updated->父updated
为什么渲染这么慢呢,就是因为整个组件需要等内部的子元素都渲染完成以后,才把整个父组件挂载到真实DOM
,但是对于整个部分没有太好的解决办法
数据处理
因为在数据处理的时候,我们对后端给的数据每条数据都进行了遍历,在上面代码中为了给某条数据加一个required
属性,对数组进行了深度遍历,这样是为了让template
中的表达式更加简单。后端返回给我们的数据可能及其庞大,进行递归可能就会影响性能,原则是能不算就不算。所以转而在template
中使用表达式来书写判断条件,可能这个表达式写的很长,但是节约了性能。
需求具体实现
在需求中我们可能需要对一个元素进行子类扩展或者删除,那么该如何实现呢。
利用Vue
本身数组处理的局限性
我们知道使用Object.definePrototype
是无法对数组元素的添加和更改进行拦截的,所以在Vue
中源码是对数组进行处理的,如果我们要对数组的某个元素需要这么写
Vue.$set(this.arr, key, value)
这样才能让Vue
中监听到值的变化,这也是很多小伙伴遇到的问题,明明更改了值,但为什么视图没有更改呢。
在这里也利用了这个漏洞,在文章前面提到,后端传给我们的数据我们需要保持这个数据格式传给他们,中途可能需要修改这些可以输入值的,如上图结构中的value
subDefine: [{
value: ''
}]
在代码中从后端传给我的数据直接传给了组件,那这个值就相当于组件的props
,在Vue
中是禁止prop
修改的。
<div v-for="(item, key) in subDefine">
<el-input v-model="item.value">
</div>
在前面提到了对数组的某个元素进行修改,必须要用借助$set
才能监听到更改,我们这里直接对props
进行更改,但是Vue
是不会报错的,这算是利用了Vue
的一个漏洞吧。 当修改value
的值的时候,父元素传进来的props
也是会更改的。
至于为什么这么做,就是因为简单,如果需要要考虑到以后的维护,可能就需要使用$emit
, 复制对象,那可能要稍微麻烦一些。
删除
删除简单,点击删除实际把该元素的父亲的subDefine
删除最后一个元素,也就是把父元素的trees
删除最后一个元素,代码如下:
trees.pop()
这样就能删除最后一个元素了
添加
后端在传给前端的时候,除了一个data
,还有一个minData
, 这个minData
的数据格式和data
相同,不同的是每一项的value
都是空的,如果该项可以扩展,意思是说能够往subDefine
中添加子元素,这个subDefine
是不为空的,但是只有一个元素。这个数据在添加子元素的时候极为的有用,比如说现在当前的元素的subDefine
是个空的,当我们向其中添加元素的时候,那这时候这个新元素的数据结构应该是怎么样的。这时就需要通过找到minData
中哪一个元素的path
和当前的path
是相同的。先前想过循环遍历找到相同的,但是瞬间被自己否定了,虽然咱们对算法没什么研究,但是也不能使用这么low
的想法吧。所以首先对mindData
进行处理,在先前提到每个元素的path
都是不同的,那是不是可以重新创建一个对象,其中的key
就是每条数据的path
,value
就是该条数据。这时候但我们需要添加一个新元素的时候,只需要知道对应的path
,然后从minData
中取出key
等于path
的那条数据,然后取出那条数据的subDefine
的第一条数据就行了。
下面是minData
的数据处理函数:
constructPathObj(subDefine, res = {}) {
subDefine.forEach((value) => {
res[value.path] = value;
if (value.subDefine) {
this.constructPathObj(value.subDefine, res);
}
});
return res;
}
minData = constructPathObj(data)
这样就得到了一个已path
为key
,数据为value
的一个对象。这里还需要注意一点就是因为前面提到path
是唯一的,所以在添加新元素的时候不能够让path
重复。例如现在subDefine
中有一个元素的path
为data/book_1
,后端要求新添加的元素path
为data/book_2
,所以有了以下代码
const { subDefine } = item;
let index;
if (subDefine.length === 0) {
// 根据path找到子元素
index = 0;
} else {
index = subDefine.length;
}
const temp = this.dealData(minData[information.path].subDefine[0], index);
subDefine.push(temp);
function dealData(data, index) {
const temp = {};
if (data.subDefine) {
temp.subDefine = [];
data.subDefine.forEach((val) => {
// 先对传给后面的数据path进行处理
val.path = val.path.replace(/(.*)_[0-9]/, `$1_${data.showName}`);
temp.subDefine.push(this.dealData(val));
});
}
if (data.type === 'dict') {
temp.showName = index + 1;
temp.path = data.path.replace(/(.*)_[0-9]/, `$1_${index}`);
}
return {
...data,
...temp,
};
}
这样就会对生成的每个元素的path
进行规范,也就到达了添加一个新元素的一个效果。
总结
因为是项目重构,所以需要现有的一个接口,那么接口的内容就不能变动。那就需要在原有的数据结构上进行修改,把这些数据处理为我们想要的数据格式。在实现项目的过程中遇到了一些性能问题,并对其分析产生这些的原因并进行解决,加深了对需求的理解,也提高对性能优化这块的重视。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。