JavaScript 框架进化历史考古 —— 如何说服后端从jQuery升级到Vue/React框架

507 阅读18分钟

不论是后端的 Java、PHP,还是前端的 JavaScript,都在不断的演化,其最终目的都是为了提升 RD 的开发效率。

以 Java 为例,从最开始 Javaweb 的 servelet + Tomcat,演化到 SpringMVC,然后又演化 SpringBoot,逐渐把各类Bean 抽离为配置项,把之前繁杂的 XML 写法统统通过注解来包装,整体还打包了 Tomcat, 研发效率呈几何的速度提升。之后又逐渐演化,集合众多微服务框架,变身为SpringCloud,满足集团中后台相关需求的快速迭代,模块化快速开发。

JavaScript 与Java 一样,在过去十几年的时间里,演化出成千上万的框架。而且由于其设计的开放性,编译和转义非常方便,进而使其能够在各方面发挥作用。例如可以使用 JavaScript 来编写 iOS 和安卓的 APP,也可以用来写桌面客户端,使用 JavaScript 的 node.js 还可以非常好的支持 I/O 密集型应用,并在淘宝双十一等场景发挥重要作用。我们甚至可以用它来训练 AI 模型、编辑视频/图片、写 3D 游戏等。

那么从普通企业的业务开发角度来看,为什么一定要将 JavaScript 的框架从古老的原生 JS 写法,升级成现在流行的 Vue.js 或 React 呢?

一般前端开发工程师会告诉你,升级之后便于维护,能提升开发效率,能甩掉历史包袱,balabala... 但作为团队领导,或者后端工程师,我相信你可能一直不能理解他在说什么,或者他说的话并没有事实依据支撑。下面我就从JavaScript 的发展历史,从两个前端最常见的页面需求来解释,为什么一定要升级。

JavaScript 的发展史

JavaScript 的诞生

1994年,网景公司(Netscape)发布了 Navigator 浏览器 0.9 版。这个版本的浏览器只能用来浏览,不具备与访问者互动的能力。网景公司急需一种网页脚本语言,使得浏览器可以与网页互动。

网页脚本语言到底是什么语言?网景公司当时有两个选择:一个是采用现有的语言,比如Perl、Python、Tcl、Scheme等等,允许它们直接嵌入网页;另一个是发明一种全新的语言。

这两个选择各有利弊。第一个选择,有利于充分利用现有代码和程序员资源,推广起来比较容易;第二个选择,有利于开发出完全适用的语言,实现起来比较容易。

到底采用哪一个选择,网景公司内部争执不下,管理层一时难以下定决心。

就在这时,发生了另外一件大事:1995年 Sun 公司将 Oak 语言改名为 Java,正式向市场推出。

Sun 公司大肆宣传,许诺这种语言可以”一次编写,到处运行”(Write Once, Run Anywhere),它看上去很可能成为未来的主宰。

网景公司动了心,决定与 Sun 公司结成联盟。它不仅允许 Java 程序以 applet(小程序)的形式,直接在浏览器中运行;甚至还考虑直接将 Java 作为脚本语言嵌入网页,只是因为这样会使 HTML 网页过于复杂,后来才不得不放弃。

总之,当时的形势就是,网景公司的整个管理层,都是 Java 语言的信徒,Sun 公司完全介入网页脚本语言的决策。因此,Javascript 后来就是网景和 Sun 两家公司一起携手推向市场的,这种语言被命名为 “Java + script” 并不是偶然的。

为什么说 JavaScript 有设计缺陷

这里有三个客观原因,导致Javascript的设计不够完善。

设计阶段过于仓促

JavaScript 的设计,其实只用了十天。而且,设计师是为了向公司交差。

另一方面,这种语言的设计初衷,是为了解决一些简单的网页互动(比如,检查“用户名”是否填写),并没有考虑复杂应用的需要。设计者做梦也想不到,JavaScript 将来可以写出像 Gmail 这种极其庞大复杂的网页。

没有先例

JavaScript 同时结合了函数式编程和面向对象编程的特点,这很可能是历史上的第一例。而且直到今天为止,JavaScript 仍然是世界上唯一使用 Prototype 继承模型的主要语言。这使得它没有设计先例可以参考。

过早的标准化

JavaScript 的发展非常快,根本没有时间调整设计。

1995年,5月:设计方案定稿;10月:解释器开发成功;12月:向市场推出,立刻被广泛接受,全世界的用户大量使用。

JavaScript 缺乏一个从小到大、慢慢积累用户的过程,而是连续的爆炸式扩散增长。大量的既成网页和业余网页设计者的参与,使得调整语言规格困难重重。

更糟的是,JavaScript 的规格还没来及调整,就固化了。

1996年8月,微软公司强势介入,宣布推出自己的脚本语言Jscript;11月,为了压制微软,网景公司决定申请JavaScript 的国际标准;1997年6月,第一个国际标准ECMA-262正式颁布。

也就是说,JavaScript 推出一年半之后,国际标准就问世了。设计缺陷还没有充分暴露就成了标准。相比之下,C语言问世将近20年之后,国际标准才颁布。

而且之后的十几年,各大公司相继推出自己的浏览器,以及针对自己浏览器的特性,各个浏览器之间的实现参差不齐,写法也各不相同,这让前端程序员在处理各个浏览器兼容的问题上要花费大量的时间。

jQuery的诞生

jQuery官网,对于自己的介绍非常精准:

jQuery is a fast, small, and feature-rich JavaScript library. It makes things like HTML document traversal and manipulation, event handling, animation, and Ajax much simpler with an easy-to-use API that works across a multitude of browsers. With a combination of versatility and extensibility, jQuery has changed the way that millions of people write JavaScript.

jQuery是一个快速、小型和功能丰富的JavaScript库。它使用易于使用的 API 在多个浏览器上完美工作,使 HTML 文档遍历和操作、事件处理、动画和 Ajax 等内容变得简单得多。jQuery 既具备多种功能,还可扩展。它改变了数百万人编写 JavaScript 的方式。

前面我们已经介绍过,各个浏览器之间各有各的 JavaScript 的实现,相互不一致。例如有的浏览器支持 Array 的 forEach 遍历,有的浏览器不支持;有的浏览器可以通过样式选择器选择某个页面元素,有的浏览器就不支持。这时,jQuery 的出现,就将世面所有主流浏览器的能力全部抹平了。

前端程序员只要页面引入一个 jQuery,并按照 jQuery 的方法来写代码,就能完美的在所有浏览器里运行。

这还不是 jQuery 带来的全部好处,他还提供了三大核心工具:DOM批量遍历和操作、事件处理及 Ajax,另外它的链式调用也开创了JavaScript 的链式写法的先河,非常方便。

Dom批量遍历和操作

jQuery在抹平浏览器实现差异外,最重要的能力就是页面元素(Dom)选择器,它可以按照元素样式、id、内容等方式来选择页面内的元素(Dom)。

例如:获取页面所有带有'continue' class 样式的<button>元素,并将其HTML更改为“下一步...”

使用原生JavaScript:

  1. 需要先获取页面所有的<button> 元素
  2. 依次遍历,判断这个元素是否包含 'continue' 样式
  3. 依次遍历,将其内容修改为 “下一步...”。

且不说这个操作麻烦与否,单单各个浏览器之间关于元素选择器、遍历操作和修改内容这三个方法实现的差异化兼容就足够恶心了。

使用 jQuery :

$("button.continue").html( "Next Step..." );

事件处理

各个浏览器对于 click 等事件的监听写法不一致,jQuery抹平了这一差异,并融合了 Dom 批量选择器的能力。

例如:当点击页面上 ID 为'button-container'的元素时,页面上所有样式带有 'banner-message' 的元素要移除样式表中的 'display:none' 的 CSS 样式。

使用原生 JavaScript:

  1. 获取页面上 ID 为'button-container'的元素
  2. 获取页面上所有样式带有 'banner-message' 的元素
    1. 获取页面内所有的元素
    2. 遍历他们,并筛选出样式带有 'banner-message' 的元素
  3. 使用 attachevent 或者 addEventListener 为 button 添加 click 事件
  4. 处理点击事件
    1. 遍历所有 'banner-message' 的元素
    2. 遍历每一个元素的 CSS 样式列表
    3. 如果样式中有display:none, 则移除
    4. 为他们设置新的 CSS 样式列表

使用jQuery:

$("#button-container button").on("click", function() {
  $(".banner-message").show();
});

Ajax

使用查询参数zipcode=97201调用服务器接口/api/getWeather,并将元素#weather-temp的 html 替换为返回的文本。

使用jQuery:

$.ajax({
  url: "/api/getWeather",
  data: {
    zipcode: 97201
  },
  success: function( result ) {
    $( "#weather-temp" ).html( "<strong>" + result + "</strong> degrees" );
  }
});

总结

jQuery 2006 年被发明出来的时候,还没有 ES5(2011年6月发布)。即使在 ES5 发布之后很长一段时间里,也不是所有浏览器都支持。因此在这一时期,除 DOM 操作外,jQuery 的巨大贡献在于解决跨浏览器的问题,以及提供了方便的对象和数组操作工具,比如 each()index()filter 等。

如今 ECMAScript 已经发布了 ES2021(ES12) 的标准,浏览器标准混乱的问题也已经得到了很好的解决,前端界还出现了 Babel 这样的转译工具和 TypeScript 之类的新语言。所以现在大家都尽可放心的使用各种新的语言特性,哪怕 ECMAScript 的相关标准还在制定中。在这一时期,jQuery 提供的大量工具方法都已经有了原生替代品——在使用上差别不大的情况下,我们确实宁愿用原生实现。

事实上,jQuery 也在极尽可能地采用原生实现,以提高执行效率。jQuery 没有放弃这些已有原生实现的工具函数/方法,主要还是因为向下兼容,以及一如既往的提供浏览器兼容性——毕竟不是每一个使用 jQuery 的开发者都会使用转译工具。

一个常见的列表需求

随着互联网业务的发展,越来越多的内容形式被搬到了线上,各个互联网大厂也都退出了自己的互联网工具或者产品。

我们以最常见的列表渲染为例:

如果我们要从服务器,通过 Ajax 异步获取一个列表数据,并根据服务器的返回组建不同的列表数据以供展现,如下图所示:

image.png

我们需要根据后端的返回,生成每一个列表项,例如有的列表项里面有三张图,有的只有一张图,有的没有图,有的是视频,有的是广告。这样一个列表,如果使用 jQuery 来生成,是一个非常麻烦的事情:

// 下文为示例伪代码
// 创建一个 ul 列表并加在 #container 中
var httpResponse = {...};
httpResponse.forEach(function (index, item) {
  // 处理没有图片的情况
  if (item.image.length == 0) {
  	$("<ul>").append(
      $("<li>").append(
        $("<a>").attr("href", "#").text("first").append(
        	$("<div>").attr("class", "title").text(item.title),
          $("<div>").attr("class", "content").text(item.content),
          ... // 其他的div等
        )
      )
    );
  // 有一张图片的情况
  } else if (item.image.length == 1) {
  	$("<ul>").append(
      $("<li>").append(
        $("<a>").attr("href", "#").text("first").append(
        	$("<img>").attr("src", item.banner),
          $("<div>").attr("class", "title").text(item.title),
          $("<div>").attr("class", "content").text(item.content),
          ... // 其他的div等
        )
      )
    );
  // 有三张图片的情况
  } else if (item.image.length == 3) {
      $("<ul>").append(
      $("<li>").append(
        $("<a>").attr("href", "#").text("first").append(
        	$("<div>").attr("class", "title").text(item.title),
          $("<div>").attr("class", "content").text(item.content),
          ... // 其他的div等
          $("<img>").attr("src", item.banner[0]),
          $("<img>").attr("src", item.banner[1]),
          $("<img>").attr("src", item.banner[2]),
        )
      )
    );
  }
})
$("<ul>").appendTo($("#container"));

如此简单的列表渲染,只要里面有一些条件判定,jQuery在动态生成的时候就会变得及其恶心。

即使没有条件判定,当列表内每一个条目的布局层级较多时,jQuery 一层套一层的生成节点也足够令人头疼。而且后面跟的一堆括号,就是一个**“嵌套地狱”**,只要改错一个括号层级,你的整个逻辑都会报错,排查起来及其困难。

这还仅仅只是一个列表渲染,当你的页面需要列表、表单、多tab、词库联想等等各种功能组合在一起,而且还要彼此配合展示、数据协同的时候,可以想象用 jQuery 构建一个复杂的企业级页面和交互是多么的困难。

有很多抖机灵的前端程序员这个时候就会开始另辟蹊径,例如,预先写好各种html 模板,然后根据条件选择需要的模板,然后对html 模板内的字符串进行替换,替换结束后再渲染到页面上:

// 下文为示例伪代码
var templateA = "<div>{{%name%}}</div><div class='banner'>{{%bannerSlot%}}</div>";
var templateB = "<img src='{{%banner%}}'/>";
var data = $.ajax(...);
var listHtml = templateA.replace('{{%name%}}', data.name)
	.replace('{{%bannerSlot%}}', templateB.replace('{{%banner%}}', data.banner))
$("<ul>").html(listHtml);

这样,就可以预先按照想要的 html 结构,来直接替换内容渲染,避免 jQuery 的嵌套地狱。

但是这又引来了另一个问题,复杂的数据替换非常的麻烦,有没有什么方式来统一渲染这些列表呢?

JS 模板引擎的诞生

上文我们已经说到,单纯使用jQuery选择页面中的元素时非常方便的,但是组建一个根据数据条件展示不同内容的页面结构,依然非常非常的麻烦。尤其是我们现在的页面,根据后端返回展示不同内容的需求越来越多,页面结构也是千变万化,折让我们前端页面构建变得非常难受。

上文也提到,一些抖机灵的小伙伴已经自己开始写模板,然后使用字符串查找替换了,虽然比较原始,但是能基本解决痛点。另外,如果支持在模板里面写 JavaScript 逻辑就更好了。

当然想到这一点的,不只有我们,还有业界的各位互联网公司,尤其是在2012年前后,诞生了大量的模板引擎,为代表的的有:

Mustache(领英、twitter等,至今仍在维护)、baiduTemplate(百度,2011年诞生,2012年停止维护)、artTemplate(腾讯,2015年停止维护)、juicer(淘宝,2017年停止维护)、xtemplate、doT、Jade(至今都在维护,我们公司现在也在使用)等

以百度的 baiduTemplate 为例,我们可以轻松的根据数据和模板,获得一个转义后的 html 内容,它有非常多的特性:

  1. 语法简单,使用Javascript原生语法,支持变量、条件判断、循环等多种用法;
  2. 默认HTML转义(防XSS攻击),并且支持包括URL转义等多种转义;
  3. 变量未定义自动输出为空,防止页面错乱;
  4. 功能强大,如分隔符可自定等多种功能;

例如我们输出一个列表,可以先组建一个列表 item 模板:

<script id="liteItemTemplate" type="text/html">
<div>
    <h1>title:<%=title%></h1>
    <% if(list.length > 1) { %>
        <h2>输出list,共有 <%= list.length %> 个元素</h2>
        <ul>
            <% for(var i=0; i<5; i++){ %>
                <li><%= list[i] %></li>
            <% } %>
        </ul>
    <% } else { %>
        <h2>没有list数据</h2>   
    <% } %>
</div>  
</script>

然后调用 baiduTemplate 提供的方法,即可自动编译模板和数据为我们想要的 html,而且还会自动进行 XSS 转义,避免危险操作:

var data={
    "title":'欢迎使用baiduTemplate',
    "list":[
        'test1:默认支持HTML转义,如输出<script>,也可以关掉,语法<:=value> 详见API',
        'test2:',
        'test3:',
        'test4:list[5]未定义,模板系统会输出空'
    ]
};

var html = baidu.template('liteItemTemplate',data);

$(".result").html(html);

这样,我们只需要维护好我们的模板,而且在模板里面判断数据条件,使用if 等方式组装页面即可,既避免了 jQuery 的嵌套地狱,又实现了数据和展现分离,非常方便。

baiduTemplate 看起来使用的是 Dom 模板,这意味着它通过在Dom中添加标记,来控制流程(each、if,等等),它且不依赖任何外部模板库。它可能更快,而且代码易读、易写,且标记与模板之间没有隔阂,CSS如何与之交互也一目了然。

另外一种方式是字符串模板,如前文举例。为代表的的是 Mustache、jade 等,它虽然必须要编译,但是这也使得它可以应用在任何地方,如服务端等

模块化和MVC框架的诞生

前文已经讲过,随着互联网技术的不断演进,浏览器上需要的业务需求越来越复杂。大一点的网站通常需要很多的页面路由,每个页面也需要非常多的模块来组建。以微京东为例:

image.png

其页面 路由/内容之多、交互之复杂、数据处理之密集,已经完全超出之前几年的 web 页面了。

查看其页面源代码,不算外部引入的逻辑 JavaScript 脚本,单单基本的页面节点 + 基础脚本,就已经达到了 1万多行。

image.png

现代网站大多都会有非常多小模块组成,每个模块都有自己的复杂数据,这些数据需要处理、转义、渲染等操作才能被用户看到,每个模块也有自己模块内的复杂交互。如果这些逻辑全部都由服务端来组装,麻烦倒不说,开发调试、上线、后期维护都是问题,甚至还会影响服务器的吞吐量。

此时,几乎所有的互联网公司都达成了一个共识,那就是:**真正的浏览器端的 JavaScript 应用必须有适当的数据模型,并具备客户端渲染能力,而绝不仅仅是服务器处理数据,再加上一些 Ajax 和 jQuery 代码那么简单。必须要进行前后端分离,必须要模块化,必须要有MVC。**只有每个模块单独处理自己的 JS 逻辑、样式、交互,相互隔离,然后由多个模块组装成完整页面,才有可能组建复杂和大型的网站。

模块化的诞生

JavaScript 模块化的发展,这里根据一些特征,划分为三个阶段。以阶段二 CommonJS 的出现最为开创性的代表,引领多种规范竞争,利于发展,最终标准化。

阶段一:语法层面的约定封装

作为初期阶段,一些简单粗暴的约定封装,方式许多,优势劣势各有不同。大多利用 JavaScript 的语言特性和浏览器特性,使用 Script 标签、目录文件的组织、闭包、IIFE、对象模拟命名空间(如YUI)等方法。

这些方法,解决了一些问题,但对日渐复杂的前端代码和浏览器异步加载的特性,很多问题并没有解决,有了需求和问题,解决方案就自然而然的被带了出来。

阶段二:规范的制定和预编译(2009年到2015年)

2009年1月29日,Kevin Dangoor 发布了一篇文章 《What Server Side JavaScript needs》,并在 Google Groups 中创建了一个 ServerJS 小组,旨在构建更好的 JavaScript 生态系统,包括服务器端、浏览器端,而后更名为 CommonJS 小组。CommonJS 社区产生了许多模块化的规范 Modules ,大牛云集,各显神通,不同思想的碰撞和斗争。

这一阶段的发展,开始了对模块化规范的制定。以 CommonJS 社区为触发点,发展出了不同的规范如 CommonJS( Modules/*** )、AMD、CMD、UMD 等和不同的模块加载库如 RequireJS、Sea.js、Browserify 等,这里面的悲欢离合暂且按下不表。

解决了浏览器端 JavaScript 依赖管理、执行顺序等在之前一个阶段未被解决的许多问题被有了一定程度的解决,随着 browserify 和 webpack 工具的出现,让写法上也可以完全和服务端 Node.js 的模块写法一样,通过 AST 转为在浏览器端可运行的代码,虽然多了一层预编译的过程,但对开发来说是很友好的,预编译的过程完全可以由工具自动化。

一些典型示例:

// 1. CommonJS Modules
// hello.js
var hello = {
		sayHi: function(){
        console.log('Hi');
    },
    sayHello: function(){
        console.log('Hello');
    }
}
module.exports.hello = sayHello;

// main.js
var sayHello = require('./hello.js').sayHello;
sayHello();


// 2. AMD
// hello.js
define(function() {
    var names = ['hanmeimei', 'lilei'];

    return {
        sayHi: function(){
            console.log(names[0]);
        },
        sayHello: function(){
            console.log(names[1]);
        }
    };
});

// main.js
define(['./hello'], function(hello) {
    hello.sayHello();
});

// 3. CMD
// hello.js
define(function(require, exports, module) {
  var names = ['hanmeimei', 'lilei'];
  module.exports = {
    sayHi: function(){
      console.log(names[0]);
    },
    sayHello: function(){
      console.log(names[1]);
    }
  };
});

// main.js
define(function(require) {
	var hello = require('./hello');
	hello.sayHi();
});

有了模块化的规范标准,虽然规范写法各不相同,但还是给开发者带来了许多便利,封装出许多模块化的包,在不同的项目之间使用,基础设施搭建愈发完善,一定程度上的竞争关系,不过随时间发展,市场会选择一个最优解。

阶段三:原生语言层面模块化的支持(2015年至今)

相比于之前的规范,ECMAScript 标准在2015年对原生语言层面提出了声明式语法的模块化规范标准 ES Modules,历经多年,相比之前的模块化的规范,必定取其精华,去其槽粕。各大浏览器对 ES Modules 逐渐的实现,在未实现的浏览器上也可以通过 Babel 等工具预编译来兼容,ES Modules 逐渐在前端成了公认的编写模块化的标准。

示例:

// hello.js
var names = ['hanmeimei', 'lilei'];

export const hello = {
    sayHi: function(){
        console.log(names[0]);
    },
    sayHello: function(){
        console.log(names[1]);
    }
}

// file main.js
import { hello } from "./lib/greeting";
hello.sayHello();

SPA & MVC 框架的诞生

2012年前后,还爆发了大量的SPA框架(single page web application,SPA)。

相较于传统页面,SPA有非常多的优势

  1. 有良好的交互体验

    能提升页面切换体验,用户在访问应用页面是不会频繁的去切换浏览页面,从而避免了页面的重新加载;

  2. 前后端分离开发

单页Web应用可以和 RESTful 规约一起使用,通过 REST API 提供接口数据,并使用 Ajax 异步获取,这样有助于分离客户端和服务器端工作。更进一步,可以在客户端也可以分解为静态页面和页面交互两个部分;

  1. 减轻服务器压力

    服务器只用出数据就可以,不用管展示逻辑和页面合成,吞吐能力会提高几倍;

  2. 共用一套后端程序代码

    不用修改后端程序代码就可以同时用于 Web 界面、手机、平板等多种客户端;

为代表的是 2012年前后,推出的多个 SPA 框架:AngularJS、Backbone、Batman、CanJS、Ember、Meteor、Knockout、Spine 等。所有的框架都坚持 视图--模型 分离。有的强调MVC(Model View Control),有的提到MVVM(Model View ViewModel),甚至有人拒绝明确说出第三个词儿(只提模型、视图,然后加上让它们协调运作的东西)。对各个框架而言,最终的思路其实是相似的。

以 Backbone.js 为例,它引入了两个最重要的特性,影响了之后的大部分前端框架:

MVC的引入

Backbone 做的最重要事,就是是将**业务逻辑(Model)用户界面(View)**分开。当两者纠缠在一起时,不管是逻辑处理还是界面处理都是非常麻烦的;当逻辑不依赖于用户界面时,它们都会变得非常清晰明了。

ModelView
编排数据和业务逻辑监听更改并呈现用户界面
从服务器加载和保存数据处理用户输入和交互
当数据更改时,触发事件将捕获的输入发送到模型

以下面一个简单的tab切换为例:

image.png

如果使用 jQuery来写,代码里充斥了大量的查找元素,判断元素,从元素内取值,修改元素的代码,这些 页面展示逻辑数据处理逻辑 掺杂在一起,你既不能快速整理数据逻辑,也没办法一眼就看出来元素展现和切换的逻辑,整体乱七八糟:

jQuery(document).ready(function($){
	var tabs = $('.cd-tabs');
	
	tabs.each(function(){
		var tab = $(this),
			tabItems = tab.find('ul.cd-tabs-navigation'),
			tabContentWrapper = tab.children('ul.cd-tabs-content'),
			tabNavigation = tab.find('nav');

		tabItems.on('click', 'a', function(event){
			event.preventDefault();
			var selectedItem = $(this);
			if( !selectedItem.hasClass('selected') ) {
				var selectedTab = selectedItem.data('content'),
					selectedContent = tabContentWrapper.find('li[data-content="'+selectedTab+'"]'),
					slectedContentHeight = selectedContent.innerHeight();
				
				tabItems.find('a.selected').removeClass('selected');
				selectedItem.addClass('selected');
				selectedContent.addClass('selected').siblings('li').removeClass('selected');
				//animate tabContentWrapper height when content changes 
				tabContentWrapper.animate({
					'height': slectedContentHeight
				}, 200);
			}
		});
		checkScrolling(tabNavigation);
		tabNavigation.on('scroll', function(){ 
			checkScrolling($(this));
		});
	});

	function checkScrolling(tabs){
		var totalTabWidth = parseInt(tabs.children('.cd-tabs-navigation').width()),
		 	tabsViewport = parseInt(tabs.width());
		if( tabs.scrollLeft() >= totalTabWidth - tabsViewport) {
			tabs.parent('.cd-tabs').addClass('is-ended');
		} else {
			tabs.parent('.cd-tabs').removeClass('is-ended');
		}
	}
});

然而,当你使用 MVC 框架时,可以有效的剥离业务逻辑和页面展示逻辑,也能快速的知道整个项目的整体运作原理,以 Backbone 为例:

// 将每一个tab里面所有涉及到的数据,抽离为 Model
// ./models/tabItem.js
app.tabItem = Backbone.Model.extend({
		defaults: {
			title: '标题',
			isActive: false,
      content: '内容,balabala...'
		},
		// 还内置了数据处理方法,必须通过这些方法来 get/set/change
		toggle: function () {
			this.save({
				isActive: !this.get('isActive')
			});
		}
});

// 在tab的业务模块里,只需要关心整体的数据该如何变化即可
app.tabView = Backbone.View.extend({
		// 这里使用了前面讲到的 模板引擎
		template: _.template($('#item-template').html()),
		initialize: function () {
      // 监听Model的变化,当数据有变更时,采取不同的策略
			this.listenTo(this.model, 'change', this.render); // 重新渲染
			this.listenTo(this.model, 'destroy', this.remove); // 删除自己这个节点
			this.listenTo(this.model, 'visible', this.toggleVisible); // 隐藏自己这个节点
		},
		// 根据数据,重新渲染页面元素
		render: function () {
			this.$el.html(this.template(this.model.toJSON()));
			return this;
		},
		// 隐藏自己这个节点
		toggleVisible: function () {
			this.$el.toggleClass('hidden', this.isHidden());
		}
});

另外在模板里,写好页面元素该如何展现:

<script type="text/template" id="stats-template">
  <span class="todo-count">
    	<strong><%= remaining %></strong>
  		<%= remaining === 1 ? 'item' : 'items' %> left
  </span>
	<ul class="filters">
  		<li><a class="selected" href="#/">All</a></li>
    	<li><a href="#/active">Active</a></li>
    	<li><a href="#/completed">Completed</a></li>
  </ul>
	<% if (completed) { %>
  		<button class="clear-completed">Clear completed</button>
  <% } %>
</script>

也就是说,页面的整体展现,我们从jQuery的手动选择元素,并控制展现,变成了操作数据,由数据组建 html,然后渲染到页面上。每一次数据的变更,都会触发 render 函数,然后框架就会由新的数据 + template,组建一个新的 html,将老的 html 移除,将新的 html 挂载。用图示例:

image.png

或者:

image.png

当然,Backbone 还引入了 collection 和 controller 的概念,后面 controller 又被改名成了 router。此处不再展开。

事件委托

看到这里,你可能发现了上方 MVC 模型的一个BUG,那就是,我除了频繁的渲染页面之外,如果我的 html 上还有事件绑定,那我还需要频繁的重新绑定事件:

image.png

这个问题不合理的地方在于:

页面的内容变了,因此频繁的渲染没有问题,但是元素上绑定的事件,因为老的html节点被移除了,导致新的节点还需要重新绑定一次,如果事件监听很少的话,尚能接受,但是如果事件绑定较多,你就不得不为事件绑定专门写一个function,然后频繁的调用。

如下所示:

rebindEvent: function () {
  this.allCheckbox = this.$('.toggle-all')[0];
  this.$input = this.$('.new-todo');
  this.$footer = this.$('.footer');
  this.$main = this.$('.main');
  this.$list = $('.todo-list');

  this.allCheckbox.on('add', this.addOne);
  this.$input.on('reset', this.addAll);
  this.$footer.on('change:completed', this.filterOne);
  this.$main.on('filter', this.filterAll);
  this.$list.on('all', _.debounce(this.render, 0));
},

看起来似乎没有问题,但是当页面频繁render的时候,对于浏览器的性能将会是一个巨大挑战。

Backbone 引入了一个事件委托机制,将页面内涉及的所有事件,统一都绑定到了其父元素(页面挂载节点)上,通过冒泡机制来统一由父元素响应相关事件。因为父元素不会被移除,所以只需要一次绑定,就可以频繁的render了。

如下方代码:

app.AppView = Backbone.View.extend({
		// 整个app实例挂载(渲染)在哪个页面元素里
		el: '.todoapp',
		statsTemplate: _.template($('#stats-template').html()),
		// 统一监听app内的所有事件,做出响应
		events: {
			'keypress .new-todo': 'createOnEnter',
			'click .clear-completed': 'clearCompleted',
			'click .toggle-all': 'toggleAllComplete'
		},
    ... 其他逻辑
});

一个大表单联动的复杂需求

看起来,Model + Template → View 的模式已经能够应对所有的开发场景了。但是设想两个场景:

  1. 你的页面上,有1万条数据(例如twitter的信息流),在一次请求后,你需要向每一条数据里差异化的修改一些内容(例如评论、点赞量等),此时,你不得不完整的重新 render 1万条数据,生成新的html,然后将原来的1万条页面元素删除,然后插入新的元素。你的浏览器,每一次 render 可能都需要卡10秒才能展示新的内容。
  2. 你有一个复杂的后台表单,他们彼此之间有级联关系,数据之间也有依赖关系。如果一个选项改变了之后,你需要改变数据,然后重新渲染与该数据关联的相关表单项。而且用户之前填写的数据也会丢失,你不得不手动复原这些数据。

像1这样的大数据量的情况可能并不多见,但是级联表单,在我们的日常开发中,是非常常见的。拿下图举例:

image.png

  1. 各个表单项本身就有联动关系,如地区级联控件等
  2. 当你选择了一个选项的时候,会影响另一个选项的展现与否、合法性校验、数据收集
  3. 我之前已经填写好的内容,在页面重新render之后,不能消失不见重新填写
  4. 我在提交的时候,要收集我所有当前选择方式的所有数据,不能收集一些无关空数据

我们单看一个简单的级联组件,如果是用 jQuery开发:

$(function(){
  var level1 = [{id:20000,name:'专业技术人员',pid:20000,lv:1}, ...others];
  var level2 = [{id:10100,name:'中国gcd机关负责人',pid:10000,lv:2}, ...others];
  var level3 = [{id:10201,name:'国家权力机关负责人',pid:10200,lv:3}, ...others];
  var level4 = [{id:'1A',label:'各类专业、技术人员'}, ...others];

  var professionStr1 = '<option>---请选择职业类别---</option>';
  var professionStr2 = '<option>---请选择职业---</option>';
  var professionStr3 = '<option>---请选择具体职业---</option>';
  var professionStrOne = professionStr1;
  var professionStrTwo = '';
  var professionStrThree = '';
  for(var i=0;i<level1.length;i++){
    professionStrOne += '<option value="' + level1[i].id + '">' + level1[i].name + '</option>';
  }
  $(".professionOne").html(professionStrOne);
  $(".professionOne").change(function(){
    professionStrTwo = professionStr2;
    if($(this).val() != '---请选择职业类别---'){
        var professionTwoPid = new Array();
        for(var j=0;j<level2.length;j++){
          if (level2[j].pid == $(this).val()) {
            professionStrTwo += '<option value="' + level2[j].id + '">' + level2[j].name + '</option>';
            professionTwoPid[j] = level2[j].pid;
          }
        }
        if(professionTwoPid.length == 0){
            for(var l=0;l<level1.length;l++){
                if(level1[l].id == $(this).val()){
                    var professionOneValue = level1[l].name;
                }
          }
          professionStrTwo = professionStrThree = '<option value="' + $(this).val() + '">' + professionOneValue + '</option>';
          $(".professionTwo").html(professionStrTwo);
            $(".professionThree").html(professionStrThree);
        }else{
            $(".professionTwo").html(professionStrTwo);
            $(".professionThree").html(professionStr3);
        }
    }else{
        $(".professionTwo").html(professionStr2);
      $(".professionThree").html(professionStr3);
    }
  })
  $(".professionTwo").change(function(){
    var professionStrThree = professionStr3;
    if($(this).val() != '---请选择职业---'){
        var professionThreePid = new Array();
        for(var k=0;k<level3.length;k++){
          if (level3[k].pid == $(this).val()) {
            professionStrThree += '<option value="' + level3[k].id + '">' + level3[k].name + '</option>';
            professionThreePid[k] = level3[k].pid;
          }
        }
        if(professionThreePid.length == 0){
            for(var m=0;m<level2.length;m++){
                if(level2[m].id == $(this).val()){
                    var professionTwoValue = level2[m].name;
                }
          }
          professionStrThree = '<option value="' + $(this).val() + '">' + professionTwoValue + '</option>';
          $(".professionThree").html(professionStrThree);
        }else{
            $(".professionThree").html(professionStrThree);
        }
    }else{
        $(".professionThree").html(professionStr3);
    }
  })
});

基本就啥也不是!

Backbone MVC 框架的实现我就不展示了,不过大家应该也能想到,每一次用户选择第一级,就获取到对应的第二级的数据,并更换第二级的 Model,页面就会自动 render 第二级;用户选择第二级的时候,更换第三级的Model,页面就会自动 render 出第三级。以此类推。

app.AppView = Backbone.View.extend({
		// 所有的级联选择,都会触发change事件
		events: {
			'change .profession': 'changProfession'
		},
    changProfession: function (e) {
      // 先将下一级的数据放到Model里
      this.model.set({
        e.index + 1: this.data[e.value]
      });
  		// 清除所有下下级数据
  		// 例如我之前选择到了第四级,这次变更的是第一级,则原来选过的第三级、第四级的数据要清掉
  		this.model.clearAfter(e.index);
    }
});

你只需要吧重点放在数据怎么处理上,无需关心页面怎么生成Dom。就是这么优雅。

数据绑定和差异化更新技术

先来看一个非常常见的案例:

0875d1a1157bd64d9988b6ce0fec7bd1.gif

我们在更换了补充的经营信息后,其随后的表单内容就会发生变化。之前我们已经讲过 MVC 的 Model 和 View 的协同 Render 可以非常方便的处理页面岁数据发生变化的问题。

但是这里有一个非常重要的一点,是,我们的模板是整个表单,一旦其中的某个数据变化了,重新 Render 了,那么用户其他填写的字段就会消失,我们需要手动还原。

image.png

此时我们在render后,必须要手动还原数据:

app.AppView = Backbone.View.extend({
    render: fucntion () {
      this.$el.html(this.template(this.model.toJSON()));
			this.updateViewData();
			return this;
    },
    updateViewData: function () {
			this.$('.new-todo0').val(this.model.get('a'));
      this.$('.new-todo1').val(this.model.get('b'));
      this.$('.new-todo2').val(this.model.get('c'));
      this.$('.new-todo3').val(this.model.get('d'));
    }
});

当然,每个框架都有自己的改进方案,如 Backbone js 就提供了 collection 的概念:

image.png

上面的情况我们可以使用 collection,即每一个表单字段都有自己单独的 Model,这样虽然在写代码的时候麻烦了一些,但是可以避免数据丢失,手动赋值的情况出现。

那么,既然写 Model 如此麻烦,那么有没有一个 Model 就能解决整个表单关联渲染的情况呢?

Vue 和 React 的差异化更新

讲了这么多,终于迎来了我们目前最流行的框架:Vue.js 和 React。他们的特性非常非常多,解决了非常非常多的问题,我们不展开讲。今天我们只讲,Vue 和 React 为什么可以做到 Render 之后,不会影响用户已填写数据,以及不用手动还原数据的。

其实它们做到这一点,是利用了虚拟Dom技术,见下图:

image.png

它们在页面模板编译阶段,会自动识别出 Template 中代码块和 Model 数据之间的关联关系,并为相应的数据生成专属于这一个数据的 Render 函数。当某一条数据发生变化时,会执行该数据涉及的 Render 函数,然后得到一个新的虚拟 Dom 节点。

image.png

如图所示,当Model中的某一条数据发生变动的时候,Vue 只会生成与之相关联的部分的 虚拟 Dom,做差异化更新,而不是整个表单全部替换。

另外,如果我这一条数据关联的内容里,包含了多个页面节点时,Vue 在 Render 后,生成新的虚拟 Dom 后,会自动与之前的 虚拟Dom 进行 diff。找到真正发生变动的页面节点,然后差异化更新:

image.png

如果我们整个listData 都被重新赋值了,那么,Vue会先依据新的数据, render出一个新的虚拟 Dom,然后会与之前的虚拟Dom进行对比diff。 在 diff 之后,发现只有一个数字发生变化了,那么Vue 就只会找到页面上的这个数字,然后只改一个数字,多么得高效!假如这个列表有1万条数据,其节省的时间和性能可想而知。

另外,Vue的差异化更新,对例如商户系统、资质系统、PaaS系统等复杂表单系统来说,简直是天大的福音。市面上也出现了大量基于Vue、React 的 UI 组件库,他们又对表单系统,数据校验等进行二次封装,配合Vue 的模板、Slot、过滤器、双向数据绑定等,极大地提升了我们的开发效率。

全文总结

其实能看到这里,你应该已经知道,Vue/React 与 jQuery 完全就不是可做比较的东西,他们是不通时代的产物。

jQuery只能被看成是一个工具库,用来在十几年前抹平诸如IE、Chrome各个浏览器之间的差异。另外他还提供了一个好用的页面元素选择器,可以快速的选择页面内的元素。随着浏览器的不断升级,各个浏览器之间的差异已经几乎抹平,jQuery提供的诸多工具类也已经内置到浏览器原生工具里,因为原生支持,性能也比jQuery高很多。

jQuery没有模块化、没有MVC的数据/表现分离、没有辅助现代开发的工具函数,更没有成体系的UI组件库,一切都是最原始的,难以承载现代化的前端开发任务。

使用 Vue/React 现代化框架,你将拥有:

MVC 架构

业务逻辑和页面展示逻辑完全分离,相互更容易理解各自的逻辑和流程,便于开发/维护

独立的数据Model,数据集中收集和处理

模板渲染

避免手动组装页面结构,造成嵌套地狱,也可以避免在业务逻辑里面写一堆展示逻辑

模块化

将页面差分成模块和组件,按需引入和打包,结构化维护

差异化更新

避免没有意义的渲染,提升整体性能,还能避免因操作Dom引起的数据和事件监听丢失

UI框架和工具库

社区拥有大量针对 Vue 和React 的工具库和UI库,开箱即用

其他

你还拥有了SPA、Store、前端路由等多个特性。另外你还可以使用 webpack 等NPM生态,在开发时 HMR、tree-shaking等能力将会极大提升你的研发效率。