渲染模板
脚手架是怎么渲染模板的呢?用 vuex
举例,先看一下它的代码:
module.exports = (generator) => { // 向入口文件 `src/main.js` 注入代码 import store from './store' generator.injectImports(generator.entryFile, `import store from './store'`) // 向入口文件 `src/main.js` 的 new Vue() 注入选项 store generator.injectRootOptions(generator.entryFile, `store`) // 注入依赖 generator.extendPackage({ dependencies: { vuex: '^3.6.2', }, }) // 渲染模板 generator.render('./template', {}) }
可以看到渲染的代码为 generator.render('./template', {})
。./template
是模板目录的路径:
所有的模板代码都放在 template
目录下,vuex
将会在用户创建的目录下的 src
目录生成 store
文件夹,里面有一个 index.js
文件。它的内容为:
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { }, mutations: { }, actions: { }, modules: { }, })
这里简单描述一下 generator.render()
的渲染过程。
第一步, 使用 globby 读取模板目录下的所有文件:
const _files = await globby(['**/*'], { cwd: source, dot: true })
第二步,遍历所有读取的文件。如果文件是二进制文件,则不作处理,渲染时直接生成文件。否则读取文件内容,再调用 ejs 进行渲染:
// 返回文件内容 const template = fs.readFileSync(name, 'utf-8') return ejs.render(template, data, ejsOptions)
使用 ejs
的好处,就是可以结合变量来决定是否渲染某些代码。例如 webpack
的模板中有这样一段代码:
module: { rules: [ <%_ if (hasBabel) { _%> { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/, }, <%_ } _%> ], },
ejs
可以根据用户是否选择了 babel
来决定是否渲染这段代码。如果 hasBabel
为 false
,则这段代码:
{ test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/, },
将不会被渲染出来。hasBabel
的值是调用 render()
时用参数传过去的:
generator.render('./template', { hasBabel: options.features.includes('babel'), lintOnSave: options.lintOn.includes('save'), })
第三步,注入特定代码。回想一下刚才 vuex
中的:
// 向入口文件 `src/main.js` 注入代码 import store from './store' generator.injectImports(generator.entryFile, `import store from './store'`) // 向入口文件 `src/main.js` 的 new Vue() 注入选项 store generator.injectRootOptions(generator.entryFile, `store`)
这两行代码的作用是:在项目入口文件 src/main.js
中注入特定的代码。
vuex
是 vue
的一个状态管理库,属于 vue
全家桶中的一员。如果创建的项目没有选择 vuex
和 vue-router
。则 src/main.js
的代码为:
import Vue from 'vue' import App from './App.vue' Vue.config.productionTip = false new Vue({ render: (h) => h(App), }).$mount('#app')
如果选择了 vuex
,它会注入上面所说的两行代码,现在 src/main.js
代码变为:
import Vue from 'vue' import store from './store' // 注入的代码 import App from './App.vue' Vue.config.productionTip = false new Vue({ store, // 注入的代码 render: (h) => h(App), }).$mount('#app')
这里简单描述一下代码的注入过程:
- 使用 vue-codemod 将代码解析成语法抽象树 AST。
- 然后将要插入的代码变成 AST 节点插入到上面所说的 AST 中。
- 最后将新的 AST 重新渲染成代码。
提取 package.json
的部分选项
一些第三方库的配置项可以放在 package.json
文件,也可以自己独立生成一份文件。例如 babel
在 package.json
中注入的配置为:
babel: { presets: ['@babel/preset-env'], }
我们可以调用 generator.extractConfigFiles()
将内容提取出来并生成 babel.config.js
文件:
module.exports = { presets: ['@babel/preset-env'], }
生成文件
渲染好的模板文件和 package.json
文件目前还是在内存中,并没有真正的在硬盘上创建。这时可以调用 writeFileTree()
将文件生成:
const fs = require('fs-extra') const path = require('path') module.exports = async function writeFileTree(dir, files) { Object.keys(files).forEach((name) => { const filePath = path.join(dir, name) fs.ensureDirSync(path.dirname(filePath)) fs.writeFileSync(filePath, files[name]) }) }
这段代码的逻辑如下:
- 遍历所有渲染好的文件,逐一生成。
- 在生成一个文件时,确认它的父目录在不在,如果不在,就先生成父目录。
- 写入文件。
例如现在一个文件路径为 src/test.js
,第一次写入时,由于还没有 src
目录。所以会先生成 src
目录,再生成 test.js
文件。
webpack
webpack 需要提供开发环境下的热加载、编译等服务,还需要提供打包服务。目前 webpack 的代码比较少,功能比较简单。而且生成的项目中,webpack 配置代码是暴露出来的。这留待 v3 版本再改进。
添加新功能
添加一个新功能,需要在两个地方添加代码:分别是 lib/promptModules
和 lib/generator
。在 lib/promptModules
中添加的是这个功能相关的交互提示语。在 lib/generator
中添加的是这个功能相关的依赖和模板代码。
不过不是所有的功能都需要添加模板代码的,例如 babel
就不需要。在添加新功能时,有可能会对已有的模板代码造成影响。例如我现在需要项目支持 ts
。除了添加 ts
相关的依赖,还得在 webpack
vue
vue-router
vuex
linter
等功能中修改原有的模板代码。
举个例子,在 vue-router
中,如果支持 ts
,则这段代码:
const routes = [ // ... ]
需要修改为:
<%_ if (hasTypeScript) { _%> const routes: Array<RouteConfig> = [ // ... ] <%_ } else { _%> const routes = [ // ... ] <%_ } _%>
因为 ts
的值有类型。
总之,添加的新功能越多,各个功能的模板代码也会越来越多。并且还需要考虑到各个功能之间的影响。
下载依赖
下载依赖需要使用 execa,它可以调用子进程执行命令。
const execa = require('execa') module.exports = function executeCommand(command, cwd) { return new Promise((resolve, reject) => { const child = execa(command, [], { cwd, stdio: ['inherit', 'pipe', 'inherit'], }) child.stdout.on('data', buffer => { process.stdout.write(buffer) }) child.on('close', code => { if (code !== 0) { reject(new Error(`command failed: ${command}`)) return } resolve() }) }) } // create.js 文件 console.log('\n正在下载依赖...\n') // 下载依赖 await executeCommand('npm install', path.join(process.cwd(), name)) console.log('\n依赖下载完成! 执行下列命令开始开发:\n') console.log(`cd ${name}`) console.log(`npm run dev`)
调用 executeCommand()
开始下载依赖,参数为 npm install
和用户创建的项目路径。为了能让用户看到下载依赖的过程,我们需要使用下面的代码将子进程的输出传给主进程,也就是输出到控制台:
child.stdout.on('data', buffer => { process.stdout.write(buffer) })
下面我用动图演示一下 v1 版本的创建过程:
创建成功的项目截图: