【前端工程化】之模板工具plop初体验

3,084 阅读8分钟

有个朋友最近做了一下前端工程化的分享,笔者以前只是浅尝辄止,听完收获颇丰。考虑到工程化可以系统的完善自己前端方面的能力,所以笔者最近也开始做这个系列,从零开始,按基本结构,规范,工具,优化,自动化部署和性能监控的顺序,学习实践。正好我司让我们组开始做技术分享,笔者就分享了一下刚学的plop。分享,交流之后整理出来发上来记录一下。本文源码

一、plop概述

什么是plop?一个微型生成器框架,用于生成符合团队规范的模板文件

可以简单的认为它是 inquirer对话框 和 hanldebar模版的简单融合。

生成模板文件?听起来好像和vsCode的用户代码片段差不多。如果只处理单文件的话,那他们确实差不多。plop受益于handlebars(中文文档)更强大的语义化模板功能,可能略强于代码片段。那么,仅仅如此吗?我们来试一下。

二、plop起步

  1. 安装

    npm install --save-dev plop
    //npm install -g plop
    
  2. 在项目根文件夹创建 plopfile.js

    //plopfile.js
    module.exports = function (plop) {};
    

    它导出一个函数,这个函数接收一个 plop 对象作为第一个参数。

  3. 创建生成器generator

    一个 plop 对象暴露一个包含 setGenerator(name, config) 函数的 plop api。setGenerator 用于创建一个生成器(generator)。当 plop 在当前文件夹或子文件夹的终端中运行时,这些 generator 将会以列表的形式被展示在终端中。

    下面是一个最简单的 generator

    module.exports = function (plop) {
    	// 创建一个生成器
    	plop.setGenerator('component', {
    		description: '新增一个公共组件',//描述,在终端里生成器后面显示的内容
    		prompts: [], // 提示,用于捕获用户输入
    		actions: []  // 行为,具体执行的内容
    	});
    };
    

    prompts即是 inquirerprompts,可参考inquirer的文档,这里就不详细展开了。

    actions的标准属性如下:

    属性数据类型默认值描述
    typeStringaction 的类型(包括 add, modify, addMany
    forceBooleanfalse强制执行(不同的 action 类型会对应不同的逻辑)
    dataObject/Function{}指定 action 被执行时需要混入prompts答案的数据
    abortOnFailBooleantrue当一个 action 由于某种原因执行失败时停止执行之后的所有 action
    skipFunction一个用于指定当前 action是否需要被执行的函数

    这些是plop最基本,也是最核心的内容。了解了这些之后我们就可以正式开始使用了,来吃苟!

三、plop初体验

​ 下面以vue项目中新增一个vue文件作为示例来演示如何使用plop。因为后续使用的文件较多所以不再使用默认的plopfile.js。

  1. 在项目根目录新建一个文件夹 generators,文件夹中新增一个文件index.js

    后续文件比较多,我们新增一个文件夹来存放相关文件

    //generators/index.js
    module.exports =  (plop) => {
    	// 创建一个生成器
    	plop.setGenerator('component', {
    		description: '新增一个公共组件',//描述,在终端里生成器后面显示的内容
    		prompts: [], // 提示,用于捕获用户输入
    		actions: []  // 行为,具体执行的内容
    	});
    };
    
  2. 修改package.json

    通过--plopfile命令来指定plop的入口文件地址为刚刚新建

    //package.json
    {
      //...
      "scripts": {
        //...
        "generator": "plop --plopfile generators/index.js",
      },
    }
    
  3. 新建hanldebar模板文件

    在generators文件夹下新建index.vue.hbs文件

    <template>
      <div></div>
    </template>
    
    <script>
    export default {
      name: "{{ componentName }}",
      components: {},
      props: {
        list: {
          type: Array,
          default: function()
           {
            return [];
          },
        },
      },
      data() {
        return {}
      },
      created() {},
      mounted() {},
      methods: {},
    };
    </script>
    
    <style scoped></style>
    
  4. 完善生成器

    //generators/index.js
    const componentExists = require('./utils/componentExists');
    //componentExists是一个工具方法,用来验证是否已存在同名
    module.exports =  (plop) => {
    	// 创建一个生成器
    	plop.setGenerator('component', {
    		description: '新增一个组件', 
    		prompts: [  
    			{
    				type: 'input',
    				name: 'componentName',
    				message: '请输入组件名:',
    				default: 'Button',
    				validate: value => {
    					if (/.+/.test(value)) {
    						return componentExists(value)
    							? '已经存在相同的容器名或者组件名'
    							: true;
    					}
    					return '组件名为必填';
    				},
    			},
    		],
    		actions: data => {
    			const actions = [
    				{
    					type: 'add',
    					path: '../src/components/{{properCase componentName}}/index.vue',
                        //componentName与prompts的name值对应,即为用户输入内容
    					templateFile: './index.vue.hbs',
    					abortOnFail: true,
    				},
    			];
    			return actions;
    		},
    	});
    };
    
    
    //utils/componentExists.js
    /**
     * componentExists
     *
     * 判断组件或页面是否存在
     */
    const fs = require("fs");
    const path = require("path");
    const pageComponents = fs.readdirSync(
      path.join(__dirname, "../../src/components")
    );
    const pageContainers = fs.readdirSync(
      path.join(__dirname, "../../src/views")
    );
    const components = pageComponents.concat(pageContainers);
    
    function componentExists(comp) {
      return components.indexOf(comp) >= 0;
    }
    module.exports = componentExists;
    
  5. 实战

    到这里我们就可以正式尝试一下了。

    首先运行npm run generator,再输入组件名称Test

    创建完成。再试一下组件名不输入、输入为空、重复输入,都没问题,这里就不放截图了占位置了。

    那今天的分享到此为止......怎么可能!只有这些的话岂不是舍近求远,明明一个cv就能解决的事情。下面正式进行一下企业级的实战(吹牛 (= - =) )。

四、plop进阶

  1. 当我们新建页面时我们会做什么

    一般来说,我们新增一个页面时会在views/pages文件夹下新建一个页面文件夹(当项目比较大时会分模块,再多一层文件夹),之后新建页面的vue文件,然后去配置路由,新增对应的vuex模块,引入封装的api等等...

    那现在来尝试用plop做一下这些事情。

    先调整一下之前的文件结构,在generators文件夹下新增component文件夹,把之前的生成器配置和模板文件放进去。再新增view文件夹用来放后续的文件。generators/index.js文件调整如下:

    const componentGenerator = require('./component/index.js');
    const viewGenerator = require('./view/index.js');
    
    module.exports = (plop) => {
    	plop.setGenerator('component', componentGenerator);
    	plop.setGenerator('view', viewGenerator);
    };
    

    在view文件夹下新增index.vue.hbs文件同上文同名文件一样。

    view/index.js文件调整如下:

    const componentExists = require('../utils/componentExists');
    
    module.exports = {
    	description: '新增一个视图容器(页面)', 
    	prompts: [
    		{
    			type: 'input',
    			name: 'viewName',
    			message: '请输入容器(页面)名:',
    			default: 'Form',
    			validate: (value) => {
    				if (/.+/.test(value)) {
    					return componentExists(value)
    						? '已经存在相同的容器名或者组件名'
    						: true;
    				}
    				return '容器名必填';
    			},
    		},
    	],
    	actions: (data) => {
    		let actions = [
    			{
    				type: 'add',
    				path: '../src/views/{{ viewName }}/index.vue',
    				templateFile: './view/index.vue.hbs',
    				abortOnFail: true,
    			},
    		];
    		return actions;
    	},
    };
    

    目前为止view和component这两个生成器已经功能相同了。

  2. 新建页面时的路由处理

    src目录下新建router文件夹:

    //router/index.js
    import Vue from 'vue';
    import Router from 'vue-router';
    import Allrouters from './routers.js';
    Vue.use(Router);
    export default new Router({
    	mode: 'hash',
    	routes: [
    		// 默认首页
    		{ path: '*', redirect: '/index' },
    		...Allrouters,
    	],
    });
    
    //router/routers.js
    
    //-- append import here --
    export default [
    	//-- append router here --
    ];
    

    注意//-- append import here --和//-- append router here --和下文action及模板文件里的正则验证保持完全一致。

    新建router.js.hbs模板文件,新增两个modify类型action:

    //view/router.js.hbs
    {
    		path: '/{{ viewName }}',
    		name: '{{ viewName }}',
    		component: {{ viewName }},
    },
    //-- append router here --
    
    //view/index.js  
    ...
    actions: (data) => {
    		let actions = [
    			{
    				type: 'add',
    				path: '../src/views/{{ viewName }}/index.vue',
    				templateFile: './view/index.vue.hbs',
    				abortOnFail: true,
    			},
    			{
    				type: 'modify',
    				path: '../src/router/routers.js',
    				pattern: /(\/\/-- append import here --)/gi,
    				template:
    					"const {{ viewName }} = () => import('../views/{{ viewName }}/index.vue');\n$1",
    			},
    			{
    				type: 'modify',
    				path: '../src/router/routers.js',
    				pattern: /(\/\/-- append router here --)/gi,
    				templateFile: './view/router.js.hbs',
    			},
    		];
    		return actions;
    	},
    ...
    

    两个modify类型action会修改routers.js文件,实现同步路由。这里默认的是页面不分模块的情况,假如分模块的话,新加一个输入模块名的prompt,修改相应逻辑,多加一层就可以了。如果路由也分模块的话,可以配合webpackrequire.context来实现,根据项目实际情况动态调整。

  3. 配置vuex

    vuex我参考了一个朋友修改的结构和规范,不是一定要这样,用自己最熟悉的那套最好。查看代码

    在view文件夹下新增与上图一一对应的模板文件。查看模板

    增加一个confirm类型的prompt,与相关模板的action

    //view/index.js
    prompts: [
    		//...
    		{
    			type: 'confirm',
    			name: 'vuex',
    			default: true,
    			message: '是否使用vuex?',
    		},
    	],
    	actions: (data) => {
    		let actions = [
    			//...
    		];
    		if (data.vuex) {
    			let store = [
    				{
    					type: 'add',
    					path: '../src/views/{{ viewName }}/store/constants.js',
    					templateFile: './view/constants.js.hbs',
    					abortOnFail: true,
    				},
    				{
    					type: 'add',
    					path: '../src/views/{{ viewName }}/store/actions.js',
    					templateFile: './view/actions.js.hbs',
    					abortOnFail: true,
    				},
    				{
    					type: 'add',
    					path: '../src/views/{{ viewName }}/store/getters.js',
    					templateFile: './view/getters.js.hbs',
    					abortOnFail: true,
    				},
    				{
    					type: 'add',
    					path: '../src/views/{{ viewName }}/store/mutations.js',
    					templateFile: './view/mutations.js.hbs',
    					abortOnFail: true,
    				},
    				{
    					type: 'add',
    					path: '../src/views/{{ viewName }}/store/index.js',
    					templateFile: './view/index.js.hbs',
    					abortOnFail: true,
    				},
    			];
    			actions = actions.concat(store);
    		}
    		return actions;
    	},
    

    模板文件能过获取到prompts中提供的参数,配合handlebars自身的模板语法,实现根据用户选择动态控制模板,修改页面模板文件index.vue.hbs如下:

    //view/index.vue.hbs
    
    <template>
      <div></div>
    </template>
    
    <script>
    {{#if vuex}}
    import {
    	STORE_NAME,
    	GET_TOTAL_ACTION,
    	SET_TOTAL_MUTATION
    } from './store/constants';
    import storeOption from './store/index';
    import { mapState, mapActions, mapMutations } from 'vuex';
    {{/if}}
    
    export default {
      name: "{{ viewName }}",
      components: {},
      props: {
        list: {
          type: Array,
          default: function()
           {
            return [];
          },
        },
      },
      data() {
        return {};
      },
      computed: {
      {{#if vuex}}
        ...mapState({
    			total: (state) => state[STORE_NAME].total,
    		}),
      {{/if}}
    		
    	},
      {{#if vuex}}
      beforeCreate(){
        this.$store.registerModule(STORE_NAME,storeOption)//动态注册vuex模块
      },
      {{/if}}
      created() {},
      mounted() {},
      methods: {
        {{#if vuex}}
        ...mapActions({
    			getToalAction: `${STORE_NAME}/${GET_TOTAL_ACTION}`
    		}),
    		...mapMutations({
    			setToalMutation: `${STORE_NAME}/${SET_TOTAL_MUTATION}`,
    		}),
        {{/if}}
      },
      {{#if vuex}}
      destroyed(){
        this.$store.unregisterModule(STORE_NAME,storeOption)//离开页面时要卸载vuex模块,否则再次进入页面vuex的action会多次触发
      },
      {{/if}}
    };
    </script>
    <style scoped></style>
    
  4. 就这?

    到这里好像感觉也就...嗯,还行?

    仔细想想我们修改的路由文件很可能是个公用文件,其他同事也可能会在里面新增修改,为了避免冲突,最好新建页面之后立即推送到git。咦,node可以执行脚本命令,那可不可以把git操作也一并处理了?尝试一下。

    导入exec,自定义polp的action:

    //generator/index.js
    
    const { execSync } = require('child_process'); //node的子进程, execSync是exec的同步版,衍生shell 并在该 shell 中运行命令
    const componentGenerator = require('./component/index.js');
    const viewGenerator = require('./view/index.js');
    
    module.exports = (plop) => {
    	plop.setGenerator('component', componentGenerator);
    	plop.setGenerator('view', viewGenerator);
    	plop.setActionType('git', (answers, config) => {
    		try {
    			execSync(`git pull`);
    			execSync(`git add .`);
    			execSync(`git commit -m "新建${config.file}"`);
    			execSync(`git push`);//这些只是示例,不代表真实情况
    		} catch (err) {
    			throw err;
    		}
    	});
    };
    
    //view/index.js
    	actions.push({
    		type: 'git',
    		viewName: data.viewName,
    	});
    

    setActionType方法可以自定义actions(类似于 addmodify)。默认参数如下:

    参数类型描述
    answersObjectgenerator prompts的答案
    configActionConfiggenerator action的配置对象
    plopPlopfileApi用于action所在的plop文件的plop api

    由此可见,plop或者说node可以做任何我们想让他做的事情。

  5. 其他

    前面几步实现了基础的路由,vuex配置,git提交。但是真实项目中肯定还有更多的通用配置要加入进去,比如我们项目的页面都有统一的基础布局,那模板又丰富了:

    //view/index.vue.hbs
    
    <template>
    	<div class="pagesDiv">
    		<page-little-menu
    			:title="[
    				{ name: '数据治理' },
    				{ name: '问题设备查看', path: {{ viewName }} },
    			]"
    		/>
    		<div class="tableDivForNoCondition">
    		</div>
    	</div>
    </template>
    

    再比如我们有动态计算el表格高度然后自适应的mixins,如果有表格的页面就可以选择性注入模板;

    一般页面都是组件化开发,在页面文件夹下新建components文件夹,同时新增一个示例组件,然后在index.vue中导入组件也是必不可少的;

    封装的axios/fetchapi也是要复制进去的...

    等等太多了,这里就不多赘述了。我想每个老前端er都有新建,新建,新建,CV,CV,CV,CV然后才把页面基础结构搭好的经历。而使用plop之后只需要输入页面名称,选择一些配置,一个符合自身项目规范的页面结构就搭建完成了,想想就爽爆了。plop还有helper,partial等辅助功能,有兴趣朋友别犹豫了,你也开始吧。

五、总结

​ 上文实现了从最基础的配置一个模板文件,到一个骨架丰富(简陋,但这里不想写了,后面的内容大同小异)的视图容器生成。相比它效率提升的作用,笔者其实更在意它对于规范提升的作用。使用它可以让整个项目保持整体的一致性,视图、数据、工具等拆分的越细,项目越像同一个人写的。这对于多人协作太重要了,在你修改或者接手同事的代码时,可以帮你快速适应代码,问题可能发生的地方或者你将要修改的地方,你都了如指掌。但当项目或者团队不够大,拆分的越细,写起来越麻烦,比如上文的vuex结构,可能每次新增,修改都要修改多个文件。而合并文件,取消常量设置,简化一下结构的话,可能plop效率提升作用更优于规范提升。

本文的视野只局限在页面级,但是放大看一下,把整个项目的的配置都写进来的话,再上传npm,那它就是个脚手架了?本系列完成之后试一试。

参考

一个不错的plop中文文档

朋友的工程化博客(react)