现代前端开发背后的设计模式

本文内容来自于之前的一次技术分享,阐述了一些新的前端开发的设计模式,希望和前端开发同学交流一下,开阔思路,共同提高。作为初级文并没有牵扯太高深的东西,更多是一种解决问题的方法。

现代前端开发的特点

现代前端应用越来越复杂,越来越像桌面和移动端的原生应用,这就要求现代前端需要具备一些原生应用的能力,如以下:

  1. 具备复杂交互的处理能力 - 什么是复杂的交互?当用户发生一次点击行为时,可能需要同时刷新多个界面元素的状态,在这种情况下,就要加强界面元素之间的通讯能力。
  2. 良好的工程化和团队协作开发能力 - 随着项目越来越大,代码复杂度增加,传统的前端开发三驾马车(HTML、CSS、Javascript)难以为继,如何组织架构好前端工程,促进团队协作就成了重点。
  3. 可被自动化测试 - 传统的前端开发是非常难以测试的,一是因为从开发模型上就没为自动化测试考虑过,但是项目复杂度的增加,导致版本迭代时回归性 Bug 越来越多,就需要有更好的方案来解决质量问题。

现代前端设计模式

数据视图双向绑定 MVVM

前因

传统的前端开发都是直接通过 Javascript 操作数据与 DOM,例如以下的代码:

$('#button').click(function() {
  $.getJSON('http://url.com', function(res) {
     $('#label').val(res.value);
  });
}

短短五行覆盖了点击事件处理、线上数据抓取、界面更新三步,这样的开发适合小型或者简单的页面,但是在开发复杂页面时,这样混杂在一起的代码很容易碰到数据与视图代码难以复用的情况,因为都在一起,也无法进行有效的单元测试。

经过了之前传统开发模式的洗涤之后,有一些开发者开始思考,如何将视图与数据分开,于是借鉴后端的 MVC 开发模型,将前端也进行了拆分,其中比较典型的框架是 Backbone

但和后端应用的数据库数据不同,这里的数据是通过接口从后端获取的数据;View 视图层,只负责响应页面内的交互事件;Controller 控制器层,负责数据与视图层、视图与视图的通讯。

类似如下:

class Worker extends Controller {
  route () {
    const view = new View();
    view.render().appendTo('#root');
    const model = new Model();
    model.fetch(function(res) {
      view.renderData(res);
    }
  }
}

但是从这里我们可以看到,实际上 Controller 的工作依然是同步视图层和数据层,在适当的时候重绘页面,但这样有两个问题:

  1. 数据发生改变时,视图层肯定要发生改变,这一步其实可以让它自动化,就能减少开发者的重复代码。
  2. 数据发生改变时,整个页面都需要全部重新绘制,而我们知道 DOM 重绘的开销是非常大的,而且它是一个同步的过程,会造成浏览器线程阻塞,导致用户无法操作。

后果:数据视图双向绑定的 MVVM

开发这在经过了数据和视图之间大量的同步工作后,Reactive 式开发模式在移动端开发先流行起来,它的实现就是 MVVM,它通过 MV(Model-View)层替换了之前的 Controller 层,在 Model 和 View 之间搭建了一个桥梁,当数据发生改变时自动将数据的改变同步到界面上,而无需再有人工操作。

这样大大提高了整体开发效率,同时有效的拆分让可测试性大大提高,只需要测试界面组件对用户行为的响应是否正常,以及数据输入参数后的输出是否符合预期即可。

第一个问题:Javascript 如何知道数据发生改变?

在 ES5 规范中,提供了一个 Object.defineProperty() 方法,它为监听对象属性(Property)改变提供了可能。

它给 Javascript Object 提供了设置 getter 和 setter 的可能,setter 和 getter 是指当给对象属性添加一个获取和改变的监听回调函数,当对对象属性进行操作时,回调函数会自动执行。

例如(代码来自 Object.defineProperty()):

function Archiver() {
  var temperature = null;
  var archive = [];

  Object.defineProperty(this, 'temperature', {
    get: function() {
      console.log('get!');
      return temperature;
    },
    set: function(value) {
      temperature = value;
      archive.push({ val: temperature });
    }
  });

  this.getArchive = function() { return archive; };
}

var arc = new Archiver();
arc.temperature; // 'get!'
arc.temperature = 11;
arc.temperature = 13;
arc.getArchive(); // [{ val: 11 }, { val: 13 }]

第二个问题:部分数据改变时,如何提高视图更新速度?

再次强调,DOM 更新是非常耗时的,Javascript 本身的运算速度非常快,但是一旦开始更新 DOM 就全部慢下来了。所以 MVVM 都采用了更加先进的方法来更新视图。

  1. 进行和原始数据的数据对比,缩小更新范围。
  2. 普遍采用虚拟 DOM(Virtual DOM)来处理数据更新。

以 React JSX 为例,来描述一下它的虚拟 DOM 如何生成的,首先,JSX 的写法与 HTML 并无二致:

import React from 'react';

const HelloComponent = ({ name }) => {
  return (
    <div className="hello">
      Hello, { name }.
    </div>
  )
}

React.render(<HelloComponent name="XQ" />, '#root');

经过 Babel 编译之后,原有的 XML 结构会改成 React.createElement() 方法。

'use strict';

var _react = require('react');

var _react2 = _interopRequireDefault(_react);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

var HelloComponent = function HelloComponent(_ref) {
  var name = _ref.name;

  return _react2.default.createElement(
    'div',
    { className: "hello" },
    'Hello, ',
    name,
    '.'
  );
};

_react2.default.render(_react2.default.createElement(HelloComponent, { name: 'XQ' }), '#root');

React.createElement() 做了如下几件事情,首先把 JSX 改成类似如下的数据结构,其次,对监听该数据中 name 参数的改变。

{
	tagName: "div",
	properties: {
		className: "hello"
	}
	children: [
		{
			text: "Hello, "
		},
		{
			text: name
		},
		{
			text: "."
		}
	]
}

然后根据该数据结构中的每一个 Object 都生成实际的 DOM,并且缓存起来,当数据发生改变时就可以直接找到对应的 DOM 更新界面。

当然 React 还做了大量优化,有更好的数据对比算法,而且当数据改变时不是立即更新界面,而是积累到一起,等主线程空闲下来时,一起批量更新等等。

前端资源组件化 Web Component

这里说的不是 W3C 基于 Shadow DOM 的 Web Components 标准,那个标准目前在浏览器支持不完善下暂时不能投入实际使用中。

如前所说,HTML 已经被 Javascript 化,从写页面改为写组件了,但是前端三驾马车里,还有一个 CSS,还有一堆静态资源(例如图片、字体、声音),它们的一些特点让它们难以被组件化。

  1. 全局性 - 这些资源直接影响页面全局,不像 Javascript 那样具备命名空间的特性,CSS 的样式一写就会直接影响到整个网页。
  2. 难以维护 - 大量散乱的 CSS 和静态资源经过长时间版本迭代后,难以区分哪些资源归属于哪些页面,通常采取的办法是置之不理,但是这样时间一长会导致整个工程很大,冗余代码过多。

解决方案:Webpack

Webpack 是于 2012 年出现的一套前端打包方案,虽然之前出现过其它的组件加载方案,例如 require.js、sea.js、browserify 等等,但是它是第一个能够把静态资源也打包进来的解决方案。

它的神器之处在于它有一堆 Loader,通过指定扩展名和 Loader 的对应关系,便能将它们打包整合进入前端组件中。

Loader 的配置范例如下:

    loaders: [
      {
        test: /.*\.json$/,
        loader: 'json' // json loader 可以将 JSON 转换为 Javascript
      },
      {
        test: /\.jpe?g$|\.gif$|\.png$|\.svg$|\.eot$|\.woff2?$|\.ttf$|\.wav$|\.mp3$/,
        loader: "file" // file loader 将静态资源转换为 base64 整合进入 Javascript
      },
      {
        test: /\.js$/,
        loader: 'babel',
        exclude: /node_modules/,
        include: __dirname // babel loader 将 ES6 代码编译成 ES5,提前使用新特性。
      },
      {
        test: /\.less?$/,
        loaders: ['style', 'css', 'less'] // 这三个 loader 将 less 转换为 HTML inline style。
      }
    ]

如果需要引用图片,不再需要手写 ,而是:

import logoImage from './logo.png'; 	// Import logo image
import 'style.less'; 					// Import style

const img = document.createElement('img');
img.src = logoImage;
document.body.appendChild(img);

脉脉网为例,样式被引用进界面后,会生成一个单独的