【转载】事件驱动&数据驱动

负责的业务代码中有一段比较复杂的逻辑,找来当时的负责人简单讲了下,没想到这次谈话像是洪七公给郭靖讲降龙一般,从思路上改变了编程范式。

如果在掘金上搜索“Vue 数据响应式”会搜到一堆关于原理的讲解,但没有讲如何正确使用数据响应式,或者说,我以前的编程思路中并不是以数据的角度去写 Vue 的,仍是一种 jQuery 的事件驱动试编程思路。

逛 github 时发现了这项目 godbasin/vue-ebook: 《深入理解 Vue.js 实战》 摘录一下第九章关于数据驱动和事件驱动编程思维的部分。

第 9 章 思维转变与大型项目管理

9.1 编码思维转变

首先介绍事件驱动和数据驱动两种编码思维模式,其实两种写码方式它和你使用 jQuery 还是使用 Vue 等框架并没有多大的关系,更多会在于设计代码时的一个思考方式。

但事实上当年我在从 jQuery 切换到 AngularJS 的时候,也是常常满屏的疑惑,不知道从何下手。而事件驱动和数据驱动的思维调整,也是在这一个过渡过程体会比较深刻,所以这里还是会结合 jQuery 和 Vue 来讲解一下这样的转变。

我们先来看看事件驱动的编码方式。

9.1.1 事件驱动

由于前端是页面交互出身的,运作模式也是基于 I/O 模式。你知道为什么 JavaScript 是单线程的吗?其实更多是因为对页面交互的同步处理。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM,若是多线程会导致严重的同步问题。

为什么这么说呢?我们可以从 GUI(图形用户界面)来讲起。

GUI 与事件

GUI(图形用户界面)与事件驱动的渊源可谓不浅。GUI 应用程序的特点是注重与用户的交互,因此程序的执行取决于与用户的实时交互情况,大部分的程序执行需要等到用户的交互动作发生之后。由于用户的输入频率并不高,若不停轮询获取用户输入(点像 ajax 轮询),这样的方式存在以下问题:

  1. 资源利用率低。
  2. 不能真正做到及时同步。

由于 GUI 程序的执行流程由用户控制,并且不可预期,为了适应这种特点,我们通常采用事件驱动的编程方法。程序对事件的响应,其实就是调用预先编制好的代码来对事件进行处理。

如果 Javascript 完全使用同步的单线程方式来执行,我们就无法对多个事件进行监听。除此之外,我们的页面交互就会变得很慢,还会有很大一部分的等待时间,造成很多资源浪费。所以 Javascript 是异步的,支持多个事件的并发,而 JavaScript 的并发模型基于“事件循环”。在 Javascript 中,主线程从"任务队列"中读取事件,这个过程是循环不断的,整个的这种运行机制又称为 Event Loop(事件循环)。

在 GUI 的使用场景和并发的 Javascript 设计下,我们写代码的时候也会代入这样的思维:用户输入 => 事件响应 => 代码运行 => 刷新页面状态。于是乎,刚开始写页面功能的思路如下:

  1. 开发静态页面。
  2. 添加事件监听,包括用户输入、http 请求、定时器触发等事件。
  3. 针对不同事件,编写不同的处理逻辑,包括获取事件状态/输入、计算并更新状态等。
  4. 根据计算后的数据状态,重新渲染页面。

通俗地说,事件驱动思维是从事件响应出发,来完成应用的设计和编程。事件驱动的编码方式,在 jQuery 提供了链式调用、方便的$()元素选择、on()事件监听之后进入了热潮,我们经常会写这样的代码:

// 某个父元素上监听多个子节点的事件并处理
// setLength 函数用来限制长度
someDom
  // 子节点 A 是输入框,样式为 classA
  .on("keyup", ".classA", function (ev) {
    inputObject.a = setLength(ev, 16);
  })
  // 子节点 B 也是输入框,样式为 classB
  .on("keyup", ".classB", function (ev) {
    inputObject.b = setLength(ev, 8);
  })
  // 子节点 C 也是输入框,样式为 classC
  .on("keyup", ".classC", function (ev) {
    inputObject.c = setLength(ev, 8);
  })
  // 子节点 D 为下拉列表,样式为 classD
  .on("change", ".classD", function (ev) {
    inputObject.d = ev.target.value;
  });

在 jQuery 的帮助下,事件绑定写起来真的太爽啦,虽然可读性和维护性上都有缺陷,但在当时也是性价比很高的开发方式了。所以当时很多开发者会选择(或是不自主地)使用事件驱动方式来写代码。

事件驱动的流程

事件驱动其实是前端开发中最容易理解的编码方式,例如我们写一个提交表单的页面,用事件驱动的方式来写的话,会是这样一个流程:

(1) 编写静态页面。

<!-- 实现静态页面 -->
<form>
  Name:
  <p id="name-value"></p>
  <input type="text" name="name" id="name-input" />
  Email:
  <p id="email-value"></p>
  <input type="email" name="email" id="email-input" />
  <input type="submit" />
</form>

(2) 给对应的元素绑定对应的事件。

例如给 input 输入框绑定输入事件,在前端页面中绑定事件监听通过addEventListener来实现:

var nameInputEl = document.getElementById("name-input");
var emailInputEl = document.getElementById("email-input");
// 监听输入事件,此时 updateValue 函数未定义
nameInputEl.addEventListener("input", updateNameValue);
emailInputEl.addEventListener("input", updateEmailValue);

(3) 事件触发时,更新页面内容。

我们给元素绑定了事件监听之后,在事件触发的时候,我们需要进行相关逻辑的处理(发起请求、更新页面内容等),这里我们将用户输入的内容更新到页面中展示:

var nameValueEl = document.getElementById("name-value");
var emailValueEl = document.getElementById("email-value");
// 定义 updateValue 函数,用来更新页面内容
function updateNameValue(e) {
  nameValueEl.innerText = e.srcElement.value;
}
function updateEmailValue(e) {
  emailValueEl.innerText = e.srcElement.value;
}

以上这个流程,是很常见的前端编码思维,我们称之为事件驱动模式。如果使用了 Vue,也是可以很容易用事件驱动的方式这样写代码:

<template>
  <!-- 1. 绘制 HTML -->
  <div>
    Name:
    <p>{{ name }}</p>
    <!-- 2. 使用 v-on 绑定事件,这里绑定 updateValue 方法 -->
    <input type="text" v-bind:value="name" v-on:change="updateValue" />
    <!-- 上面 input 可以简写为: -->
    <input type="text" v-model="name" />
  </div>
</template>

<script>
  export default {
    data() {
      return {
        name: "",
      };
    },
    methods: {
      // 3. change 事件触发时,更新数据
      updateValue(event) {
        this.name = event.target.value;
      },
    },
  };
</script>

Vue 帮我们省去了元素选择、HTML 拼接并更新等这些工作,同时直接在模板上绑定的方式也简化了(使用v-on:或者@),方便开发者阅读和理解。

我们再来回顾下事件驱动的方式:
(1) 开发静态页面。
(2) 在对应的元素上绑定事件。
(3) 实现被绑定的事件功能,例如获取数据、更新页面等。

整个思考的链路在于触发了怎样的操作这个操作会导致什么后果(即需要做怎样的处理),事件驱动的思维方式都是围绕“操作”(在前端语言中,也就是“事件”),我们跟随着“操作”的链路来实现代码编写。

如今前端页面交互也越来越复杂,我们在设计功能的时候,也常常需要使用抽象的能力。作为程序员,我们最常用的抽象方式就是数据抽象,而前端的界面、组件、配置等,都可以抽象成数据表达。关于怎么进行抽象,《第 10 章 如何正确地进行抽象》内容会详细介绍。

我们看看数据驱动的思维方式是怎样的。

9.1.2 数据驱动

其实不管是生活中还是工作中,几乎所有的事物我们都可以抽象为数据。像游戏里面的角色、物品、经验值、天气、时间等等,都是数据。游戏其实也算是对真实世界抽象的一种,而抽象之后,最终都可呈现为数据。

如果要对事件驱动和数据驱动进行直观的比较,其实最大的转变是,以前会把组件视为 DOM,把事件/逻辑处理视为 Javascript,把样式视为 CSS。而当转换思维方式之后,组件、事件、逻辑处理、样式都是一份数据,我们只需要把数据的状态和转换设计好,剩下的实现则由具现方式(模版引擎、事件机制等)来实现。

数据驱动的流程

既然前面介绍了事件模型一般的编码流程,我们再来看看,同样的写一个提交表单的页面,用数据驱动的方式来写的话,会是下面这样的步骤过程。

(1) 设计数据结构。

首先我们需要,将页面中会变化和不会变化的内容隔离开,然后对其中会变化的内容进行抽象,再根据抽象结果来设计数据结构。例如这里的表单,可变的部分包括两个输入框、两处展示输入框内容的文字。但其实涉的数据只有两个,一个是名字name,另外一个是邮件email,都可以用字符串表示:

// 包括一个 name 和 一个 email 的值
export default {
  data() {
    return {
      name: "",
      email: "",
    };
  },
};

(2) 完成静态页面,同时把数据和事件绑定到页面中。

接下来我们把静态页面开发出来,然后将步骤(1)中的数据绑定到页面中需要使用/展示的地方,同时在一些事件触发的元素上绑定对应的方法:

<form>
  Name:
  <p>{{ name }}</p>
  <input
    type="text"
    name="name"
    v-bind:value="name"
    v-on:input="updateNameValue"
  />
  Email:
  <p>{{ email }}</p>
  <input
    type="email"
    name="email"
    v-bind:value="email"
    v-on:input="updateEmailValue"
  />
  <input type="submit" />
</form>

(3) 事件绑定的方法(methods)中,补充相应的逻辑处理。

我们在第(2)步中绑定了一下事件监听,主要是两个输入框v-on:input绑定的输入事件,我们需要在用户输入的同时更新到data中:

export default {
  data() {
    return {
      name: "",
      email: "",
    };
  },
  methods: {
    // 绑定 input 事件,获取到输入值,设置到对应的数据中
    updateNameValue(event) {
      this.name = event.target.value;
    },
    updateEmailValue(event) {
      this.email = event.target.value;
    },
  },
};

我们在设置数据(this.name = event.target.value)的时候,Vue 会自动帮我们更新页面中绑定该数据的内容({{ name }}{{ email }}处),我们就不用自己手动获取元素然后更新节点内容了。

其实我们也可以先开发静态模板,然后根据可变的内容来设计数据结构。事件驱动和数据驱动一个很重要的区别在于,我们是从每个事件的触发(“操作”)为中心来设计我们的代码,还是以数据为中心,接收事件触发和更新数据状态的方式来写代码。

我们再来详细地对比一下。

9.1.3 数据驱动和事件驱动

这里或许你们会有些疑问,看起来只是写代码的顺序不一样而已,甚至写代码的顺序都是一样的,那事件驱动和数据驱动的区别在哪?一个很有用的区别在于,从事件驱动转换到数据驱动思维后,我们在编程实现的过程中,更多的是思考数据状态的维护和处理,而无需过于考虑 UI 的变化和事件的监听,即使我们页面全部重构了,影响到的只有模板中绑定的部分,重新绑定一下就可以了。

使用数据驱动来写代码会强迫开发者有一个前置条件,你会需要去设计一个数据结构,或者也可以称之为一个模型。在代码开发前的数据设计会有什么好处呢?

数据的获取和修改

我们在设计数据的时候,会进行将页面抽象成数据的一个步骤,例如一个表单里的内容可以抽象成一个对象,而一个列表中的内容可以表达成一个由对象组成的数组。

在 Vue 中,我们可以直接将数据绑定到页面元素中,而当这些内容变动的时候,我们只需要按照设计的数据格式来更新数据就可以。例如,我们新建了一个表单,然后在从后台拉取数据、获取填写的内容进行校验并提交到后台、提交成功后清空已填写内容等,可以这么实现:

<template>
  <form>
    <div><a>姓名</a><input v-model="formInfo.name" /></div>
    <div><a>手机号码</a><input type="tel" v-model="formInfo.phone" /></div>
    <div><a>家庭地址</a><textarea v-model="formInfo.address"></textarea></div>
    <button @click="submit">提交</button>
  </form>
</template>
<script>
  export default {
    data() {
      return {
        // 表单内容信息
        formInfo: {
          name: "",
          phone: "",
          address: "",
        },
      };
    },
    mounted() {
      // 从后台拉取数据
      this.getPhoneInfo();
    },
    methods: {
      getPhoneInfo() {
        // request 为一个请求库示例,返回 Promise
        request({
          url: "test",
        }).then((res) => {
          // 获取数据,并填入
          this.phoneInfo = res.phone_info;
        });
      },
      submit() {
        // 提取填写的内容并提交
        const { name, phone, address } = this.phoneInfo;
        // 这里也可以进行一些表单验证,此处略
        request({
          url: "test",
          data: {
            name,
            phone,
            address,
          },
        }).then(() => {
          // 成功后可以清空
          this.phoneInfo = {
            name: "",
            phone: "",
            address: "",
          };
        });
      },
    },
  };
</script>

在这整个过程中(获取数据并更新到页面、获取用户输入的内容、清空输入框内容),我们只需要获取和修改phoneInfo这个数据就可以了。而 Vue 框架会帮我们完成从页面元素获取数据,以及将数据更新到页面展示中这些工作。那是否意味着使用 jQuery 就不可以这样做了呢?并不是,如果要在 jQuery 中使用数据驱动,我们需要自己去实现从页面获取数据、更新到页面中这样的逻辑,例如:

function getPhoneInfo() {
  // 获取数据并返回
  const name = $("#name").val();
  const phone = $("#phone").val();
  const address = $("#address").val();
  return {
    name,
    phone,
    address,
  };
}
function setPhoneInfo(phoneInfo) {
  const { name, phone, address } = phoneInfo;
  // 给元素设置数据
  $("#name").val(name);
  $("#phone").val(phone);
  $("#address").val(address);
}

这样,我们在 jQuery 中实现以上逻辑应该是这样:

<form>
  <div><a>姓名</a><input id="name" /></div>
  <div><a>手机号码</a><input type="tel" id="phone" /></div>
  <div><a>家庭地址</a><textarea id="address"></textarea></div>
  <button id="submit">提交</button>
</form>
<script>
  // 页面加载就发起请求
  $().ready(() => {
    // request 为一个请求库示例,返回 Promise
    request({
      url: "test",
    }).then((res) => {
      // 获取数据,并填入
      setPhoneInfo(res.phone_info);
    });

    // 绑定点击事件
    $("#submit").on("click", () => {
      // 提取填写的内容并提交
      const { name, phone, address } = getPhoneInfo();
      // 这里也可以进行一些表单验证,此处略
      request({
        url: "test",
        data: {
          name,
          phone,
          address,
        },
      }).then(() => {
        // 成功后可以清空
        setPhoneInfo({
          name: "",
          phone: "",
          address: "",
        });
      });
    });
  });
</script>

所以,其实使用数据驱动还是事件驱动,跟使用 jQuery 还是 Vue 并没有多大关系,只是我们在整个页面的交互过程,从以往的从用户交互为中心,调整成以数据的状态扭转为中心,来进行一些逻辑的实现。

但是数据驱动是否又跟 Vue 完全没关系呢?对我个人来说,Vue、Angular、React 这些前端框架的出现,推动了我从事件驱动转变成数据驱动,从而我能更好地使用这些框架。技术的迭代、工具的更新和个人的成长,有时候是相辅相成的。

事件流与数据流

使用数据驱动还有一个好处,就是基于模型设计的代码,即使经历了需求变更、页面结构调整、后台接口调整,也可以快速地实现更新和支持。还是以上面的表单作为例子,我们在基于事件驱动开发,通常的思考和写代码的方式是:

  • 页面加载时 -> 请求后台 -> 拿到数据 -> 更新到页面
  • 用户点击提交时 -> 获取用户输入内容 -> (校验用户输入内容 ->) 提交给后台 -> 清空已输入内容

也就是说,事件驱动的特点是,以某个交互操作为起点,流程式地处理逻辑。流程式的代码,在遇到中间某个环节变更,就需要同时更新该变更点前后环节的流程交接。例如我们在页面加载的时候,需要先加载本地缓存,再从后台请求更新。如果是上面的流程,我们需要新增“本地获取缓存”的环节,同时需要在“页面加载时”、“更新到页面”两个环节进行衔接。

而数据驱动的思考方式特点是,以数据为中心,思考数据的输入和输出:

  • phoneInfo的数据来源包括两个:从后台获取、用户输入、重置清空
  • phoneInfo的数据去处包括:提交给后台

同样的,如果我们需新增“本地获取缓存”的环节,在数据驱动的情况下,只是增加了一个数据来源,对于整个模型影响会小很多:

  • phoneInfo的数据来源包括两个:从本地缓存获取、从后台获取、用户输入、重置清空

其实在我们日常开发中,更多时候是结合了事件驱动和数据驱动来使用。而如果现在的你刚入门没多久,也不用纠结是否真的用了某种思维模式、是否用了什么设计模式,我们在一次次的开发过程中,会不断地积累和加深一些思考,适合自己的才是最好的。

9.2 大型应用管理

说到大型应用,常见的我们项目中需要考虑加载性能和加载速度相关的,这些在很多前端相关的文章或者书籍中都可找到。这里主要介绍一些在大型项目中会使用到的工具,以及好用的技巧,还有项目规范、合作开发等经验,供大家参考。

9.2.1 代码打包

我们先从最基础的代码打包来讲起。

路由懒加载

当我们的应用变得很大,为了提升首屏加载的体验,我们需要对代码进行分块打包。一般来说,不同的框架有不同的异步加载解决方案,同时可以结合打包工具(Webpack、Gulp 等)进行分块打包。我们可以把首屏相关的东西打包到 bundle 文件中,其他模块分块打包到 chunk 文件,首页只需要加载 bundle 文件,然后在空闲的时候或者需要使用的时候再按需加载 chunk 文件模块。

通常情况下,我们会结合路由进行分块打包,路由管理工具大部分都支持异步加载。我们可以根据自己需要,来打包成多个文件,在路由进入的时候才获取和加载。Vue 可参考《第 7 章 Vue Router 路由搭建应用》中路由懒加载相关内容。

Source Map

这里需要讲一下,Source Map 就是一个信息文件,里面储存着位置信息。也就是说,转换后的代码的每一个位置,所对应的转换前的位置。有了它,我们在定位压缩后的代码时,浏览器可以将直接显示原始代码,而不是压缩后的代码。这无疑给开发者带来了很大的便利。在开发环境下,还能通过 Chrome 匹配源文件进行在线 debug 和修复源码。大家也可以自行搜索下,进行了适当的配置之后,我们可以在浏览器上直接调试 CSS 并保存到本地文件,体验真的很棒。

Tree-shaking

我们在引入一些开源代码或是公共库的时候,其实大部分时间我们都只是使用其中里面的一小部分代码。Tree-shaking 支持按需打包,没有被引用的模块不会被打包进来,减少我们的包大小,缩小应用的加载时间,给用户更好的体验。

Tree-shaking 最初是 Rollup 提出并实现。Rollup 会静态分析代码中的 import,并将排除任何未实际使用的代码。这允许我们架构于现有工具和模块之上,而不会增加额外的依赖或使项目的大小膨胀。在 Webpack 2 里也加入了 Tree-shaking 新特性,而 Vue 3.0 中也对支持 Tree-shaking 进行了优化,降低了代码包大小,可以参考《第 16 章 关于 Vue 3.0》

9.2.2 抽象、组件化和配置化

在我们开始写重复的代码、或是进行较多的复制粘贴的时候,大概是时候需要考虑对组件进行适当的抽象(也可以成为封装)了。好的抽象能大量减少重复代码,同时对项目整体有更深入的理解。过度的抽象会增加项目的复杂度,同时降低了代码的可读性和维护性。所以关键在于适度,好的办法是结合产品和业务来进行抽象,例如一个播放器组件、日历组件、快速导航栏、快捷菜单等组件封装,便于多次使用。

应用的抽象、配置化相关,可以参考《第 10 章 如何正确地进行抽象》《第 13 章 实战:表单配置化实现》。同时,我们也需要把一些相同的方法抽离,封装成通用的工具库,像常用的时间转换、字符串处理、http 请求等,都可以单独拎出来维护。

9.2.3 状态和数据管理

我们的应用里,多数会面临组件的某些状态和数据相互影响、相互依赖的问题。现在也有比较成熟的解决方案和状态管理工具,像 Vuex、Redux、Mobx 等,我们需要结合自身的框架和业务场景来使用。像父子组件的交互、应用内无直接管理的数据状态共享、事件的传递等,也都需要结合实际适当地使用,可参考《第 11 章 全局数据管理与 Vuex》

9.2.4 代码流程规范

代码规范其实是团队合作中最重要的地方,使用统一的代码规范,会大大减少我们接手别人代码时候感觉到头疼的次数。好的代码习惯很重要,命名、适当的注释、适度的抽象等,会对代码的可读性有很大的提升。但是问题是每个人习惯都不一样,所以在此之上,我们需要有统一的代码规范。由于每个人习惯不一致,所以不可能让所有人都满意,代码规范的存在本身就已经有很重要的作用了。

项目结构

项目结构其实也很重要,我们在设计一个项目的时候,项目结构设计得清晰,维护就会越方便。项目结构设计有几个技巧:

  • 公共库、公共组件、公共配置分开维护
  • 静态资源文件单独放
  • 与构建相关的配置文件,可以放在最外层
  • 最后打包生成的文件,可以放在 dist 或者 built 目录下
  • README.md 文件放在最外层

在 Vue 中,我们可以这么组织:

├─dist                      // 编译之后的项目文件
├─src                       // 开发目录
│  ├─assets                 // 静态资源
│     ├─css                   // 公共css
│     ├─img                         // 图片资源
│  ├─utils                  // 公共工具库
│  ├─config                 // 公共配置
│  ├─components             // 公共组件
│  ├─pages                  // 页面,根据路由结构划分
│  ├─App.vue                // 启动页面,最外层容器组件
│  ├─main.js                // 入口脚本
├─ babel.config.js          // babel 配置文件
├─ vue.config.js            // vue 自定义配置,与 webpack 配置相关
├─ package.json             // 项目配置
├─ README.md                // 项目说明

同时,这样的结构可以写在 README 文件中维护,在协作过程、新人加入的时候就可以直接清晰地理解项目代码,也可以快速地找到需要的文件放置在哪里。而 README 在项目管理中也是很重要的一个文档,我们也来看看。

养成写 README 的习惯

一般来说,程序员拿到一个项目,首先要找一下文档。而程序员的文档基本上都是用 README 来管理的,一般来说 README 里面会包括:

  • 项目简单说明(背景、相关接口人)
  • 如何运行代码(安装、启动、构建)
  • 目录结构说明
  • 更多其他相关说明(配置文件、涉及文档)

如果涉及文档太多,也可以统一放置在 docs 文件夹下面。这样,新人在接手到这个项目的时候,可以根据 README 中的指引自行进行代码下载和运行,也可以快速地找到相关的指引和责任人。

代码流程规范工具

一些工具可以很好地协助我们,像 Eslint、Tslint 等(目前 Tslint 已不再维护,可以使用 Eslint),加上代码的打包工具协助,可以在一些流程中强校验代码规范。还可以使用像 prettier 这样的工具,能自动在打包的时候帮我们进行代码规范化。

除了这些简单的什么驼峰、全等、单引双引等基础的规范,其实更重要的是流程规范。最基础的比如改动公共库或是公共组件的时候,需要进行 code review。通常我们使用 Git 维护代码,通过 merge request 等方式来进行 code review,这样在合并或是版本控制上有更好的体验。但其实最重要的还是沟通,沟通是一个团队里必不可少、同时又很容易出问题的地方。

项目的维护永远是程序员的大头,一般来说前人种树后人乘凉。但是很多时候,大家会为了一时的方便,对代码规范比较随意,就导致了我们经常看到有人讨论“继承”来的代码,就变成了了前人挖坑后人填坑的方式了。或许相比新技术的研究和造轮子,有个好的写码习惯、提高项目维护性并不能带来短期的利益,但是其实身为一个负责任的程序员,还是要对有些追求的。

项目管理和维护其实是很重要的一件事,但这些在日常工作中常常会被忽视。更多人的关注点在系统上线,而运营和维护其实也是同样有价值和有作业的工作,我们也应该重视起来。

互联网在不断地发展,新技术层出不穷,处在叶子尖端的前端领域更是在不断摇晃。我们在一个陌生环境中,会习惯性用最熟悉的行为来继续。但如果以开放的心态来接受新事物,你会有想象不到的收获。