干货 - 记录一次有意义的页面和代码优化

2,873 阅读8分钟

为何写这个文章

很少有时间对于写过的代码重构 这次发现重构是真的很有意思的事情 所以就记录下来了

modal的fifo应用

集卡属于互动类型的游戏,此页面有9个弹窗,其中有同时出现的5个弹窗的情况,且如果同时出现必须按照指定顺序弹出。
遇到复杂的交互逻辑,数据结构可以帮助理清思路,抽象逻辑,完成稳定可靠的代码。在这次交互中,弹框要一个个按照顺序弹出,可以虑有序队列。但是弹框的弹出和关闭属于事件。在上一个弹框弹出关闭后,触发下一个弹框弹出。可以考虑事件的发布订阅。

队列图解

队列 是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(end)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队首。 队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列的特性为 先进先出 (First-In-First-Out,FIFO)

弹窗和队列结合

队列的弹窗展示有三个状态

  1. 同一时间段只有一个弹窗触发
  2. 同一时间段有两个或多个弹窗出发
  3. 一个弹窗在展示过程中,另一个弹窗要触发

整体逻辑分析如下图


import { EventEmitter } from "events"
const emitter = new EventEmitter()
const queue = new Queue()


// 事件中心
export class EventCenter {
  // 添加绑定事件
  static on(type, cb) {
    this[`${type}`] = cb
    emitter.on(type, this[`${type}`])
  }

  // 执行绑定事件回掉
  static emit(type, data) {
    emitter.emit(type, data)
    // 每次回掉后 就接触绑定
    if (type.indexOf("Close") > -1) {
      this.remove(type)
    }
  }

  // 解除绑定事件
  static remove(type) {
    emitter.removeListener(type, this[`${type}`])
  }

  static eventNames() {
    return emitter.eventNames()
  }
}

// 一个弹窗的队列
class Queue {
  constructor() {
    this.dataStore = []
  }

  enqueue(e) {
    this.dataStore.push(e)
  }

  dequeue() {
    this.dataStore.shift()
  }

  front() {
    return this.dataStore[0]
  }

  back() {
    return this.dataStore[this.dataStore.length - 1]
  }

  isEmpty() {
    if (this.dataStore.length === 0) {
      return true
    }
    return false
  }

  toString() {
    return this.dataStore.join(",")
  }
}


/**
 * 将弹窗事件名推入队列
 */
export const push = eventName => {
  if (queue.isEmpty()) {
    queue.enqueue(eventName)
    openDialog() // 启动出队逻辑
  } else {
    queue.enqueue(eventName) // 循环中依然可以同时入队新的元素
  }
}

/**
 * 打开弹窗,递归,循环出队
 */
const openDialog = () => {
  // 打开弹窗
  EventCenter.emit(queue.front())
  // 监听弹窗关闭
  EventCenter.on(`${queue.front()}Close`, () => {
    queue.dequeue() // 出队
    if (!queue.isEmpty()) {
      // 队列不为空时,递归
      openDialog()
    }
  })
}

结果

创建enum管理状态

游戏状态多意味着常量多,好的代码最好是不写注释也一目了然。如果可以把注释通过代码表示出来那就太棒了,限制维护者强制书写注释那就更好了。

实现


// 创建enum 数据
/**  *
 * 数据类型
 * KEY:{
 *  value:string,
 *  desc:string
 * }
 *
 * enum[KEY] => value:string
 * enum.keys() => KEYS: Array<KEY>
 * enum.values() => values: Array<value>
 *
 */
export const createEnum = enumObj => {
  const keys = target => Object.keys(target) || []
  const values = target =>
    (Object.keys(target) || []).map(key => {
      if (
        typeof target[key] === "object" &&
        target[key].hasOwnProperty("value")
      ) {
        return target[key].value
      } else if (
        typeof target[key] === "object" &&
        !target[key].hasOwnProperty("value")
      ) {
        return key
      }
    })

  const handler = {
    get: function(target, key) {
      switch (key) {
        // keys 获取key => Array<key:string>
        case "keys":
          return () => keys(target)
        // values Array<key:string> || Array<Object<key:string,value:string>>
        case "values":
          return () => values(target)
        // 获取 详细描述 descs TODO
        // [key,[value]] 键值对 entries TODO
        // 合并 assign TODO
        default:
          break
      }

      if (target[key]) {
        if (
          typeof target[key] === "object" &&
          target[key].hasOwnProperty("value")
        ) {
          return target[key].value
        } else if (
          typeof target[key] === "object" &&
          !target[key].hasOwnProperty("value")
        ) {
          return key
        }
        return target[key]
      }
      throw new Error(`No such enumerator: ${key}`)
    },
  }

  return new Proxy(enumObj, handler)
}

使用


export const MODAL_TYPE = createEnum({
  GIVE_CARD: {
    value: "giveCard",
    desc: "天降弹窗",
  },
  ASSIST_CARD: {
    value: "assistCard",
    desc: "助力弹窗",
  },
  BUY_CARD: {
    value: "buyCard",
    desc: "下单弹窗",
  },
  TASK_CARD: {
    value: "taskCard",
    desc: "任务弹窗",
  },
  RECEIVE_CARD: {
    value: "receiveCard",
    desc: "收卡弹窗",
  },
  SEND_CARD: {
    value: "sendCard",
    desc: "送卡弹窗",
  },
})

代码抽离和模块划分

优化后的代码index.js减少了500行左右的代码
我的处理是

  1. 先进行模块划分,把this.renderXXX里的代码放入components里让整体逻辑分离更清晰,把只和子组建相关的逻辑全部移除到子组建中
  2. 首页的ajax多个请求合并,把index.js和store内都会用到的请求都组合再一起,对整体页面init的逻辑整合
  3. 此时再整理首页里面的各种 状态控制锁。将状态控制锁放入constant里。只需要根据不同状态进入不同的组建即可,进行整体的逻辑整合。因为原来的逻辑是 user的状态有4中,活动的状态有4中。这样的排列组合就有16中。需要把这么多排列组合分别在首页逻辑中判断是很复杂的。这里可以考虑根据展示结果判断。同样的展示结果对应哪几种user和activity状态判断组合在一起。后期增加游戏状态时候,只需要再加一个状态和对应的组建即可,也无惧user和活动状态的多变性
  4. 分离出ui组建和容器组建
  • UI组件:只负责页面的渲染
  • 容器组件:只负责业务逻辑和数据的处理,要保证组建功能的完整性,与父组建的交互只需给出回调的callback。与父组建无关的逻辑就封闭起来。
  1. 对觉得已经不满足后期维护需求,承载负荷过重的代码进行重构。此时代码已经解耦,重构起来会是一件快乐和容易的事情
  2. 对css,动画等进行优化
  3. 删除重复和多余的代码
  4. 分析页面性能,进行具体调整

单测

写单测是和留下组建快照 是代码的一次快照缩影,以后维护中可以验证是否修改最后的html结构。用代码描述出最基本的功能,并进行验证。这样保证代码的设计合理性和容易理解的实用性。

import {
  createEnum,
} from "../../../activity/collect_card/utils"

describe("createEnum", () => {
  const mockData = {
    GET: {
      value: "get",
      desc: "test",
    },
    POST: "post",
  }
  it("get value ", () => {
    const data = createEnum(mockData)
    expect(data.GET).toBe("get")
    expect(data.POST).toBe("post")
  })
  it("object.keys() run success", () => {
    const data = createEnum(mockData)
    expect(data.keys()).toEqual(["GET", "POST"])
  })
  it("object.values() run success", () => {
    const data = createEnum(mockData)
    expect(data.values()).toEqual(["get", "post"])
  })
})


加载优化

骨架屏

import React, { useMemo } from "react"
import classNames from "classnames"
import PropTypes from "prop-types"

import "./index.less"

function toPoint(percent) {
  if (!`${percent}`.split("").includes("%")) {
    return percent
  }
  var str = percent.replace("%", "")
  str = str / 100
  return str
}

function isPercent(percent) {
  return `${percent}`.split("").includes("%")
}

const SkeletonItem = props => {
  const {
    className,
    component: Component = "span",
    height = 16,
    variant = "text",
    width,
    style,
    children,
    ...rest
  } = props

  const clientWidthCalc = document.documentElement.clientWidth / 360

  const getBackgroundSize = useMemo(() => {
    if (variant !== "img") {
      return {}
    }
    if (!isPercent(width))
      return { backgroundSize: `${(width / 2) * clientWidthCalc}px` }
    return {
      backgroundSize: `${((toPoint(`${width}`) * 360) / 2) *
        clientWidthCalc}px`,
    }
  }, [width, variant, clientWidthCalc])

  return (
    <Component
      className={classNames("root", variant, className)}
      style={Object.assign(
        {
          width: isPercent(width) ? width : `${width * clientWidthCalc}px`,
          height: isPercent(height) ? height : `${height * clientWidthCalc}px`,
          ...style,
        },
        getBackgroundSize,
        {}
      )}
      {...rest}
    >
      {children}
    </Component>
  )
}

SkeletonItem.propTypes = {
  // class名称
  className: PropTypes.string,
  // 组建类型 span div
  component: PropTypes.elementType,
  // 组建高度 40% 48 百分比一般用于文本
  height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  // 元素形状 text 文本  rect 长方形 circle 圆形  img图片
  variant: PropTypes.oneOf(["text", "rect", "circle", "img"]),
  // 组建宽度 40% 48
  width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
}

export default SkeletonItem




@skeleton_bg_color: #f8f8f8;
@skeleton_bg_color_2: #f2f2f2;

.root {
  display: block;
  background-color: @skeleton_bg_color;
}

// 文本
.text {
  position: relative;
  display: block;
  height: 1.2em;
  min-height: 18 * @rex;
  border-radius: 4 * @rex;
  overflow: hidden;
  background-color: @skeleton_bg_color;
  transform: scale(1, .6);
  transform-origin: 0 60%;
}

.text:empty::after {
  position: absolute;
  display: block;
  width: 100%;
  height: 100%;
  background: linear-gradient(
    90deg,
    rgba(248, 248, 248, 0),
    @skeleton_bg_color_2 75%,
    rgba(248, 248, 248, 0)
  );
  transform: translateX(-100%);
  animation: skeletons-loading 2s infinite ease-out;
  content: " \00a0";
}

// 圆
.circle {
  border-radius: 50%;
}

.rect {
  position: relative;
  border-radius: 4 * @rex;
  overflow: hidden;
  background-color: @skeleton_bg_color;
}

.rect:empty::after {
  position: absolute;
  display: block;
  width: 100%;
  height: 100%;
  background: linear-gradient(
    90deg,
    rgba(248, 248, 248, 0),
    @skeleton_bg_color_2 75%,
    rgba(248, 248, 248, 0)
  );
  transform: translateX(-100%);
  animation: skeletons-loading 2s infinite ease-out;
  content: " \00a0";
}

// 图片
.img {
  background: url(../../../../images/default-image.png) center center no-repeat;
  background-color: #f8f8f8;
}

@-webkit-keyframes skeletons-loading {
  100% {
    transform: translateX(100%);
  }
}

@keyframes skeletons-loading {
  100% {
    transform: translateX(100%);
  }
}

.goods_list_skeleton {
  min-height: 100vh;
  padding-top: 21 * @rex;
  background: #fff;

  .title {
    margin: 0 auto 21 * @rex;
  }

  .list {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    padding: 0 3 * @rex;

    .item {
      width: 175 * @rex;
      margin-bottom: 13 * @rex;
    }
  }
}

使用

const SkeletonGoodsList = () => {
  return (
    <div className="goods_list_skeleton">
      <Item variant="rect" width={170} height={18} className="title"></Item>
      <div className="list">
        {Array.from(
          [0, 1, 2, 3, 4, 5].map(item => (
            <div className="item" key={item}>
              <Item variant="img" width={175} height={175}></Item>
              <Item variant="text"></Item>
              <Item variant="text"></Item>
              <Item variant="text" width={"40%"}></Item>
            </div>
          ))
        )}
      </div>
    </div>
  )
}

修改css

使用stylelint检测css一些不和规则的书写
因为css里有一些属性会触发重绘 所以 按照一定的书写顺序可以减少重绘提高css加载速度
而使用简写等可以减少css的打包体积
1. 安装
npm install stylelint --save-dev
npm install stylelint-config-standard --save-dev
npm install stylelint-order --save-dev
2. 在根目录下创建.stylelintrc配置文件
{ 
  "extends": "stylelint-config-standard", 
  "plugins": ["stylelint-order"],
  "rules": {
    "order/order": [
      "declarations",
      "custom-properties",
      "dollar-variables",
      "rules",
      "at-rules"
    ],
    "order/properties-order": [
      "position",
      "z-index",      
      "top",
      "bottom",
      "left",         
      "right",
      "float",
      "clear",
      "columns",
      "columns-width",
      "columns-count",
      "column-rule",
      "column-rule-width",
      "column-rule-style",
      "column-rule-color",
      "column-fill",
      "column-span",
      "column-gap",      
      "display",
      "grid",
      "grid-template-rows",
      "grid-template-columns",
      "grid-template-areas",
      "grid-auto-rows",
      "grid-auto-columns",
      "grid-auto-flow",
      "grid-column-gap",
      "grid-row-gap",
      "grid-template",
      "grid-template-rows",
      "grid-template-columns",
      "grid-template-areas",
      "grid-gap",
      "grid-row-gap",
      "grid-column-gap",
      "grid-area",
      "grid-row-start",
      "grid-row-end",
      "grid-column-start",
      "grid-column-end",
      "grid-column",
      "grid-column-start",
      "grid-column-end",
      "grid-row",
      "grid-row-start",
      "grid-row-end",      
      "flex",
      "flex-grow",
      "flex-shrink",
      "flex-basis",
      "flex-flow",
      "flex-direction",
      "flex-wrap",
      "justify-content",
      "align-content",
      "align-items",
      "align-self",
      "order",
      "table-layout",
      "empty-cells",
      "caption-side",
      "border-collapse",
      "border-spacing",
      "list-style",
      "list-style-type",
      "list-style-position",
      "list-style-image",
      "ruby-align",
      "ruby-merge",
      "ruby-position",
      "box-sizing",
      "width",
      "min-width",
      "max-width",
      "height",
      "min-height",
      "max-height",
      "padding",
      "padding-top",
      "padding-right",
      "padding-bottom",
      "padding-left",
      "margin",
      "margin-top",
      "margin-right",
      "margin-bottom",
      "margin-left",      
      "border",
      "border-width",
      "border-top-width",
      "border-right-width",
      "border-bottom-width",
      "border-left-width",
      "border-style",
      "border-top-style",
      "border-right-style",
      "border-bottom-style",
      "border-left-style",
      "border-color",
      "border-top-color",
      "border-right-color",
      "border-bottom-color",
      "border-left-color",
      "border-image",
      "border-image-source",
      "border-image-slice",
      "border-image-width",
      "border-image-outset",
      "border-image-repeat",
      "border-top",
      "border-top-width",
      "border-top-style",
      "border-top-color",
      "border-top",
      "border-right-width",
      "border-right-style",
      "border-right-color",
      "border-bottom",
      "border-bottom-width",
      "border-bottom-style",
      "border-bottom-color",
      "border-left",
      "border-left-width",
      "border-left-style",
      "border-left-color",
      "border-radius",
      "border-top-right-radius",
      "border-bottom-right-radius",
      "border-bottom-left-radius",
      "border-top-left-radius",
      "outline",
      "outline-width",
      "outline-color",
      "outline-style",
      "outline-offset",
      "overflow",
      "overflow-x",
      "overflow-y",
      "resize",
      "visibility",
      "font",
      "font-style",
      "font-variant",
      "font-weight",
      "font-stretch",
      "font-size",
      "font-family",
      "font-synthesis",
      "font-size-adjust",
      "font-kerning",        
      "line-height",
      "text-align",
      "text-align-last",
      "vertical-align",      
      "text-overflow",
      "text-justify",
      "text-transform",
      "text-indent",
      "text-emphasis",
      "text-emphasis-style",
      "text-emphasis-color",
      "text-emphasis-position",
      "text-decoration",
      "text-decoration-color",
      "text-decoration-style",
      "text-decoration-line",
      "text-underline-position",
      "text-shadow",      
      "white-space",
      "overflow-wrap",
      "word-wrap",
      "word-break",
      "line-break",
      "hyphens",
      "letter-spacing",
      "word-spacing",
      "quotes",
      "tab-size",
      "orphans",
      "writing-mode",
      "text-combine-upright",
      "unicode-bidi",
      "text-orientation",
      "direction",
      "text-rendering",
      "font-feature-settings",
      "font-language-override",
      "image-rendering",
      "image-orientation",
      "image-resolution",
      "shape-image-threshold",
      "shape-outside",
      "shape-margin",
      "color",
      "background",
      "background-image",
      "background-position",
      "background-size",
      "background-repeat",
      "background-origin",
      "background-clip",
      "background-attachment",
      "background-color",
      "background-blend-mode",
      "isolation",
      "clip-path",
      "mask",
      "mask-image",
      "mask-mode",
      "mask-position",
      "mask-size",
      "mask-repeat",
      "mask-origin",
      "mask-clip",
      "mask-composite",
      "mask-type",
      "filter",
      "box-shadow",
      "opacity",
      "transform-style",
      "transform",
      "transform-box",
      "transform-origin",
      "perspective",
      "perspective-origin",
      "backface-visibility",
      "transition",
      "transition-property",
      "transition-duration",
      "transition-timing-function",
      "transition-delay",
      "animation",
      "animation-name",
      "animation-duration",
      "animation-timing-function",
      "animation-delay",
      "animation-iteration-count",
      "animation-direction",
      "animation-fill-mode",
      "animation-play-state",
      "scroll-behavior",
      "scroll-snap-type",
      "scroll-snap-destination",
      "scroll-snap-coordinate",
      "cursor",
      "touch-action",
      "caret-color",
      "ime-mode",
      "object-fit",
      "object-position",
      "content",
      "counter-reset",
      "counter-increment",
      "will-change",
      "pointer-events",
      "all",
      "page-break-before",
      "page-break-after",
      "page-break-inside",
      "widows"
    ],    
    "no-empty-source": null,
    "property-no-vendor-prefix": [true, {"ignoreProperties": ["background-clip"]}],
    "number-leading-zero": "never",
    "number-no-trailing-zeros": true,
    "length-zero-no-unit": true,
    "value-list-comma-space-after": "always",
    "declaration-colon-space-after": "always",
    "value-list-max-empty-lines": 0,
    "shorthand-property-no-redundant-values": true,
    "declaration-block-no-duplicate-properties": true,
    "declaration-block-no-redundant-longhand-properties": true,
    "declaration-block-semicolon-newline-after": "always",
    "block-closing-brace-newline-after": "always",
    "media-feature-colon-space-after": "always",
    "media-feature-range-operator-space-after": "always",
    "at-rule-name-space-after": "always",
    "indentation": 2,
    "no-eol-whitespace": true,
    "string-no-newline": null
  }
}
3. 使用
npx stylelint "**/*.css" --fix // fix 是自动修改
4. stylelint的检测提示

下图 是stylelint检测的要修改的点 加入--fix就会自动修改了

对比 虽然很小 但是 量变产生质变 文件多了自然就减少的多了

bundle区分 使用react lazy

const RedBagRain = React.lazy(() =>
  import(
    /* webpackPrefetch: true, webpackChunkName: 'RedBagRain.lazy'*/ "./components/red_bag_rain"
  )
)

<React.Suspense fallback={null}>
              <RedBagRain
                dis_cold_start_selectors={[".mask", ".slidemodal"]}
                visible={!showFinishPage}
              />
            </React.Suspense>

RedBagRain有155 kb ,组建单独打包,分包加载,最后显示分出来 我把modal,actionsheet等 需要点击才能显示出的组建分包加载。这样就达到了首要的包优先加载,其他的包懒加载。如果不需要立即加载的,延迟1000s加载的写法是

const RedBagRain = React.lazy(() => {
  return new Promise(resolve => setTimeout(resolve, 10 * 100)).then(() =>
    import(/*webpackChunkName: 'RedBagRain.lazy'*/ "./components/red_bag_rain")
  )
})

图片压缩

点击进入tiny-图片压缩网站 webpack-bundle-analyzer 分析那些图片太大,如果不是主要图片就进行再次压缩

结果对比

打包结果对比:

优化前

加载结果对比

reference

  1. FIFO和LIFO自动管理modal控制器
  2. 队列在前端弹窗中的应用
  3. Bundle splitting components with Webpack and React
  4. CSS 渲染原理以及优化策略
  5. React Lazy加载的组件丢失其状态(被卸载)
  6. 使用Chrome Performance对页面进行分析优化
  7. CSS代码检查工具stylelint