页面插件化 - 多状态页面开发新模式

470 阅读5分钟

目录


什么是页面插件化

页面插件化是一种页面架构开发的思想,其适用于多状态页面,且各个状态在页面、逻辑之间的区别不大的情况。

比如:订单详情页中,待审核、处理中、已驳回、已完成之间,各个状态切换只会影响页面中的小部分内容(如按钮的显隐、审核信息的展示等等),而整个页面的大体结构没有发生改变。

其用于解决多状态页面由于各个状态杂糅在代码中而导致的后期升级维护困难的问题。

该模式在完整情况下使用JS作为配置文件,页面中的每一个状态都相当于一个JS插件,主页面根据现有状态调用具体的JS配置文件来控制页面内容显示与业务逻辑,故因此取名 “页面插件化” 。

优点:

  • 可以十分清晰的分离页面中各个状态的业务逻辑,在维护时无需担心会意外影响到其他状态的代码逻辑,降低风险。
  • 使用JS作为配置文件,可以使用JSX轻松定制主页面的特定区域,加入专有逻辑。
  • 天生适用于组件化页面开发,可以进一步减轻主页面主流程代码逻辑压力。
  • 起到 “eslint” 的效果,强行让团队成员 “think before coding” ,拉长页面的生命周期。

缺点:

  • 依赖页面主框架设计,页面后期维护是否便利几乎是在主框架设计完成后就已经决定了。
  • 依赖团队成员的理解能力或者文档的维护,团队成员必须提前理解主页面逻辑才能进行修改。

对比

从一个简单的详情页开始

从一个简单的详情页开始

现在我们的目标非常简单,整个详情页只有A、B、C、D四个区块,要根据三种不同的状态(Ready、Processing、Finished)来控制他们的显隐,并且在页面顶部的Tips中显示当前状态显示的区块名称。

  • Ready:显示A、D区块
  • Processing:显示A、B、D区块
  • Finished:显示A、B、C区块
// 一般写法
<div id="app">
  <div class="header">
    <el-radio-group v-model="status">
      <el-radio-button :label="0">Ready</el-radio-button>
      <el-radio-button :label="1">Processing</el-radio-button>
      <el-radio-button :label="2">Finished</el-radio-button>
    </el-radio-group>
    <span class="tips">
      <template v-if="status === 0">Display:A, D</template>
      <template v-if="status === 1">Display:A, B, D</template>
      <template v-if="status === 2">Display:A, B, C</template>
    </span>
  </div>
  <div class="container-group">
    <div class="container container-a"></div>
    <div v-if="status" class="container container-b"></div>
    <div v-if="status === 2" class="container container-c"></div>
  </div>
  <div v-if="status !== 2" class="container container-d"></div>
</div>

<script>
  const App = {
    data() {
      return {
        status: 0 // 0: ready 1: processing 2: finished
      }
    }
  }

  Vue.createApp(App).use(ElementPlus).mount('#app')
</script>

这个 “一般写法” 就很一般,v-if=status 一把梭判断完了,代码上目前看起来还算整洁,行数也不多,一下就能看懂。 还能更好吗?我们看一下用 “页面插件化” 的方式来写会是什么样:

// 页面插件化写法
<div id="app">
  <div class="header">
    <el-radio-group v-model="status">
      <el-radio-button :label="0">Ready</el-radio-button>
      <el-radio-button :label="1">Processing</el-radio-button>
      <el-radio-button :label="2">Finished</el-radio-button>
    </el-radio-group>
    <span v-if="config.header.tips" class="tips">{{ config.header.tips }}</span>
  </div>
  <div class="container-group">
    <div v-if="config.container.a" class="container container-a"></div>
    <div v-if="config.container.b" class="container container-b"></div>
    <div v-if="config.container.c" class="container container-c"></div>
  </div>
  <div v-if="config.container.d" class="container container-d"></div>
</div>

<script>
  const ready = {
    header: { tips: 'Display:A, D' },
    container: { a: true, d: true }
  }

  const processing = {
    header: { tips: 'Display:A, B, D' },
    container: { a: true, b: true, d: true }
  }

  const finished = {
    header: { tips: 'Display:A, B, C' },
    container: { a: true, b: true, c: true }
  }

  const App = {
    data() {
      return {
        status: 0 // 0: ready 1: processing 2: finished
      }
    },
    computed: {
      config() {
        return [ready, processing, finished][this.status]
      }
    }
  }

  Vue.createApp(App).use(ElementPlus).mount('#app')
</script>

你会发现几乎所有Dom都被加上了 v-if 判断,但是它们判断的依据都特别简单——直接根据 config 对象中的特定值,它们不再关心 status 到底为何值了。 而我们看向 <script /> 里面,其中的业务逻辑被分离的可以说是特别清晰了,需要哪个页面区块,直接 container.x = true 就完事了。你甚至可以直接从配置代码中轻松看出页面的整体逻辑,而无需深入分析。

添加一个新状态

而现在,我们亲爱的产品经理来了,提了一个非常合理的需求——失败是成功之母,我们需要一个失败状态😀,而处于该状态时显示B、C两个区域。 我们先来看下 “一般写法” 是怎么改的:

<div id="app">
  <div class="header">
    <el-radio-group v-model="status">
      <el-radio-button :label="0">Ready</el-radio-button>
      <el-radio-button :label="1">Processing</el-radio-button>
      <el-radio-button :label="2">Finished</el-radio-button>
+     <el-radio-button :label="3">Failed</el-radio-button>
    </el-radio-group>
    <span class="tips">
      <template v-if="status === 0">Display:A, D</template>
      <template v-if="status === 1">Display:A, B, D</template>
      <template v-if="status === 2">Display:A, B, C</template>
+     <template v-if="status === 3">Display:B, C</template>
    </span>
  </div>
  <div class="container-group">
-   <div class="container container-a"></div>
+   <div v-if="status !== 3" class="container container-a"></div>
    <div v-if="status" class="container container-b"></div>
-   <div v-if="status === 2" class="container container-c"></div>
+   <div v-if="[2, 3].includes(status)" class="container container-c"></div>
  </div>
- <div v-if="status !== 2" class="container container-d"></div>
+ <div v-if="![2, 3].includes(status)" class="container container-d"></div>
</div>

<script>
  const App = {
    data() {
      return {
-        status: 0 // 0: ready 1: processing 2: finished
+        status: 0 // 0: ready 1: processing 2: finished 3: failed
      }
    }
  }

  Vue.createApp(App).use(ElementPlus).mount('#app')
</script>

还好还好,改动不大,就是中间区块 v-if 条件变了不少,需要重新思量一下~那么 “页面插件化” 的维护效果会好吗?

<div id="app">
  <div class="header">
    <el-radio-group v-model="status">
      <el-radio-button :label="0">Ready</el-radio-button>
      <el-radio-button :label="1">Processing</el-radio-button>
      <el-radio-button :label="2">Finished</el-radio-button>
+     <el-radio-button :label="3">Failed</el-radio-button>
    </el-radio-group>
    <span v-if="config.header.tips" class="tips">{{ config.header.tips }}</span>
  </div>
  <div class="container-group">
    <div v-if="config.container.a" class="container container-a"></div>
    <div v-if="config.container.b" class="container container-b"></div>
    <div v-if="config.container.c" class="container container-c"></div>
  </div>
  <div v-if="config.container.d" class="container container-d"></div>
</div>

<script>
  const ready = {
    header: { tips: 'Display:A, D' },
    container: { a: true, d: true }
  }

  const processing = {
    header: { tips: 'Display:A, B, D' },
    container: { a: true, b: true, d: true }
  }

  const finished = {
    header: { tips: 'Display:A, B, C' },
    container: { a: true, b: true, c: true }
  }

+ const failed = {
+   header: { tips: 'Display:B, C' },
+   container: { b: true, c: true }
+ }

  const App = {
    data() {
      return {
-       status: 0 // 0: ready 1: processing 2: finished
+       status: 0 // 0: ready 1: processing 2: finished 3: failed
      }
    },
    computed: {
      config() {
-       return [ready, processing, finished][this.status]
+       return [ready, processing, finished, failed][this.status]
      }
    }
  }

  Vue.createApp(App).use(ElementPlus).mount('#app')
</script>

这就没了?这就改完了?你这工作不饱和啊,得再多来点需求啊😅。

我们可以很清楚的看到,应用了 “页面插件化” 思想的页面,在增加状态就只需要添加一个 “新插件” ,直接设置B、C区块显示,并且控制Tips的显示内容就可以了。而由于每个状态都是一个 “插件” ,它们之间的逻辑相互独立,所以维护代码不会影响到其他状态,就好像这个页面只有一个状态似的。

添加更多的按钮

添加更多的按钮

那么现在这个页面渐渐的完整了起来,为了客户的使用便利,所以产品又提了一个变更~ Ready、Processing状态下,客户要是想终止订单怎么办,Failed的时候客户想再申请一次试一试,怎么样方便操作~

欸~你看这个需求是不是很合理啊,那就加俩按钮吧,Ready、Processing显示Abort,Failed显示Reapply。

<div id="app">
  <div class="header">
    <el-radio-group v-model="status">
      <el-radio-button :label="0">Ready</el-radio-button>
      <el-radio-button :label="1">Processing</el-radio-button>
      <el-radio-button :label="2">Finished</el-radio-button>
      <el-radio-button :label="3">Failed</el-radio-button>
    </el-radio-group>
    <span class="tips">
-     <template v-if="status === 0">Display:A, D</template>
+     <template v-if="status === 0">Display:A, D, Abort</template>
-     <template v-if="status === 1">Display:A, B, D</template>
+     <template v-if="status === 1">Display:A, B, D, Abort</template>
      <template v-if="status === 2">Display:A, B, C</template>
-     <template v-if="status === 3">Display:B, C</template>
+     <template v-if="status === 3">Display:B, C, Reapply</template>
    </span>
  </div>
  <div class="container-group">
    <div v-if="status !== 3" class="container container-a"></div>
    <div v-if="status" class="container container-b"></div>
    <div v-if="[2, 3].includes(status)" class="container container-c"></div>
  </div>
  <div v-if="![2, 3].includes(status)" class="container container-d"></div>
+ <div class="actions">
+   <el-button>Close</el-button>
+   <el-button v-if="[0, 1].includes(status)" type="primary" @click="onAbort">Abort</el-button>
+   <el-button v-if="status === 3" type="primary" @click="onReapply">Reapply</el-button>
+ </div>
</div>

<script>
  const App = {
    data() {
      return {
         status: 0 // 0: ready 1: processing 2: finished 3: failed
      }
+   },
+   methods: {
+     onAbort () {
+       alert('onAbort')
+     },
+     onReapply () {
+       alert('onReapply')
+     }
    }
  }

  Vue.createApp(App).use(ElementPlus).mount('#app')
</script>

欸~是不是有那味儿了,这样一搞有没有一种似曾相识的感觉,这不就是工程里那一堆详情页嘛。我们来分析一下 “一般写法” 的代码,#app 内的代码修改都是老生常谈了,.actions 内的按钮也是普普通通的写进去,onAbortonReapply 的方法也是普普通通的写进 methods 里面。看上去没啥问题。

但是我们想一下,onReapply 这个函数只有 Failed 一个状态会使用,它的出现使得整个页面的代码开始复杂了起来。以后也许 Finished 这个状态会显示一个 onApplyAgain(再次申请) 的按钮,而它和 onReapply 业务逻辑还不完全一样...长此以往,主页面的代码就会越来越多,$hit山也会越堆越高...

那么我们再看看下页面插件化:

<div id="app">
  <div class="header">
    <el-radio-group v-model="status">
      <el-radio-button :label="0">Ready</el-radio-button>
      <el-radio-button :label="1">Processing</el-radio-button>
      <el-radio-button :label="2">Finished</el-radio-button>
      <el-radio-button :label="3">Failed</el-radio-button>
    </el-radio-group>
    <span v-if="config.header.tips" class="tips">{{ config.header.tips }}</span>
  </div>
  <div class="container-group">
    <div v-if="config.container.a" class="container container-a"></div>
    <div v-if="config.container.b" class="container container-b"></div>
    <div v-if="config.container.c" class="container container-c"></div>
  </div>
  <div v-if="config.container.d" class="container container-d"></div>
+ <div class="actions">
+   <el-button>Close</el-button>
+   <el-button v-if="config.actions?.abort" type="primary" @click="onAbort">Abort</el-button>
+   <el-button v-for="(item, i) in config.actions?.custom || []" :key="i" type="primary" @click="item.onClick">
+     {{ item.text }}
+   </el-button>
+ </div>
</div>

<script>
  const ready = {
-   header: { tips: 'Display:A, D' },
+   header: { tips: 'Display:A, D, Abort' },
-   container: { a: true, d: true }
+   container: { a: true, d: true },
+   actions: { abort: true }
  }

  const processing = {
-   header: { tips: 'Display:A, B, D' },
+   header: { tips: 'Display:A, B, D, Abort' },
-   container: { a: true, b: true, d: true }
+   container: { a: true, b: true, d: true },
+   actions: { abort: true }
  }

  const finished = {
    header: { tips: 'Display:A, B, C' },
    container: { a: true, b: true, c: true }
  }

  const failed = {
-   header: { tips: 'Display:B, C' },
+   header: { tips: 'Display:B, C, Reapply' },
-   container: { b: true, c: true }
+   container: { b: true, c: true },
+   actions: {
+     custom: [
+       { text: 'Reapply', onClick: () => { alert('onReapply') } }
+     ]
+   }
  }

  const App = {
    data() {
      return {
        status: 0 // 0: ready 1: processing 2: finished 3: failed
      }
    },
    computed: {
      config() {
        return [ready, processing, finished, failed][this.status]
      }
+   },
+   methods: {
+     onAbort () {
+       alert('onAbort')
+     }
    }
  }

  Vue.createApp(App).use(ElementPlus).mount('#app')
</script>

欸~你会发现从 .actions 开始事情就变得不一样了起来,所有的状态都会显示Close没错,config.actions?.abort 控制Abort按钮显示也很合理,但是后面跟着的却不是想象中的 config.actions?.reapply 而是由 v-for 循环渲染的 config.actions?.custom || []。其实通过这种形式,我们就可以定制各个状态下显示的按钮,Failed 显示Reapply? Finished 显示ApplyAgain? 统统没有问题,你就往 custom 数组内加就完事了。

而如果后期 FailedFinished 全部都是显示Reapply,那你就可以把它拿到主页面中去,像Abort按钮那样实现逻辑控制。

分离代码到文件

哦,也许你会说,现在主页面让页面插件化搞的代码越来越多了,插件代码占了半壁江山啊。

别激动...这是JS,是应用了插件化的思想的页面,你其实一直都可以把插件代码拿到外面去,然后重新在主页面中引入的。

项目结构看起来就像这样:

.
├── index.html
└── status
    ├── failed.js
    ├── finished.js
    ├── processing.js
    └── ready.js