记录一次有难度的后台重构&性能优化

有这么一个需求,原来的一个后台需要重构,前端展示为这样的:
记录一次有难度的后台重构&性能优化_第1张图片
正如你所看到的,这个有添加有删除功能,还需要长成这样。后端给的数据格式经过简化为:

{
    "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,报错如下:

记录一次有难度的后台重构&性能优化_第2张图片
找了一些原因后,发现是在vue.config.js中加入了

chainWebpack: (config) => {
    config.module.rules.delete('js'); 
}

这样就不会对jsx进行编译,但是和公司自己组件库的设计有冲突,然后需要组件库成员来修复这个问题,那么这个项目可能就不能按时交付了。同时需要考虑到维护成本,vue中很少地方是使用jsx的,那后面的人维护这个是不是需要增加维护成本呢。那么是否可以选择更优秀的方式来解决这个问题呢。

插件

懒人有懒人的思考,我的第一反应就是找个插件啥的,啥都不用操心了,传递数据完事了。本身后台也用的是element-ui,所以第一想法用一下tree插件,但是tree组件长这个样
记录一次有难度的后台重构&性能优化_第3张图片
不符合产品设计的要求,但是我们可以看到的是,需要给tree传入的参数和后端传给我的参数及其的相似,那是否能从tree的实现中获取经验呢

tree组件实现原理

原理可参照element-ui的实现,这里对其进行简化


    
    

大概就是这个意思,外面是tree组件,里边是tree-node,如何实现多层嵌套呢,下面是tree-node组件的实现:


可以看到这样就实现了嵌套组件的效果,但是需要注意的是,必须需要声明当前组件的name否则在当前文件中使用当前组件。

总结

tree的实现中,可以借鉴这种思路实现当前产品的需求,实现一个子组件,如果children存在,那么就调用tree-node,如果不存在,就不需要渲染当前组件。首先我们就实现了这种效果,但是这个需求没有终结,我又遇到了新的问题--性能问题.

性能优化

事实上,在我们写代码的时候很少遇到性能问题,但是这次确实发现了当我们将数据传给组件的时候,需要大量的时间上面的结构才能渲染出来。所以需要分析是什么地方造成了这些性能问题。

性能分析

这里借助了chrome的性能分析工具performance,进行调用堆栈分析,看最消耗性能的是哪个部分

这个方法是elementtextarea中用于自适应高度的一个函数,下面就是分析这个函数对性能的影响这么大

组件优化

在需求中我是这么调用的


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`}
}

分析上面的函数,因为组件库需要考虑的元素众多,可以需要加入一些对于我们自身业务无关的代码例如上面的代码就有几个地方可以针对业务进行优化:

  1. 这里通过calculateNodeStyling来获取元素的一些属性,这对于业务来说完全是可控的,paddingborder我们完全可以设置,而不需要用js获取,在常说的性能优化中,最重要的就是避免对DOM的反复操作,如果节省了这一步操作是不是效率能够得到极大的提升呢
  2. 可以看到这个是如何实现子适应高度的,创建一个我们看不见的textarea,并且把现在的textarea的样式赋值给隐藏的textarea从而来计算高度。这对于我们只需要使用简单功能的完全没有必要的只需要使用height=scrollHeight。并且在代码我们又把这个隐藏textarea从文档流中移除,如果在文档中有1000textarea中,是不是就需要创建textarea然后将其移除呢,上面提到操作DOM会造成性能的下降

有两个原因所以准备做一个简单的输入框满足我们的需求


这样就能够 简单实现输入框的高度随内容改变而改变,而且去除了一些没有必要的操作,使得性能大大的提高。

其他的性能优化

除了更改上面组件的实现方式,这个需求中我们是否有其他的地方可以进行优化

Object.freeze冻结数据

了解Vue源码的都知道,当我们对data中的值进行set操作,需要对新赋值的数据进行响应式设置,也就是重新定义数据的setget操作。但是在当前业务中,后端的值是一个不会更改的值,我们对其进行响应式是否有必要吗,并且这个数据是非常大的,如果对这个数据递归进行重新定义getset操作是不是本身就是一种消耗的性能,所以我们并不需要对其进行以来收集,使用object.freeze就不会让vue对这些数据进行重新定义settergetter

this.data = Object.freeze(data);

这里使用了Object.freeze,这里介绍一下这个的使用方法
例如

const map = {
    key: 'value'
} 
map.key = '12'
console.log(map.key) // 'value'

当更改mapkey的时候,修改以后的值后并没有发生变化。
但是如果当前的对象的属性值也是一个对象,除非该对象的属性值也是一个冻结对象,那么该对象的是可以更改的,例如

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,所以不会进行重新定义setget操作。

递归组件

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修改的。

在前面提到了对数组的某个元素进行修改,必须要用借助$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就是每条数据的pathvalue就是该条数据。这时候但我们需要添加一个新元素的时候,只需要知道对应的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)

这样就得到了一个已pathkey,数据为value的一个对象。这里还需要注意一点就是因为前面提到path是唯一的,所以在添加新元素的时候不能够让path重复。例如现在subDefine中有一个元素的pathdata/book_1,后端要求新添加的元素pathdata/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进行规范,也就到达了添加一个新元素的一个效果。

总结

因为是项目重构,所以需要现有的一个接口,那么接口的内容就不能变动。那就需要在原有的数据结构上进行修改,把这些数据处理为我们想要的数据格式。在实现项目的过程中遇到了一些性能问题,并对其分析产生这些的原因并进行解决,加深了对需求的理解,也提高对性能优化这块的重视。

你可能感兴趣的