现在我们建立了开发环境和工作流,终于可以开始写产品代码了。我们将从count标签开始,它从一个集合中数元素的数量(例如一个数组或者对象)
count的规格说明书
开始之前,让我尽可能详细的定义一下count的功能。
count将要做:
1, 用一个游标来替换它自己
2, 接受一个collection集合属性
** 在count标签里,名称为collection的一个标签。
** 可以通过标点.来嵌套访问collection
3, 如果collection是undefined就输出错误的文本。
4, 当没有集合对象定义触发一个错误
5, 如果collection找不到就输出一个错误的文本。
6, 如果找不到集合对象,就触发一个错误
7, collection不是一个Object或者Array输出错误文本
8, 如果集合对象不是Object或者Array,触发一个错误
9, 输出length属性,如果collection是个数组
10, 输出Object.keys(collection)的length属性,如果collection是个Object
通过这份规格书,我们不仅清晰的定义了一个开发目标,而且定义了我们所需要的大部分的测试。多么便捷啊!
这条路上的一个关键点
现在到了开发这个项目的一个关键点:我们要决定怎么样解析我们的模板文件。这里需要考虑很多种因素,我们最关心的两个是速度和实现复杂度。因为我们现在处理的是HTML,所以我们想使用已经存在的,基于节点的HTML解析器。如果能找到一个HTML解析器能让我们很方便的查询到我们的模板字串那就更好了。
进入 Cheerio
太感谢了,Cheerio刚刚好是一个容易使用,基于节点的HTML解析器,而且还提供了类似jQuery一样的接口去访问html字串。完美!我对Cheerio有两个关注点是:
1, 速度, 我不知道它处理一个大点的模板文件需要花多久。
2, 内存使用,如果我们需要处理大的模板文件,或者生成大量的html,很有可能内存使用会使一个问题。
我对我们能解决这些问题有信心。所以,让我们先不要过早的去考虑优化的问题。如果你因为我正在做一个可怕的设计已经抓狂了,那么让我知道。
我们的第一个测试
前面小结说过,写产品代码之前先写测试,没啥好说的,开始。
首先,我们为count创建一个新的git分支
git checkout -b count
接下来,让我们在./tests/目录中创建一个名字为02-count.js的文件。在这个文件中我们为第一个测试将会加上必要的模块和描述。
var sumo = require('../');
var assert = require('assert');
describe('<count />', function () {
it('should output error text if `collection` is not supplied.', function () {
// Write test here
});
});
好极了,我们想让我们的错误信息是怎么样的呢,类似一条注释的html如何? 像这样:
<!-- Sumo Count Error: No collection provided -->
在我看来挺好的。 让我继续填充我们的测试
it('should output error text if `collection` is not supplied', function () {
var actual = sumo.compile(
'<count />',
{ test: [1, 2, 3, 4] }
);
var expected = '<!-- Sumo Count Error: No collection provided -->';
assert.equal(
actual,
expected,
'Error text should be output if no collection is provided'
);
});
运行一下,看看Mocha的输出是什么:
1) should output error text if
collectionis not supplied.6 passing (3ms)
1 failing1)
should output error text if collectionis not supplied:AssertionError: Error text should be output if no collection is supplied + expected - actual +<!-- Sumo Count Error: No collection provided --> -<count />
好极了,我们的测试失败了,显示了我们想要的错误信息。现在我们需要把他们对上号。
通过测试
现在我们有了一个测试,让我们移向index.js来实现能让我们测试通过的代码。我将会引入cheerio模块,这次我们要把它保存成产品依赖所以使用--save而不是--save-dev。
第一个事就是扩充sumo.compile。给我们的process函数增加一些样板。
var cheerio = require('cheerio');
var sumo = {
/**
* `elements` is a list of all of the template tags
* we will be searching for and processing.
*/
elements: [
'count'
],
/**
* `process` will house all of the functions that will
* process the template tags above.
*/
process: {}
};
/**
* Let's cache a reference to `sumo.process`.
*/
var process = sumo.process;
sumo.compile = function (templateStr) {
/**
* First, we'll want to create an array of all tags in
* `templateStr` that need to processed.
*/
var toProcess = this.elements.filter(function (element) {
/**
* To do this, we'll filter out all elements that
* don't occur in the template string.
*/
var filter = new RegExp('<' + element + '.*?>');
return filter.test(templateStr);
});
/**
* If `toProcess` isn't empty, then we know we have
* template tags to process.
*/
if (toProcess.length) {
/**
* Let's load `templateStr` into `cheerio` and bind
* the result to `$`, so we can use a familiar
* jQuery like syntax.
*
* We need to set xmlMode to true to better handle
* self closing tags like `<count />`
*/
var $ = cheerio.load(templateStr, {
xmlMode: true
});
/**
* Now we can process each tag in the template
* string.
*/
toProcess.forEach(function (element) {
/**
* `element` is the name of the tag we are looking
* to process. If there is a function with the same
* name in the `process` object, then we'll execute
* it and pass in our HTML via the Cheerio object.
*/
if (typeof process[element] === 'function') {
process[element]($);
}
});
/**
* Finally, we will return the contents of `$` as a
* string.
*/
return $.html();
}
/**
* If there are no tags to process, just return
* the template string unchanged.
*/
return templateStr;
};
module.exports = sumo;
接下来,让我们来实现这个process.count。
process.count = function ($) {
/**
* First, we'll iterate through each of the `count`
* elements in our parsed template string
*/
$('count').each(function () {
/**
* Then cache a reference to the current `count`
* element.
*/
var $el = $(this);
/**
* Now we can look for the `collection` attribute.
*/
var collection = $el.attr('collection');
/**
* If there is no `collection` attribute supplied,
* then we'll output error text.
*/
if (collection === undefined) {
$el.replaceWith(
'<!-- Sumo Count Error: No collection supplied -->'
);
}
});
};
在理论上,我们的测试英爱可以通过了。让我们运行gulp test试试看。
✓ should output error text if
collectionis not supplied7 passing (8ms)
成功。
触发错误事件
我们的下一个任务是除了写上面的错误字串之外,触发一个错误的事件,我们还要却表正确的错误信息传递给事件。
it('should emit an error event if `collection` is not supplied', function () {
var eventFired = false;
/**
* We'll use `once` instead of `on` to create a
* single use event handler.
*/
sumo.once('error', function (err) {
var actual = err;
var expected = 'Count: No collection supplied';
/**
* Let's make sure the error message is correct
*/
assert.equal(
actual,
expected,
'Error message should be: No collection supplied'
);
eventFired = true;
});
sumo.compile('<count />');
/**
* We also need to make sure the event actually fired
*/
assert(eventFired, 'Error event should be emitted');
});
运行测试应该可以看到一个错误:
✓ should output error text if
collectionis not supplied
1) should emit an error event ifcollectionis not supplied.7 passing (8ms)
1 failing
实现一个事件系统
为了使这个测试通过,我们需要实现一个事件系统。幸亏Node有events模块包含了我们需要的所有功能。
var cheerio = require('cheerio');
/**
* We'll use the `EventEmitter` to handle all of our
* event related needs.
*/
var EventEmitter = require('events').EventEmitter;
/**
* Since we don't need a constructor function, let's just
* make `sumo` an event emitter. This will allow us bind
* events like so:
*
* sumo.on('error', function () {})
*/
var sumo = new EventEmitter();
/**
* Let's cache a reference to `sumo.process` and
* `sumo.elements`
*/
var process = sumo.process = {};
var elements = sumo.elements = [
'count'
];
现在我们可以修改一下processs.count()来触发一个错误事件
process.count = function ($) {
$('count').each(function () {
var $el = $(this);
var collectionName = $el.attr('collection');
if (collectionName === undefined) {
/**
* Emit `error` event and supply a message.
*/
sumo.emit('error', 'Count: No collection supplied');
$el.replaceWith(
'<!-- Sumo Count Error: No collection supplied -->'
);
}
});
};
现在所有的测试应该都通过 是么?
[gulp] ‘test’ errored after 76 ms Count: No collection supplied
1) should output error text ifcollectionis not supplied
✓ should emit an error event ifcollectionis not supplied.7 passing (37ms)
1 failing1)
should output error text if collectionis not supplied:
Count: No collection supplied
哦,那里出错了,我们的测试没通过!不要担心,我们需要在测试代码中加点东西。因为我们正在触发一个错误的事件,我们应该确保它到达Mocha之前截获它,不然我们的测试将会失败。 让我增加一个截获所有事件的处理。
describe('<count />', function () {
/**
* Catch all error handler
*/
sumo.on('error', function () {});
/**
* ... Tests here ...
*/
});
让我们看看是不是工作。
✓ should output error text if
collectionis not supplied
✓ should emit an error event ifcollectionis not supplied.
✓ should supply correct error message to error event ifcollectionnot supplied.8 passing (10ms)
欧了。
让我们提交一下修改然后继续。
git add .
git commit -m “Adds tests for and implementsno collection suppliederror”
注意:为了减短篇幅我缩减了大部分的git commit。一般情况下,你应该在每次测试通过的时候提交。
现在我们完成了第一个错误的构建,我们以后可以复用很多这里的代码。正因为此,我不准备把所有的都放到这篇文章中。但是你可以看所有的测试和所有的实现
Counting
现在我们处理了所有错误的情况,现在让我们真正的来数一些集合吧。
var testString = '<count collection="test" />';
it('should output the correct count of Array', function () {
var collection = [1, 2, 3, 4];
assert.equal(
sumo.compile(testString, { test: collection }),
'4',
'Count of array is incorrect'
);
});
it('should output the correct count of Object', function () {
var collection = {
1: 1,
2: 2,
3: 3,
4: 4
};
assert.equal(
sumo.compile(testString, { test: collection }),
'4',
'Count of object is incorrect'
);
});
为了使这些测试通过,我们先要提供一些模板数据到process.compile函数里面。
/**
* Pass in the `data` parameter
*/
sumo.compile = function (templateStr, data) {
var toProcess = elements.filter(function (element) {
var filter = new RegExp('<' + element + '.*?>');
return filter.test(templateStr);
});
if (toProcess.length) {
var $ = cheerio.load(templateStr, { xmlMode: true });
toProcess.forEach(function (element) {
/**
* Pass in the `data` parameter
*/
if (typeof process[element] === 'function') {
process[element]($, data);
}
});
return $.html();
}
return templateStr;
};
现在我们有了一些可以数的东西,回到我们的count函数,让我们先让测试通过。
process.count = function ($, data) {
$('count').each(function () {
var $el = $(this);
var collectionName = $el.attr('collection');
var collection = data[collectionName];
/**
* ...abbreviated...
*/
/**
* Get the length of the passed in array. We need to
* make sure `count` is a string, or else we'll run into
* issues with Cheerio
*/
var count = collection.length.toString();
$el.replaceWith(count);
});
};
这应该能满足第一个测试,看它通过测试之后我们开始第二个。
process.count = function ($, data) {
$('count').each(function () {
var $el = $(this);
var collectionName = $el.attr('collection');
var collection = data[collectionName];
/**
* ...abbreviated...
*/
/**
* If the collection is an array
*/
var count = Array.isArray(collection) ?
/**
* Get its length
*/
collection.length.toString() :
/**
* Else, create an array of the object's
* keys and get the length of that.
*/
Object.keys(collection).length.toString();
$el.replaceWith(count);
});
};
如果我们运行测试,我们可以看到我们的count标签可以正确的数集合了。
最终测试
我们已经接近目标了。现在我们可以正确数对象和数组了。现在让我们来写一个测试来处理collection标签中的点嵌套。
it('should support dot notation in collection identifier', function () {
var testString = '<count collection="test.arr" />';
var collection = {
arr: [1, 2, 3]
};
assert.equal(
sumo.compile( testString, { test: collection } ),
'3',
'Incorrect count with dot notation'
);
});
让我们使这个通过:
process.count = function ($, data) {
$('count').each(function () {
var $el = $(this);
var collectionName = $el.attr('collection');
var collection = data[collectionName];
/**
* ...abbreviated...
*/
/**
* If the collection name contains `.`, then split the
* name into an array.
*/
if (/\./.test(collectionName)) {
collection = collectionName.split('.')
/**
* We'll use the `Array#reduce` method to crawl
* down the correct branch of the object tree. The
* first time `reduce` fires, `prev` will reference
* `data`. After that, `prev` will be a reference
* to the next branch of the `data` object.
*/
.reduce(function (prev, curr) {
/**
* If we encounter `undefined` anywhere in the
* process, then we will just return `undefined`.
* This will allow us to emit a
* 'collection not found' error below.
*/
if (prev === undefined || prev[curr] === undefined) {
return undefined;
}
/**
* Else, we'll traverse the rest of the object.
* What we return here will become `prev` in the
* next iteration of `reduce`. If there are no
* iterations left, this is what will be returned
* to `collection`.
*/
return prev[curr];
}, data);
}
/**
* ...abbreviated...
*/
});
};
哇哦!现在我们有了<count/>标签功能了。当我们完成其他的tag后我们可以回头看看咱们写的测试代码和实现代码。我们实现了规格书里面的所有的东西。
集成我们的改动
这一小节还有最后一个任务,就是集成我们的代码到主分支。让我们确保所有的测试都提交了。然后让我们把我们的count分支合并到远程主分支吧
git push origin count
现在让我们登陆到Github上然后提交一个pull reuqest 到我们的development分支,如果你还没有一个开发分支,你可以通过github提供的接口创建一个。
在页面右边点击一下pull request按钮。然后点New pull request。 在这个页面上你可以选development作为你的代码基,把count作为他的一个分支。然后点击Create pull request。因为我们只有一个开发者,所以我们可以继续合并我们的修改。
如果我们登陆到Travis CI,我们可以看到我们的测试运行,我们可以等Travis给我们的通知邮件。我们用同样的方法合并开发到主分支。
对于这个特殊的系统而言,这个系统看上去有点冗余以及不必要,但是这是实践的一个好习惯。在一个大型的团队中使用这个就显得很重要了。
总结
这就是这篇所有的内容,如果有问题,随时联系我,反馈或者关注!在下一小节,我们将回过头来重构一些代码,我们需要找到一个方法使我们的功能更模块化,更好的扩展性,以及更好的可持续性。