详解基于node.js的脚手架工具开发经历


Posted in Javascript onJanuary 28, 2019

前言

我们团队的前端项目是基于一套内部的后台框架进行开发的,这套框架是基于vue和ElementUI进行了一些定制化包装,并加入了一些自己团队设计的模块,可以进一步简化后台页面的开发工作。

这套框架拆分为基础组件模块,用户权限模块,数据图表模块三个模块,后台业务层的开发至少要基于基础组件模块,可以根据具体需要加入用户权限模块或者数据图表模块。尽管vue提供了一些脚手架工具vue-cli,但由于我们的项目是基于多页面的配置进行开发和打包,与vue-cli生成的项目结构和配置有些不一样,所以创建项目的时候,仍然需要人工去修改很多地方,甚至为了方便,直接从之前的项目copy过来然后进行魔改。表面上看问题不大,但其实存在很多问题:

  • 重复性工作,繁琐而且浪费时间
  • copy过来的模板容易存在无关的代码
  • 项目中有很多需要配置的地方,容易忽略一些配置点,进而埋坑
  • 人工操作永远都有可能犯错,建新项目时,总要花时间去排错
  • 内部框架也在不停的迭代,人工建项目往往不知道框架最新的版本号是多少,使用旧版本的框架可能会重新引入一些bug

针对以上问题,我开发了一个脚手架工具,可以根据交互动态生成项目结构,自动添加依赖和配置,并移除不需要的文件。

接下来整理一下我的整个开发经历。

基本思路

开始撸代码之前,先捋一捋思路。其实,在实现自己的脚手架之前,我反复整理分析了vue-cli的实现,发现很多有意思的模块,并从中借鉴了它的一些好的思想。

详解基于node.js的脚手架工具开发经历

vue-cli是将项目模板作为资源独立发布在git上,然后在运行的时候将模板下载下来,经过模板引擎渲染,最后生成工程。这样将项目模板与工具分离的目的主要是,项目模板负责项目的结构和依赖配置,脚手架负责项目构建的流程,这两部分并没有太大的关联,通过分离,可以确保这两部分独立维护。假如项目的结构、依赖项或者配置有变动,只需要更新项目模板即可。

参照vue-cli的思路,我也将项目模板独立发布到git上,然后通过脚手架工具下载下来,经过与脚手架的交互获取新项目的信息,并将交互的输入作为元信息渲染项目模板,最终得到项目的基础结构。

工程结构

工程基于 nodejs 8.4 以及 ES6 进行开发,目录结构如下

/bin # ------ 命令执行文件
/lib # ------ 工具模块
package.json

下面的部分代码需要你先对 Promise 有一定的了解才更好的理解。

使用commander.js开发命令行工具

nodejs内置了对命令行操作的支持,node工程下 package.json 中的 bin 字段可以定义命令名和关联的执行文件。

{
 "name": "macaw-cli",
 "version": "1.0.0",
 "description": "我的cli",
 "bin": {
 "macaw": "./bin/macaw.js"
 }
}

经过这样配置的nodejs项目,在使用 -g 选项进行全局安装的时候,会自动在系统的 [prefix]/bin 目录下创建相应的符号链接(symlink)关联到执行文件。如果是本地安装,这个符号链接会生成在 ./node_modules/.bin 目录下。这样做的好处是可以直接在终端中像执行命令一样执行nodejs文件。关于 prefix ,可以通过 npm config get prefix 获取。

hello, commander.js

在bin目录下创建一个macaw.js文件,用于处理命令行的逻辑。

touch ./bin/macaw.js

接下来就要用到github上一位神级人物——tj ——开发的模块commander.js 。commander.js可以自动的解析命令和参数,合并多选项,处理短参,等等,功能强大,上手简单。具体的使用方法可以参见项目的README。

macaw.js 中编写命令行的入口逻辑

#!/usr/bin/env node

const program = require('commander') // npm i commander -D

program.version('1.0.0')
	.usage('<command> [项目名称]')
	.command('hello', 'hello')
	.parse(process.argv)

接着,在 bin 目录下创建 macaw-hello.js ,放一个打印语句

touch ./bin/macaw-hello.js
echo "console.log('hello, commander')" > ./bin/macaw-hello.js

这样,通过node命令测试一下

node ./bin/macaw.js hello

不出意外,可以在终端上看到一句话:hello, commander。

commander支持git风格的子命令处理 ,可以根据子命令自动引导到以特定格式命名的命令执行文件,文件名的格式是 [command]-[subcommand] ,例如:

  • macaw hello => macaw-hello
  • macaw init => macaw-init

定义init子命令

我们需要通过一个命令来新建项目,按照常用的一些名词,我们可以定义一个名为 init 的子命令。

bin/macaw.js 做一些改动。

const program = require('commander')

program.version('1.0.0')
	.usage('<command> [项目名称]')
	.command('init', '创建新项目')
	.parse(process.argv)

在bin目录下创建一个 init 命令关联的执行文件

touch ./bin/macaw-init.js

添加如下代码

#!/usr/bin/env node

const program = require('commander')

program.usage('<project-name>').parse(process.argv)

// 根据输入,获取项目名称
let projectName = program.args[0]

if (!projectName) { // project-name 必填
 // 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
 program.help() 
 return
}

go()

function go () {
	// 预留,处理子命令 
}

注意第一行 #!/usr/bin/env node 是干嘛的,有个关键词叫Shebang,不了解的可以去搜搜看

project-name 是必填参数,不过,我想对 project-name 进行一些自动化的处理。

  • 当前目录为空,如果当前目录的名称和 project-name 一样,则直接在当前目录下创建工程,否则,在当前目录下创建以 project-name 作为名称的目录作为工程的根目录
  • 当前目录不为空,如果目录中不存在与 project-name 同名的目录,则创建以 project-name 作为名称的目录作为工程的根目录,否则提示项目已经存在,结束命令执行。

根据以上设定,再对执行文件做一些完善

#!/usr/bin/env node

const program = require('commander')
const path = require('path')
const fs = require('fs')
const glob = require('glob') // npm i glob -D

program.usage('<project-name>')

// 根据输入,获取项目名称
let projectName = program.args[0]

if (!projectName) { // project-name 必填
 // 相当于执行命令的--help选项,显示help信息,这是commander内置的一个命令选项
 program.help() 
 return
}

const list = glob.sync('*') // 遍历当前目录
let rootName = path.basename(process.cwd())
if (list.length) { // 如果当前目录不为空
 if (list.filter(name => {
 const fileName = path.resolve(process.cwd(), path.join('.', name))
 const isDir = fs.stat(fileName).isDirectory()
 return name.indexOf(projectName) !== -1 && isDir
 }).length !== 0) {
 console.log(`项目${projectName}已经存在`)
 return
 }
 rootName = projectName
} else if (rootName === projectName) {
 rootName = '.'
} else {
 rootName = projectName
}

go()

function go () {
	// 预留,处理子命令 
 	console.log(path.resolve(process.cwd(), path.join('.', rootName)))
}

随意找个路径下建一个空目录,然后在这个目录下执行咱们定义的初始化命令

node /[pathto]/macaw-cli/bin/macaw.js init hello-cli

正常的话,可以看到终端上打印出项目的路径。

详解基于node.js的脚手架工具开发经历

使用download-git-repo下载模板

下载模板的工具用到另外一个node模块download-git-repo ,参照项目的README,对下载工具进行简单的封装。

lib 目录下创建一个 download.js

const download = require('download-git-repo')

module.exports = function (target) {
 target = path.join(target || '.', '.download-temp')
 return new Promise(resolve, reject) {
 // 这里可以根据具体的模板地址设置下载的url,注意,如果是git,url后面的branch不能忽略
 download('https://github.com:username/templates-repo.git#master',
 target, { clone: true }, (err) => {
 if (err) {
 reject(err)
 } else {
 // 下载的模板存放在一个临时路径中,下载完成后,可以向下通知这个临时路径,以便后续处理
 resolve(target)
 }
 })
 }
}

download-git-repo模块本质上就是一个方法,它遵循node.js的CPS,用回调的方式处理异步结果。如果熟悉node.js的话,应该都知道这样处理存在一个弊端,我把它进行了封装,转换成现在更加流行的Promise的风格处理异步。

再一次对之前的 macaw-init.js 进行修改

const download = require('./lib/download')

... // 之前的省略
function go () {
 download(rootName)
 .then(target => console.log(target))
 .catch(err => console.log(err))
}

下载完成之后,再将临时下载目录中的项目模板文件转移到项目目录中,一个简单的脚手架算是基本完成了。转移的具体实现方法就不细说了,可以参见node.js的API。你的node.js版本如果在8以下,可以用stream和pipe的方式实现,如果是8或者9,可以使用新的API——copyFile()或者 copyFileSync() 。

but...

这个世界并非我们想象的那么简单。我们可能会希望项目模板中有些文件或者代码可以动态处理。比如:

  • 新项目的 名称版本号描述 等信息等,可以通过脚手架的交互进行输入,然后将输入插入到模板中
  • 项目模板并非所有文件都会用到,可以通过脚手架提供的选项移除掉那些无用的文件或者目录。

对于这类情况,我们还需要借助其他工具包来完成。

使用inquirer.js处理命令行交互

对于命令行交互的功能,可以用inquirer.js 来处理。用法其实很简单:

const inquirer = require('inquirer') // npm i inquirer -D

inquirer.prompt([
 {
 name: 'projectName',
 message: '请输入项目名称'
 }
]).then(answers => {
 console.log(`你输入的项目名称是:${answers.projectName}`)
})

prompt() 接受一个问题对象 的数据,在用户与终端交互过程中,将用户的输入存放在一个 答案对象 中,然后返回一个 Promise ,通过 then() 获取到这个答案对象。so easy!

接下来继续对macaw-init.js进行完善。

// ...

const inquirer = require('inquirer')
const list = glob.sync('*')

let next = undefined
if (list.length) {
 if (list.filter(name => {
 const fileName = path.resolve(process.cwd(), path.join('.', name))
 const isDir = fs.stat(fileName).isDirectory()
 return name.indexOf(projectName) !== -1 && isDir
 }).length !== 0) {
 console.log(`项目${projectName}已经存在`)
 return
 }
 next = Promise.resolve(projectName)
} else if (rootName === projectName) {
 next = inquirer.prompt([
 {
 name: 'buildInCurrent',
 message: '当前目录为空,且目录名称和项目名称相同,是否直接在当前目录下创建新项目?'
 type: 'confirm',
 default: true
 }
 ]).then(answer => {
 return Promise.resolve(answer.buildInCurrent ? '.' : projectName)
 })
} else {
 next = Promise.resolve(projectName)
}

next && go()

function go () {
 next.then(projectRoot => {
 if (projectRoot !== '.') {
 fs.mkdirSync(projectRoot)
 }
 return download(projectRoot).then(target => {
 return {
 projectRoot,
 downloadTemp: target
 }
 })
 })
}

如果当前目录是空的,并且目录名称和项目名称相同,那么就通过终端交互的方式确认是否直接在当前目录下创建项目,这样会让脚手架更加人性化。

前面提到,新项目的名称、版本号、描述等信息可以直接通过终端交互插入到项目模板中,那么再进一步完善交互流程。

// ...

// 这个模块可以获取node包的最新版本
const latestVersion = require('latest-version') // npm i latest-version -D

// ...

function go () {
 next.then(projectRoot => {
 if (projectRoot !== '.') {
 fs.mkdirSync(projectRoot)
 }
 return download(projectRoot).then(target => {
 return {
 name: projectRoot,
 root: projectRoot,
 downloadTemp: target
 }
 })
 }).then(context => {
 return inquirer.prompt([
 {
 name: 'projectName',
 	message: '项目的名称',
 default: context.name
 }, {
 name: 'projectVersion',
 message: '项目的版本号',
 default: '1.0.0'
 }, {
 name: 'projectDescription',
 message: '项目的简介',
 default: `A project named ${context.name}`
 }
 ]).then(answers => {
 return latestVersion('macaw-ui').then(version => {
 answers.supportUiVersion = version
 return {
 ...context,
 metadata: {
 ...answers
 }
 }
 }).catch(err => {
 return Promise.reject(err)
 })
 })
 }).then(context => {
 console.log(context)
 }).catch(err => {
 console.error(err)
 })
}

下载完成后,提示用户输入新项目信息。当然,交互的问题不仅限于此,可以根据自己项目的情况,添加更多的交互问题。inquirer.js强大的地方在于,支持很多种交互类型,除了简单的 input ,还有 confirmlistpasswordcheckbox 等,具体可以参见项目的 README 。

然后,怎么把这些输入的内容插入到模板中呢,这时候又用到另外一个简单但又不简单的工具包——metalsmith。

使用metalsmith处理模板

引用官网的介绍:

An extremely simple, pluggable static site generator.

它就是一个静态网站生成器,可以用在批量处理模板的场景,类似的工具包还有Wintersmith、 Assemble 、Hexo。它最大的一个特点就是 EVERYTHING IS PLUGIN ,所以,metalsmith本质上就是一个胶水框架,通过黏合各种插件来完成生产工作。

给项目模板添加变量占位符

模板引擎我选择handlebars。当然,还可以有其他选择,例如ejs、 jade 、 swig 。

用handlebars的语法对模板做一些调整,例如修改模板中的 package.json

{
 "name": "{{projectName}}",
 "version": "{{projectVersion}}",
 "description": "{{projectDescription}}",
 "author": "Forcs Zhang",
 "private": true,
 "scripts": {
 "dev": "node build/dev-server.js",
 "start": "node build/dev-server.js",
 "build": "node build/build.js",
 "unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
 "test": "npm run unit",
 "lint": "eslint --ext .js,.vue src test/unit/specs"
 },
 "dependencies": {
 "element-ui": "^2.0.7",
 "macaw-ui": "{{supportUiVersion}}",
 "vue": "^2.5.2",
 "vue-router": "^2.3.1"
 },
 ...
}

package.jsonnameversiondescription 字段的内容被替换成了handlebar语法的占位符,模板中其他地方也做类似的替换,完成后重新提交模板的更新。

实现脚手架给模板插值的功能

lib 目录下创建 generator.js ,封装metalsmith。

touch ./lib/generator.js

// npm i handlebars metalsmith -D
const Metalsmith = require('metalsmith')
const Handlebars = require('handlebars')
const rm = require('rimraf').sync

module.exports = function (metadata = {}, src, dest = '.') {
 if (!src) {
 return Promise.reject(new Error(`无效的source:${src}`))
 }
 
 return new Promise((resolve, reject) => {
 Metalsmith(process.cwd())
 .metadata(metadata)
 .clean(false)
 .source(src)
 .destination(dest)
 .use((files, metalsmith, done) => {
 	const meta = metalsmith.metadata()
 Object.keys(files).forEach(fileName => {
  const t = files[fileName].contents.toString()
  files[fileName].contents = new Buffer(Handlebars.compile(t)(meta))
 })
 	done()
 }).build(err => {
 	rm(src)
 	err ? reject(err) : resolve()
 })
 })
}

macaw-init.jsgo() 添加生成逻辑。

// ...
const generator = require('../lib/generator')

function go () {
 next.then(projectRoot => {
 // ...
 }).then(context => {
 // 添加生成的逻辑
 return generator(context)
 }).then(context => {
 console.log('创建成功:)')
 }).catch(err => {
 console.error(`创建失败:${err.message}`)
 }) 
}

至此,一个带交互,可动态给模板插值的脚手架算是基本完成了。

tips:墙裂推荐一下tj的另一个工具包: consolidate.js ,在vue-cli中发现的,感兴趣的话可以去了解一下。

美化我们的脚手架

通过一些工具包,让脚手架更加人性化。这里介绍两个在vue-cli中发现的工具包:

ora - 显示spinner

chalk - 给枯燥的终端界面添加一些色彩

这两个工具包用起来不复杂,用好了会让脚手架看起来更加高大上

用ora优化加载等待的交互

ora可以用在加载等待的场景中,比如脚手架中下载项目模板的时候可以使用,如果给模板插值生成项目的过程也有明显等待的话,也可以使用。

以下载为例,对 download.js 做一些改良:

npm i ora -D
const download = require('download-git-repo')
const ora = require('ora')

module.exports = function (target) {
 target = path.join(target || '.', '.download-temp')
 return new Promise(resolve, reject) {
 const url = 'https://github.com:username/templates-repo.git#master'
 const spinner = ora(`正在下载项目模板,源地址:${url}`)
 spinner.start()
 download(url, target, { clone: true }, (err) => {
 if (err) {
 spinner.fail() // wrong :(
 reject(err)
 } else {
 spinner.succeed() // ok :)
 resolve(target)
 }
 })
 }
}

用chalk优化终端信息的显示效果

chalk可以给终端文字设置颜色。

// ...
const chalk = require('chalk')
const logSymbols = require('log-symbols')

// ...

function go () {
 // ...
 next.then(/* ... */)
 /* ... */
 	.then(context => {
 // 成功用绿色显示,给出积极的反馈
 console.log(logSymbols.success, chalk.green('创建成功:)'))
 console.log()
 console.log(chalk.green('cd ' + context.root + '\nnpm install\nnpm run dev'))
 }).catch(err => {
 // 失败了用红色,增强提示
 console.error(logSymbols.error, chalk.red(`创建失败:${error.message}`))
 }) 
}

详解基于node.js的脚手架工具开发经历

根据输入项移除模板中不需要的文件

有时候,项目模板中并不是所有文件都是需要的。为了保证新生成的项目中尽可能的不存在脏代码,我们可能需要根据脚手架的输入项来确认最终生成的项目结构,将没用的文件或者目录移除。比如vue-cli,创建项目时会询问我们是否需要加入测试模块,如果不需要,最终生成的项目代码中是不包含测试相关的代码的。这个功能如何实现呢?

实现的思路

我参考了git的思路,定义个 ignore 文件,将需要被忽略的文件名列在这个 ignore 文件里,配上模板语法。脚手架在生成项目的时候,根据输入项先渲染这个 ignore 文件,然后根据 ignore 文件的内容移除不需要的模板文件,然后再渲染真正会用到的项目模板,最终生成项目。

详解基于node.js的脚手架工具开发经历

实现方案

根据以上思路,我先定义了属于我们项目自己的 ignore 文件,取名为 templates.ignore

然后在这个 ignore 文件中添加需要被忽略的文件名。

{{#unless supportMacawAdmin}}
# 如果不开启admin后台,登录页面和密码修改页面是不需要的
src/entry/login.js 	
src/entry/password.js
{{/unless}}

# 最终生成的项目中不需要ignore文字自身
templates.ignore

然后在 lib/generator.js 中添加对 templates.ignore 的处理逻辑

// ...

const minimatch = require('minimatch') // https://github.com/isaacs/minimatch

module.exports = function (metadata = {}, src, dest = '.') {
 if (!src) {
 return Promise.reject(new Error(`无效的source:${src}`))
 }

 return new Promise((resolve, reject) => {
 const metalsmith = Metalsmith(process.cwd())
 .metadata(metadata)
 .clean(false)
 .source(src)
 .destination(dest)
	// 判断下载的项目模板中是否有templates.ignore
 const ignoreFile = path.join(src, 'templates.ignore')
 if (fs.existsSync(ignoreFile)) {
 // 定义一个用于移除模板中被忽略文件的metalsmith插件
 metalsmith.use((files, metalsmith, done) => {
 const meta = metalsmith.metadata()
 // 先对ignore文件进行渲染,然后按行切割ignore文件的内容,拿到被忽略清单
 const ignores = Handlebars.compile(fs.readFileSync(ignoreFile).toString())(meta)
  .split('\n').filter(item => !!item.length)
 Object.keys(files).forEach(fileName => {
  // 移除被忽略的文件
  ignores.forEach(ignorePattern => {
  if (minimatch(fileName, ignorePattern)) {
  delete files[fileName]
  }
  })
 })
 done()
 })
 }
 metalsmith.use((files, metalsmith, done) => {
 const meta = metalsmith.metadata()
 Object.keys(files).forEach(fileName => {
 const t = files[fileName].contents.toString()
 files[fileName].contents = new Buffer(Handlebars.compile(t)(meta))
 })
 done()
 }).build(err => {
 rm(src)
 err ? reject(err) : resolve()
 })
 })
}

基于插件思想的metalsmith很好扩展,实现也不复杂,具体过程可参见代码中的注释。

总结

经过对vue-cli的整理,借助了很多node模块,整个脚手架的实现并不复杂。

  • 将项目模板与脚手架工具分离,可以更好的维护模板和脚手架工具。
  • 通过commander.js处理命令行
  • 通过download-git-repo处理下载
  • 通过inquirer.js处理终端交互
  • 通过metalsmith和模板引擎将交互输入项插入到项目模板中
  • 参考了git的ignore的思路,利用自定义的templates.ignore动态化的移除不必要的文件和目录

以上就是我开发脚手架的主要经历,中间还有很多不足的地方,今后再慢慢完善吧。

最后说一下,其实vue-cli能做的事情还有很多,具体的可以看看项目的README和源码。关于脚手架的开发,不一定要完全造个轮子,可以看看另外一个很强大的模块YEOMAN,借助这个模块也可以很快的实现自己的脚手架工具。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
jQuery获得IE版本不准确webbrowser的解决方法
Feb 23 Javascript
jQuery插件expander实现图片翻转特效
May 21 Javascript
js实现YouKu的漂亮搜索框效果
Aug 19 Javascript
使用pcs api往免费的百度网盘上传下载文件的方法
Mar 17 Javascript
IE和Firefox之间在JavaScript语法上的差异
Apr 22 Javascript
js实现select选择框效果及美化
Aug 19 Javascript
微信小程序 欢迎界面开发的实例详解
Nov 30 Javascript
js实现短信发送倒计时功能(正则验证)
Feb 10 Javascript
修改layui的后台模板的左侧导航栏可以伸缩的方法
Sep 10 Javascript
解决node终端下运行js文件不支持ES6语法
Apr 04 Javascript
element中Steps步骤条和Tabs标签页关联的解决
Dec 08 Javascript
uniapp微信小程序:key失效的解决方法
Jan 20 Javascript
微信小程序实现顶部导航特效
Jan 28 #Javascript
详解几十行代码实现一个vue的状态管理
Jan 28 #Javascript
vue.js仿hover效果的实现方法示例
Jan 28 #Javascript
vue-for循环嵌套操作示例
Jan 28 #Javascript
使用pm2自动化部署node项目的方法步骤
Jan 28 #Javascript
jQuery访问json文件中数据的方法示例
Jan 28 #jQuery
JS实现的点击按钮图片上下滚动效果示例
Jan 28 #Javascript
You might like
用PHP实现WEB动态网页静态
2006/10/09 PHP
PHP在获取指定目录下的目录,在获取的目录下面再创建文件,多平台
2011/08/03 PHP
php header函数的常用http头设置
2015/06/25 PHP
使用javascript做的一个随机点名程序
2014/02/13 Javascript
关于jQuery中的each方法(jQuery到底干了什么)
2014/03/05 Javascript
javascript圆盘抽奖程序实现原理和完整代码例子
2014/06/03 Javascript
connect中间件session、cookie的使用方法分享
2014/06/17 Javascript
影响jQuery使用的14个方面
2014/09/01 Javascript
Javascript实现颜色rgb与16进制转换的方法
2015/04/18 Javascript
整理Javascript基础语法学习笔记
2015/11/29 Javascript
jQuery bt气泡实现悬停显示及移开隐藏功能的方法
2016/07/12 Javascript
vue-cli webpack 开发环境跨域详解
2017/05/18 Javascript
详解vue-cli开发环境跨域问题解决方案
2017/06/06 Javascript
jQuery ajax读取本地json文件的实例
2017/10/31 jQuery
GOJS+VUE实现流程图效果
2018/12/01 Javascript
Vue中util的工具函数实例详解
2019/07/08 Javascript
关于layui flow loading占位图的实现方法
2019/09/21 Javascript
vue 项目引入echarts 添加点击事件操作
2020/09/09 Javascript
解决vant title-active-color与title-inactive-color不生效问题
2020/11/03 Javascript
Python 搭建Web站点之Web服务器网关接口
2016/11/06 Python
python九九乘法表的实例
2017/09/26 Python
python实现密码强度校验
2020/03/18 Python
图解Python中深浅copy(通俗易懂)
2020/09/03 Python
MYPROTEIN澳大利亚官方网站:欧洲运动营养品牌
2019/06/26 全球购物
Interrail法国:乘火车探索欧洲,最受欢迎的欧洲铁路通票
2019/08/27 全球购物
俄罗斯品牌服装和鞋子在线商店:BRIONITY
2020/03/26 全球购物
init进程的作用
2015/08/20 面试题
成人高等教育毕业生自我鉴定
2013/10/22 职场文书
迟到检讨书大全
2014/01/25 职场文书
中学生打架检讨书
2014/02/10 职场文书
《新型玻璃》教学反思
2014/04/13 职场文书
理想演讲稿范文
2014/05/21 职场文书
法院反腐倡廉心得体会
2014/09/09 职场文书
自制短波长线天线频率预选器 - 成功消除B2K之流的镜像
2021/04/22 无线电
详解Python自动化之文件自动化处理
2021/06/21 Python
4种方法python批量修改替换列表中元素
2022/04/07 Python