DeveWork

从0到1:PostCSS 插件开发最佳实践

前阵子为了满足工作上的一个需求开发了一个PostCSS 插件,后来也将这个插件提交给PostCSS 官方并得到认可。在这篇文章中笔者将记录开发过程中遇到的一些问题,且斗胆将之称为“最佳实践”,希望对有兴趣尝试PostCSS 插件开发的您有所帮助。

简介篇

开发成果展示

首先先上成果:https://github.com/Jeff2Ma/postcss-lazyimagecss (欢迎给个star 哦~)

postcss-lazyimagecss 插件实现的功能是为 CSS 中的background-image 对应的图片自动添加widthheight 属性。简单形象化的效果展示如下:

/* Input ./src/index.css */
.icon-close {
    background-image: url(../slice/icon-close.png); //icon-close.png - 16x16
}

.icon-new {
    background-image: url(../slice/icon-new@2x.png); //icon-new@2x.png - 16x16
}

/* Output ./dist/index.css */
.icon-close {
    background-image: url(../slice/icon-close.png);
    width: 16px;
    height: 16px;
}

.icon-new {
    background-image: url(../slice/icon-new@2x.png);
    width: 8px;
    height: 8px;
    background-size: 8px 8px;
}

为什么重复造一个轮子

开发这个PostCSS 插件的起因是原先工作流中使用的gulp-lazyimagecss 插件在加入SourceMap 功能后运行不正常,多次尝试修复均告失败。后来笔者想到,PostCSS 本身天然支持SourceMap,那如果将这个功能开发成PostCSS 插件岂不是也完美支持SourceMap 了?

于是笔者便在gulp-lazyimagecss 的基础上开发出了这么一个轮子。在此也感谢原开发者hzlzhlittledu 的大力帮助与支持。对笔者而言,更像是站在巨人的肩膀上开发出来这个插件。

准备篇

原理

关于PostCSS 的原理,官方有这么一个图:

简单解释,PostCSS 会将上一步传入的 CSS 按照一条条样式规则(rule)进行解析(Parser)得到一个节点树;然后借助一系列插件在节点树上进行转换操作,并最终通过Stringifier 进行拼接。source map则记录了前后的对应关系。

当然,在实际的开发中其实不必深究原理,最重要的是看其提供的API 来调用即可。

工欲善其事必先利其器

开发一个PostCSS 插件也是开发一个Node 模块,想到后面要发布到NPM 跟PostCSS 官方,那么作为一个开源项目的可维护性、可扩展性也是很重要的。因此在进入正式的开发之前,笔者做了如下的工作:

1、配置 editorconfig

editorconfig 作为一套统一代码格式的解决方案,已经在团队不少项目中使用,其很好地解决了因为团队协作中因不同代码编辑器及不同的代码习惯产生的潜在风险。这里是最终的配置文件

2、基础的开发工作流

在整个开发插件过程前,笔者根据需求配了个基于Gulp 的开发工作流,主要配备如下功能(任务):

优秀的开源代码必然是有着标准化的JavaScript 代码风格,因此在整个开发过程中借助ESlint 来严格控制自己的代码质量。这里是本项目的ESlint 配置文件。

var eslint = require('gulp-eslint');
gulp.task('lint', function () {
	return gulp.src(files)
		.pipe(eslint())
		.pipe(eslint.format())
		.pipe(eslint.failAfterError());
});

这个任务其实就是本PostCSS 插件实现的功能,之所以在开发过程中也要配置是为了下面的单元测试任务的调用。

秉承TDD(测试驱动开发)的开发理念,单元测试的任务是必不可少的。

gulp.task('test', function () {
	return gulp.src('test/*.js', { read: false })
		.pipe(mocha({ timeout: 1000000 }));
}); 

gulp watch 任务是上面任务的集体调用,实现的功能是在开发过程中,每当按下保存快捷键就自动执行ESlint 代码质量监控,同时进行单元测试任务。有效保障了整个开发过程中的质量。

3、托管到 Github 并配置Travis-ci 持续集成

整个开发过程使用Github 托管源代码并通过Travis-ci 持续集成。PostCSS 官方建议最低需要支持Node.js 0.12 的版本,所以整个Travis-ci 的配置文件如下:

sudo: false
language: node_js
node_js:
  - "0.12"
  - "4"
  - "5"
  - "6"
  - "stable"
before_script:
  - npm install -g mocha

相应的在Travis-ci 管理后台配置push 操作作为动作钩子,这样每次有commit push 上去就会自动进行测试并在log 上展示出结果:

开发篇

从最小开始

一个PostCSS 插件最基础的构成如下:

var postcss = require('postcss');
module.exports = postcss.plugin('PLUGIN_NAME', function (opts) {
    opts = opts || {};
    // 传入配置相关的代码
    return function (root, result) {
    	// 转化CSS 的功能代码
    };
});

然后就是不同的需求情况来决定是否引入第三方模块,是否有额外配置项,然后在包含root,result 的匿名函数中进行最为核心的转换代码功能编写。

root(css),rule, nodes, decl, prop, value

如本文一开头的PostCSS 原理解析,CSS 文件在经过Parser 转化后的递归单个子单位可以归为如下:

.icon-close {
    background-image: url(../slice/icon-close.png);
    font-size: 14px;
}
background-image: url(../slice/icon-close.png);

相应的CSS 属性与值,如上面 propbackground-image,valueurl(../slice/icon-close.png)

伪代码实现

根据postcss-lazyimagecss 插件要实现的内容,涉及到CSS 转化的有如下情景:

结合上一小节,可以先写出如下简洁版伪代码:

css.walkRules(function (rule) { // 遍历所有 CSS
	rule.walkDecls(/^background(-image)?$/, function (decl) { // 遍历每条 CSS 规则,找出目标 rule
		// 一些传参等代码
		nodes.forEach(function (node) { // 遍历其它 rules
			...
		});

		... // 其它代码实现,如找出图片真实width 等

		rule.append({prop: 'width', value: valueWidth}); // 在该decl 追加width 属性
	});
});

细化代码

接下来就是考虑不同情况增加一些逻辑判断:

- 判断url 中是否为网络地址或Base64 的data 形式:imageRegex.exec(value).indexOf('data:')

- 判断该rule 下是否已经有width 等属性,在nodes 循环中:

if (node.prop === 'width') {
	CSSWidth = true;
}

- 判断2倍图图片宽高是否为偶数:

value.indexOf('@2x') > -1 && (info.width % 2 !== 0 || info.height % 2 !== 0

再具体的不再详述,完整的代码实现可以了。完整的代码实现可以见这里

难点解决

postcss-lazyimagecss 插件使用了第三方模块fast-image-size 来进行图片数据(文件类型、宽高)的获取,大大提高了开发效率。然而在寻找图片绝对路径的这个实现上还是绕了不少弯路。

插件的思路是需要获取CSS 中background-image属性对应值中url()的相对图片路径,以此来找到图片的绝对路径,之后用fast-image-size 模块获取到相应的数据。

然而在一些特殊情况并不能准确找到绝对路径。

在CSS 预处理器(如Less 或Sass)中,常借助@import来组件化CSS 代码,然而在层层@import 下路径可能已经被产生变化。举个例子,有如下结构:

.
├── css
├── html
├── img
│   └── icon.png
└── scss
    ├── index.scss
    └── second
        └── _import.scss

上面的文件树中展示的 scss/index.scss @import了二级目录下的 _import.scss,在_import.scss中有一个类需要用到img/icon.png

因为同时也配置了local server(以上面的./目录作为server 的根目录),那么在 url 中可以写成../../img/icon.png../img/icon.png,甚至写成../../../../../img/icon.png(N个../)——这些情况下Sass 编译后的index.css 均可正常读取。原因相信也知道,因为root url的存在,上面的路径写法均相当于/img/icon.png

在这个情况下于用户而言是感受不到错误的,但在插件中可就找不到真实绝对路径了。笔者对于这个情况是采用了如下方式进行解决:

借助Node.js 中的fs.existsSync 函数检测绝对路径对应的文件是否存在。第一次为正常fs.existsSync,如果找到就跳出;如果没有则先对路径的字符串执行replace('../', '');然后再次执行fs.existsSync。如果两次均没有找到则在终端进行提示,但这种情况下并不会报错破坏进程的运行。

function fixAbsolutePath(dir, relative) {
	// find the first time
	var absolute = path.resolve(dir, relative);

	// check if is a image file
	var reg = /\.(jpg|jpeg|png|gif|svg|bmp)\b/i;
	if (!reg.test(absolute)) {
		pluginLog('Not a image file: ', absolute);
		return;
	}

	if (!fs.existsSync(absolute) && (relative.indexOf('../') > -1)) {
		relative = relative.replace('../', '');
		// find the second time
		absolute = path.resolve(dir, relative);
	}

	return absolute;
}

不敢说这是一种最好的处理方式,但至少是一种可行的处理方式。

单元测试

单元测试上采用Mocha 测试工具, should.js 做断言库。在笔者看来,结合TDD 进行开发,单元测试仅作为一种开发的辅助手段,规避开发过程中一些产生致命的报错。本文不展开如何写单元测试,具体实现可点击这里

优化篇

在Postcss 官方Github Repo,有一个Plugin Guidelines。对于其提倡的“Do one thing, and do it well” 深感认同,因此在基本完成插件功能后笔者又做了如下优化工作。

更友好的log 提示

官方其实是建议用内置的result.warn来代替console.logconsole.warn来展示log 信息(原因据说是一些PostCSS 处理器会忽略这类console log 输出)。不过笔者尝试后发现官方函数下提示的信息会非常长,后面采用了借助chalk 模块封装了console.log的形式增加了高亮态信息展示。

锦上添花

“找不到图片文件”的场景处理

用户在写CSS 代码的时候,background-image 的url 可能会有如下情况:

场景很多,但对于插件而言仅仅是能否找到与否的结果。在处理这些错误场景的情况下也给出的细分到“File does not exist” 或 “Not a image file”的情况,让这类错误提醒更加友好一些。

提示二倍图不正确

如果用户引用的二倍图(类似xxx@2x.png)的宽度高度为非偶数的话,也会有相应的提醒。

以上的报错提示在实际运行效果如下:

英文版 README

PostCSS 官方建议是README.md用英文写,其余语种采用类似README.zh.md的方式。

维护一份 changelog

按照建议,也将更新历史等数据放在了一个名为CHANGELOG.md文件上,并采用语义化的版本号

其它

根据自己的开发习惯,在Github 上的Repo 也放置了一份LICENSE 文件。

发布篇

发布到NPM 官方

发布到NPM 官方的步骤在这里就不再详述。仅分享一个不错的版本号增加方式(告别packup.json 的手动改版本数字)。

npm version patch => z+1
npm version minor => y+1 && z=0
npm version major => x+1 && y=0 && z=0

与上文所讲的语义化的版本号相关,vX.Y.Z(主版本号.次版本号.修订号)三个选项分别对应三部分的版本号,每次运行命令会导致相应的版本号递增一,同时子版本号清零。当然即可先运行上面命令前记得先将文件变动提交到git 上去。

之后运行npm pubulic命令即可。

发布到PostCSS 官方

Postcss 官方主页上有个plugin list 文件展示了所有的第三方插件,提交的话Fork 一份然后在该文件增加自己的插件详细然后提交合并,等作者允许即可。

发布到postcss.part

postcss.part 是一个非官方的PostCSS 插件搜索平台。提交自己插件可按照这个说明。其实本质也是Fork 然后加信息在Pull request 的方式,在此不累述。

结束篇

效果

在开发完postcss-lazyimagecss 插件后,笔者按照上面的发布方式提交了给官方。后面效果还不错,PostCSS 作者也提了个star 跟issue。PostCSS 官方推特上的推荐也带来了第一批starers。

因为这个缘故,在第三届中国CSS 大会上也有幸与PostCSS 作者ai 大神勾搭了下,并得到了大神赠送的俄罗斯巧克力。

思考

在笔者看来,PostCSS 的作为一个CSS 转换引擎,其不参与细分功能实现仅交于第三方插件的设计理念,让其产生了一个非常的开放的生态。但对于个开放机制下的一些情况笔者并不是很赞同,如一些用中文写CSS 的插件(当然这个更多是for fun),一些自定义CSS 属性的插件如用size: 10px 2px 等代替width/height 的插件——在笔者看来PostCSS 插件应该更多在遵从CSS 标准语法的基础上进行扩展。

但无论如何,还是挺佩作者开发出了这么个造福前端届的工具;也因为认同作者,笔者写了这篇文章为推广PostCSS 做了一些微小的工作;也希望对看到文末的您有所帮助,积极参与到开源创作的事业中。

参考文章:

http://ai.github.io/postcss-way/

https://github.com/postcss/postcss/blob/master/docs/guidelines/plugin.md

https://css-tricks.com/want-make-postcss-plugin

本文首发于我司内网知识平台,略有修改。版权受相关法律保护,转载请提供原来来源为本页URL http://devework.com/postcss-plugin-best-practice.html 。