现代前端开发背后的设计模式
本文内容来自于之前的一次技术分享,阐述了一些新的前端开发的设计模式,希望和前端开发同学交流一下,开阔思路,共同提高。作为初级文并没有牵扯太高深的东西,更多是一种解决问题的方法。
现代前端开发的特点
现代前端应用越来越复杂,越来越像桌面和移动端的原生应用,这就要求现代前端需要具备一些原生应用的能力,如以下:
- 具备复杂交互的处理能力 - 什么是复杂的交互?当用户发生一次点击行为时,可能需要同时刷新多个界面元素的状态,在这种情况下,就要加强界面元素之间的通讯能力。
- 良好的工程化和团队协作开发能力 - 随着项目越来越大,代码复杂度增加,传统的前端开发三驾马车(HTML、CSS、Javascript)难以为继,如何组织架构好前端工程,促进团队协作就成了重点。
- 可被自动化测试 - 传统的前端开发是非常难以测试的,一是因为从开发模型上就没为自动化测试考虑过,但是项目复杂度的增加,导致版本迭代时回归性 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 的工作依然是同步视图层和数据层,在适当的时候重绘页面,但这样有两个问题:
- 数据发生改变时,视图层肯定要发生改变,这一步其实可以让它自动化,就能减少开发者的重复代码。
- 数据发生改变时,整个页面都需要全部重新绘制,而我们知道 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 都采用了更加先进的方法来更新视图。
- 进行和原始数据的数据对比,缩小更新范围。
- 普遍采用虚拟 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,还有一堆静态资源(例如图片、字体、声音),它们的一些特点让它们难以被组件化。
- 全局性 - 这些资源直接影响页面全局,不像 Javascript 那样具备命名空间的特性,CSS 的样式一写就会直接影响到整个网页。
- 难以维护 - 大量散乱的 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);
以脉脉网为例,样式被引用进界面后,会生成一个单独的