第9章 思维转变与大型项目管理
这些年来前端发展进步了很多,不管是工具、框架的丰富,还是前端在各个领域的应用拓展,越来越多的人开始加入前端大军。而随着拥有的能力越来越多,如果我们不在适当的时候进行梳理和调整,越到后面就越抽不出时间进行思考,因为已经被死亡代码(俗称屎山)埋没了。为了避免这种事情的发生,我也常常进行一些思考和自我调整,一起分享给你们。
1. 编码思维转变
首先介绍事件驱动和数据驱动两种编码思维模式,其实两种写码方式它和你使用 jQuery 还是使用 Vue 等框架并没有多大的关系,更多会在于设计代码时的一个思考方式。
但事实上当年我在从 jQuery 切换到 AngularJS 的时候,也是常常满屏的疑惑,不知道从何下手。而事件驱动和数据驱动的思维调整,也是在这一个过渡过程体会比较深刻,所以这里还是会结合 jQuery 和 Vue 来讲解一下这样的转变。
我们先来看看事件驱动的编码方式。
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章会详细介绍。
我们看看数据驱动的思维方式是怎样的。
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 }}
处),我们就不用自己手动获取元素然后更新节点内容了。
其实我们也可以先开发静态模板,然后根据可变的内容来设计数据结构。事件驱动和数据驱动一个很重要的区别在于,我们是从每个事件的触发(“操作”)为中心来设计我们的代码,还是以数据为中心,接收事件触发和更新数据状态的方式来写代码。
我们再来详细地对比一下。
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
的数据来源包括两个:从本地缓存获取
、从后台获取、用户输入、重置清空
其实在我们日常开发中,更多时候是结合了事件驱动和数据驱动来使用。而如果现在的你刚入门没多久,也不用纠结是否真的用了某种思维模式、是否用了什么设计模式,我们在一次次的开发过程中,会不断地积累和加深一些思考,适合自己的才是最好的。
2. 大型应用管理
说到大型应用,常见的我们项目中需要考虑加载性能和加载速度相关的,这些在很多前端相关的文章或者书籍中都可找到。这里主要介绍一些在大型项目中会使用到的工具,以及好用的技巧,还有项目规范、合作开发等经验,供大家参考。
2.1 代码打包
我们先从最基础的代码打包来讲起。
路由懒加载
当我们的应用变得很大,为了提升首屏加载的体验,我们需要对代码进行分块打包。一般来说,不同的框架有不同的异步加载解决方案,同时可以结合打包工具(Webpack、Gulp 等)进行分块打包。我们可以把首屏相关的东西打包到 bundle 文件中,其他模块分块打包到 chunk 文件,首页只需要加载 bundle 文件,然后在空闲的时候或者需要使用的时候再按需加载 chunk 文件模块。
通常情况下,我们会结合路由进行分块打包,路由管理工具大部分都支持异步加载。我们可以根据自己需要,来打包成多个文件,在路由进入的时候才获取和加载。Vue 可参考第7章中路由懒加载相关内容。
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章。
2.2 抽象、组件化和配置化
在我们开始写重复的代码、或是进行较多的复制粘贴的时候,大概是时候需要考虑对组件进行适当的抽象(也可以成为封装)了。好的抽象能大量减少重复代码,同时对项目整体有更深入的理解。过度的抽象会增加项目的复杂度,同时降低了代码的可读性和维护性。所以关键在于适度,好的办法是结合产品和业务来进行抽象,例如一个播放器组件、日历组件、快速导航栏、快捷菜单等组件封装,便于多次使用。
应用的抽象、配置化相关,可以参考第10章 第13章 。同时,我们也需要把一些相同的方法抽离,封装成通用的工具库,像常用的时间转换、字符串处理、http 请求等,都可以单独拎出来维护。
2.3 状态和数据管理
我们的应用里,多数会面临组件的某些状态和数据相互影响、相互依赖的问题。现在也有比较成熟的解决方案和状态管理工具,像 Vuex、Redux、Mobx 等,我们需要结合自身的框架和业务场景来使用。像父子组件的交互、应用内无直接管理的数据状态共享、事件的传递等,也都需要结合实际适当地使用,可参考第11章
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,这样在合并或是版本控制上有更好的体验。但其实最重要的还是沟通,沟通是一个团队里必不可少、同时又很容易出问题的地方。
项目的维护永远是程序员的大头,一般来说前人种树后人乘凉。但是很多时候,大家会为了一时的方便,对代码规范比较随意,就导致了我们经常看到有人讨论“继承”来的代码,就变成了了前人挖坑后人填坑的方式了。或许相比新技术的研究和造轮子,有个好的写码习惯、提高项目维护性并不能带来短期的利益,但是其实身为一个负责任的程序员,还是要对有些追求的。
项目管理和维护其实是很重要的一件事,但这些在日常工作中常常会被忽视。更多人的关注点在系统上线,而运营和维护其实也是同样有价值和有作业的工作,我们也应该重视起来。
互联网在不断地发展,新技术层出不穷,处在叶子尖端的前端领域更是在不断摇晃。我们在一个陌生环境中,会习惯性用最熟悉的行为来继续。但如果以开放的心态来接受新事物,你会有想象不到的收获。