详解基于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 相关文章推荐
Extjs ajax同步请求时post方式参数发送方式
Aug 05 Javascript
一些mootools的学习资源
Feb 07 Javascript
Lua表达式和控制结构学习笔记
Dec 15 Javascript
jQuery实现form表单基于ajax无刷新提交方法详解
Dec 08 Javascript
关于backbone url请求中参数带有中文存入数据库是乱码的快速解决办法
Jun 13 Javascript
Bootstrap 3.x打印预览背景色与文字显示异常的解决
Nov 06 Javascript
Vue2.0实现购物车功能
Jun 05 Javascript
javascript连接mysql与php通过odbc连接任意数据库的实例
Dec 27 Javascript
Vue不能检测到Object/Array更新的情况的解决
Jun 26 Javascript
使用React手写一个对话框或模态框的方法示例
Apr 25 Javascript
浅探express路由和中间件的实现
Sep 30 Javascript
ES6常用小技巧总结【去重、交换、合并、反转、迭代、计算等】
Dec 21 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
ASP和PHP都是可以删除自身的
2007/04/09 PHP
PHP中开发XML应用程序之基础篇 添加节点 删除节点 查询节点 查询节
2010/07/09 PHP
ThinkPHP中的关联模型注意点
2014/06/16 PHP
详解PHP神奇又有用的Trait
2019/03/25 PHP
laravel数据库查询结果自动转数组修改实例
2021/02/27 PHP
超级酷和最实用的jQuery实例收集(20个)
2010/04/21 Javascript
33个优秀的 jQuery 图片展示插件分享
2012/03/14 Javascript
javascript实现tabs选项卡切换效果(自写原生js)
2013/03/19 Javascript
清除div下面的所有标签的方法
2014/02/17 Javascript
jQuery搜索子元素的方法
2015/02/10 Javascript
javascript特效实现——当前时间和倒计时效果的简单实例
2016/07/20 Javascript
微信小程序 连续旋转动画(this.animation.rotate)详解
2017/04/07 Javascript
最新Javascript程序员面试试题和解题方法
2017/11/23 Javascript
使用layui 渲染table数据表格的实例代码
2018/08/19 Javascript
原生JS实现旋转轮播图+文字内容切换效果【附源码】
2018/09/29 Javascript
js滚轮事件 js自定义滚动条的实现
2020/01/18 Javascript
Python 爬虫学习笔记之多线程爬虫
2016/09/21 Python
基于python的socket实现单机五子棋到双人对战
2020/03/24 Python
python GUI库图形界面开发之PyQt5表单布局控件QFormLayout详细使用方法与实例
2020/03/06 Python
浅谈cv2.imread()和keras.preprocessing中的image.load_img()区别
2020/06/12 Python
Python常用数据分析模块原理解析
2020/07/20 Python
Alpine安装Python3依赖出现的问题及解决方法
2020/12/25 Python
详解webapp页面滚动卡顿的解决办法
2018/12/26 HTML / CSS
关于canvas绘制模糊问题的解决方法
2019/09/24 HTML / CSS
美国知名男士服饰品牌:Brooks Brothers(布克兄弟)
2016/08/25 全球购物
中国汽车租赁行业头部企业:一嗨租车
2019/05/16 全球购物
经典c++面试题四
2015/05/14 面试题
ASP.NET中的身份验证有那些
2012/07/13 面试题
php优化查询foreach代码实例讲解
2021/03/24 PHP
公司外出活动方案
2014/08/14 职场文书
2014年银行员工年终自我评价
2014/09/19 职场文书
顶岗实习计划书
2015/01/16 职场文书
2016年少先队活动总结
2016/04/06 职场文书
教你怎么用python实现字符串转日期
2021/05/24 Python
MySQL 执行数据库更新update操作的时候数据库卡死了
2022/05/02 MySQL
python开发制作好看的时钟效果
2022/05/02 Python