文章

“要编写复杂软件又不至于一败涂地的唯一方法,就是降低其整体的复杂度。”

引言

个人认为,知识和技能的区别大抵为“我知道”与“我会用”,几个月前洋洋洒洒的在笔记中记下了很多设计原则或者类似的东西,敲下那些文字时的感觉是飘飘然的,因为它们是那么的显而易见,仿佛我记过一遍之后似乎一切就尽在掌握中一般。
然而,现实是一个残酷的老师,它会无情的敲打你,“我懂了”和“我以为我懂了”是完全不同的概念,后者的上限是“我可能懂了”而下限则是“我确实没懂”。
大概在刚开始写博客的同时,也就是半年前,我同时开始用Nodejs写爬虫,主要是想写些跑在树莓派上的玩具,当时看了几篇讲爬虫的博文,找了个koa的脚手架就整开了。用koa也并不是因为想做成网站,只是想搭个图形界面方便展示罢了,这其实不是个好的选择,不过现在不想在此多做文章,回到这个爬虫上来。基本的功能,比如模拟登陆,拉取每日榜单的api,每天定时开始缓存,下载选中id的图片,直接解析网页html等等,都没遇到什么太大的问题,搞不定的也可以去翻别人的实现来借鉴,也算是收获颇丰。
但是,随着时间的推移,虽然还是时常有一些想法划过脑海,我却越来越不愿意付诸于实践了,直到有一天,我看到了一个基于NodeJs的,经典的MVC模型的web demo,我才意识到...我之前的代码组织是一坨毛线,每一个功能对应一个api而一个api对应一个文件,很明显,所有的东西都杂糅在一起了,纵使我了解那些分层思想,问到好处能侃侃而谈,但我居然在看到这个demo前没有意识到之前写的东西有这方面的改善空间。
一时间我有些怆然若失,不经间感觉编写时纠结的一些其它无关疼痒的问题是那么的滑稽,同时也意识到我不愿意再扩展的原因除了懒以外单纯的是这个爬虫太难维护了,纵使是我自己写的。不过幸好这是我自己写的,我清楚它的每一个实现,它也并没有太多功能也谈不上多复杂,而我编写的时候即使是写在一个文件里,至少还是将每个过程单独拆成一个函数,组合起来用的,这使我重构它还算愉快且并没有耗费太多的功夫。
总而言之这个过程,使我更加痛彻的重温了模块原则:

要编写复杂软件而又不至于一败涂地的唯一方法就是降低其整体复杂度——用清晰的接口把若干简单的模块组合成一个复杂的软件。如此一来,多数问题只会局限于某个局部,那么就还有希望对局部进行改进而不至牵动全身。

然而,即使你进行了教科书般的拆分实践,这又带来了新的问题,众所周知javaScript是弱类型的脚本语言,它拥有可以快速,舒爽的开发的优点,也有重构或者扩展时你得强忍着全部删了重写冲动的特性。
来看看,假如有一个函数摆在你的面前,它接收了一个参数叫opt类似这样..

function doSomeThing(opt){
    var data = opt.data;
    var type = opt.type;
    .....
}

这不是你写的,然后它没有注释。你肯定没有办法搞明白这个opt对象里有什么,data是什么类型,可能的值,type是字符串还是数字,也许你会全局搜索查找这个函数在哪调用了,那么假如这个函数接收的是一个网传参数,或者干脆是个DOM对象,而作者又没加注释呢?这个场景不算少见..也绝不是没办法调试,但这样绝称不上友好,即使这个函数是我写的,如果它的内部逻辑又稍微有点复杂,过上一段时间要修改它,我也会选择先加个console.log把opt打印出来看看。虽然通常我会加上注释,注明它的键,值,类型,然而调试的时候我还是会把它打印出来,这很有用,但也很低效。
后来,我发现稍微新一点的Nodejs版本支持解构赋值,我简直爱上它了,稍微变动一下,这个函数就会好懂很多,像这样:

function doSomeThing({
    data={},
    type='type',
}){

    .....
}

有些函数并不需要这么写,但我还是愿意这么做。这同时也会带来一些特性,假如某些时候这些值因为一些错误没有传递过来,可以更方便的做错误处理,同时如果你不喜欢做错误处理,那会在那时得到一个奇怪的现象,并让你记住下次要做错误处理。
遥想ES6刚出来的时候,常见人调侃:“javascript从精通到再入门”,不过个人感觉,我还是喜欢它的变化的,纵使我并分不清我用的特性是ES6,7,8哪个提供的,但它们使编写javascript更轻松愉快,且可维护,当然仅限于不需要考虑兼容性的场景时。
然而,一个解构赋值,虽然在一定程度上缓解了函数难以单独读懂的问题,但并没有解决它,这就意味着即使我们把一个过程拆分成单个的模块,比如一个一个的函数,它们有可能依旧难以直接复用,我们要花一定的时间去弄清它的上下文,搞明白它的实现逻辑,与可能的结果,才敢放心的拿来复用,而解构赋值和详细的注释可以在一定程度上帮助你缩短这么做的时间,那么我们能不能消灭这个过程呢?也许不能完全消灭,但是把每个函数都写出类似api的形式,明确传进去的值,明确返回的格式可以极大接近结果。你可以加大量的注释,但你依旧需要重复 运行>报错>修正>运行的这个过程,幸运的话也许所有的错误都会在测试中发现,不幸的话就等着在生产实践中翻日志吧。
这非常不友好,这时TypeScript走进了我的视线。
故此,这篇笔记分为2个部分:

  • TypeScript的Hello World
  • jest的Hello World

Hello TypeScript

TypeScript是javaScript的超集,简单来说,它尝试把动态的javaScript静态化,借了很多静态语言的概念。在默认的检查风格下,很多在写javaScript常用的写法会报错,写起来会有种束手束脚的,不过习惯以后会好些,或者说规范些,如果严格按照要求来写,很多低级错误可以在编译阶段而不是运行阶段才被发现。不过最开始我选择了兼容模式,毕竟,不熟的时候有时候报错完全弄不明白是我语法的问题还是那的确是错了,反而有些本末倒置。
回到前言最后的问题,如何一眼就能尽可能的搞明白一个函数是干什么呢?其实主要是加上大量的注释,既然这些注释通常必不可少,我们为什么不让它发挥更大的作用呢?实际上,注释上这是什么类型的值,和类型检查这是什么值区别并不大,后者还能辅助你发现问题,何乐而不为?
给一个现有的NodeJs项目中引入TypeScript并不困难。

npm install typescript --save-dev
npm install ts-node --save-dev

然后把所有.js后缀换成.ts把启动从node换成ts-node..运行一下就会报错了...大意应该是一些语法在ts下不好使,你要想完全兼容nodejs需要安装@types/node。

npm install @types/node --save-dev

这个时候再用ts-node启动只是换了个后缀的文件应该就不会报错了,只要你原本写的不要太过分。
但是,似乎有哪里不对,我们知道typescript是要编译成javascript运行的,直接运行ts-node似乎是把编译和运行整合起来了,那么我们想看到它的编译该怎么办呢。网上查了查,大概就是创建一个tscongfig.json,具体配置项有中文的文档。
然后我创建了一个类似下面的配置。

{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es2017",
        "allowJs": true,
        //允许值为隐式的any
        "noImplicitAny": false,
        "moduleResolution": "node",
        "outDir": "./built",  // TS文件编译后会放入到此文件夹内
    },
    //入口
    "include": [
        "app.ts",
        "./src/**/*"
    ]
}

具体的可以看官方文档,这个配置文件应该可以允许你用ts-node运行一个原本正常运行仅仅是改了个后缀文件的nodejs项目。这里要额外提一嘴隐式的any值,在有些时候,我们函数会接收从系统api返回的数据,浏览器端比如一个dom对象,这个对象有很多值,你要取需要的值。
而在typescript中因为编译阶段的检查,你不声明它有,它就当没有,就会报错。在这里,当然把我们要的值声明一遍会更合理,然而有时为了方便会声明一个any,而这个声明和没有声明是差不多的,它可以是任何东西,可以取出任何的值,那索性不如就允许隐式声明any算了,不为了声明而声明,当然,这会失去一些强制定义类型带来的好处,但同时保留了一些弱类型才有的便利,毕竟其实我们还是在写javascript,当然为了不需要一口气把整个项目全部删了重写,允许这么做实在是帮大忙了。
至此,让当前项目可以用typescript重写的环境就算好了,稍微需要注意的也就是tsc的编译并不会删除原本的文件,所以我把package.json改成了。

  "scripts": {
    "start": "npm run build && npm run server",
    "build": "rm -rf built/*&&tsc",
    "server": "node built/app.js",
    "dev": "nodemon ",
  },

这里提一嘴nodemon,这是一个几乎零配置的支持nodejs热更新的组件,这里后面没有跟参数是因为写了配置文件,具体的可以查阅文档,基本没什么太多好讲的,正常使用的话把node启动换成nodemon就是热更新了。
这里附上针对typescript的nodemon.json

{
    "watch": ["src","app.ts"], 
    "ext": "ts,js", 
    "ignore": [], 
    "exec": "ts-node ./app.ts" 
}

效果就是src文件夹下的ts,js文件如果有变动了重新执行ts-node app.ts,当然app.ts变动了也这么做。
环境搭好了,下面来看看基本的内容:

类型声明

Type:boolean,number,string,void,Any,null,undefined,[],object,
Tuple(元组),enum(枚举),never
另外声明了null undefined的变量可以被别的类型赋值,但是void不行,void只能被赋值为null和undefined.
声明object不能被赋值原始类型的数据,比如string和number。
另外,用object声明一个对象声明后无法直接操作,因为没有声明其它键,用any声明则没这个问题。
元组和枚举则是对javascript基础类型的扩展,元组是类似声明一个数组并告知内部有多种类型的值,而枚举,类似一种映射关系。
never则可以用来声明本不应该返回值的函数。
声明类型的时候,首字母似乎不强制要求大写string和String它都认。
还有当一个变量可能有多种类型值的时候可以用|来分隔类型。
列子:

let str:String = '这是一个字符串';
let obj:Object = {};
let arr:Array = [];
function fun():void{

}
//数组
let arr:number[] = [1,2,3]
let arr:Array<number> = [1,2,3]
//元祖
let x:[number,string] = [1,'string']
//枚举
enum Color {Red = 1, Green = 2, Blue = 4}
let c: Color = Color.Green; //2
let cN:string = Color[2]; //Green
let cN2:string=Color[3];//undefined

接口(interface)

这个个人感觉是使用typescript的核心,定义interface实际上和写注释差不多,区别在于它会帮你检查你写的和你注释的是不是一回事。
readonly表示只读,既只能在创建的时候赋值。
?表示可以不存在。
列子:

interface iopt{
    readonly  name:String;
    type?:String;
    data?:idata;
}
interface idata {
    info:String;
    count:Number;
}
let opt:iOpt={
    name:'some opt'
}
opt.type='type';
let data = {
    inof:'info',
    count:0
}
opt.data=data

理想情况下所有的对象和函数都应该有它的interface定义,由于interface也会声明提前,所以你可以统一写在底部,或者写在原本应该写注释的地方,或者统一写到一个单独的文件里,怎么组织都行。
索引类型,感觉有点绕,直接贴文档里的说明。

interface StringArray {
  [index: number]: string;
}

let myArray: StringArray;
myArray = ["Bob", "Fred"];

let myStr: string = myArray[0];

不建议使用数字类型索引,因为略不可控。

接口与类
接口也可以继承,不得不说这省了很多代码量。
也可以基于一个接口描述来写一个类,下面直接贴文档中的说明。

//基于接口描述来写一个类
interface ClockInterface {
    currentTime: Date;
    setTime(d: Date);
}

class Clock implements ClockInterface {
    currentTime: Date;
    setTime(d: Date) {
        this.currentTime = d;
    }
    constructor(h: number, m: number) { }
}
//稍微复杂一点的..
interface ClockConstructor {
    new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
    tick();
}

function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {
    return new ctor(hour, minute);
}

class DigitalClock implements ClockInterface {
    constructor(h: number, m: number) { }
    tick() {
        console.log("beep beep");
    }
}
class AnalogClock implements ClockInterface {
    constructor(h: number, m: number) { }
    tick() {
        console.log("tick tock");
    }
}

let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);
//接口继承
interface Shape {
    color: string;
}

interface Square extends Shape {
    sideLength: number;
}

let square = <Square>{};
square.color = "blue";
square.sideLength = 10;

泛型

为了避免到处都是any的情况...可能不好描述,依旧拉一段文档的描述来。

//声明,这里T就是一个泛形,这个函数这里这么写是想表达你传入什么类型出来还是什么类型。
//如果这里可以传入多种类型的值,不想写一大堆|又不使用这种写法的话,就只能用any了
function identity<T>(arg: T): T {
    return arg;
}

let output = identity<string>("myString");
//这个列子中<>其实在调用的时候可以省略

小结

作为hello world上面这些内容感觉就差不多了,还有大量的细节需要去实际中感受,实际上,当前我也只是搭了个架子,并没有大量的应用到实践中去,理解难免片面,打这篇笔记更多的目的更像是过一遍基础的东西。
总的来说,如果原本就喜欢或者被要求写清晰的注释的话,切换到TypeScript上并没有增加太多工作量,如果原本没有这些习惯,那么如果需要重构和扩展的时候,之前省下来的时间会加倍的消耗掉,所以还不如切换过来感受一下强制要求你写类似注释的感觉。
不过依旧是具体问题具体分析,客观来说切换到TypeScript上毕竟还是会增加一些复杂度,在相当长的时间里我也并不乐见javascript的各种超集,不过当我实际遇到问题时,再看这些东西,嗯,真香。

jest

jest是一个几乎零配置的javascript测试框架,很简单易用。当我重构代码的时候发现只能自己手动一个一个功能点过去测试的时候那是相当痛苦...尤其是变动频繁的时候,而且手动点过去测试出问题了还不一定是报错的地方有问题,再排查起来...
总之一言难尽。然后我就开始找简单的测试框架,发现这个非常适合。
想引用到既有项目中,非常简单。

cnpm install jest --save-dev

然后就可以写测试用例了...
随便找个地方创建一个比如 fun.test.js
在里面写用例,然后运行jest就可以看结果了...简单列子的话,文档里的那个很好..
这里附上一个测试某个接口是否正常的列子

const someApi = require('../api/someApi.js');

let mockQuery = {

}
let ctx = {
    request:{
        body:mockQuery
    }
}

test('someApi', () => {
    someApi.contrl(ctx).then((res)=>{
        let result = res.body;
        expect(result.code).toBe(200);
        expect(result.contents.length).toBe(50);
    })
});

就是这么简单...这里测试的接口是写在koa中的,我自己定义了返回的code,实际去判断http的响应码也是一个办法。
如果想引到一个用typescript写的项目中依然很容易,我们只需要..

cnpm install ts-jest --save-dev
cnpm install @types/jest --save-dev

然后,写一个配置文件
jest.config.js

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

剩下的就和普通项目一样,不过可以结合typeScript来写用例也能引用ts文件来测试了,实在是轻松愉快。

总结

总的来说,以上内容都只是搭架子,也就是入门,而之所以要搭这个架子都有具体的问题等着解决。
很多时候看别人说这个那个,其实在自己遇见相同的场景前都是不以为然的,可能会认同,但大都不会去做。
不过,假如没有看别人提这个那个的,遇到相同的场景时则可能得花更多的精力去找解决方案,毕竟场景这个东西很难用一句话去搜索引擎问出答案,毕竟它不会报错。
所以,有的时候看看,或者写写罗里吧嗦的文章蛮好的,作为一篇笔记,这篇其实扯了那么长就两点,论静态类型的有用性和单元测试的重要性。
但就像我之前虽然看过很多提这些的文章但依旧没有把它们用起来一般,无非是没有遇到需要的场景罢了,这点套到设计模式和代码组织规范那一类东西也一样。
人类总是在重复同样的错误,无疑我也是这句话描述对象中的一员。

Comment

This is just a placeholder img.