手把手教你写一个脚手架(中)

简介: 手把手教你写一个脚手架(中)

渲染模板

脚手架是怎么渲染模板的呢?用 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 来决定是否渲染这段代码。如果 hasBabelfalse,则这段代码:

{
    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 中注入特定的代码。

vuexvue 的一个状态管理库,属于 vue 全家桶中的一员。如果创建的项目没有选择 vuexvue-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')

这里简单描述一下代码的注入过程:

  1. 使用 vue-codemod 将代码解析成语法抽象树 AST。
  2. 然后将要插入的代码变成 AST 节点插入到上面所说的 AST 中。
  3. 最后将新的 AST 重新渲染成代码。

提取 package.json 的部分选项

一些第三方库的配置项可以放在 package.json 文件,也可以自己独立生成一份文件。例如 babelpackage.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])
    })
}

这段代码的逻辑如下:

  1. 遍历所有渲染好的文件,逐一生成。
  2. 在生成一个文件时,确认它的父目录在不在,如果不在,就先生成父目录。
  3. 写入文件。

例如现在一个文件路径为 src/test.js,第一次写入时,由于还没有 src 目录。所以会先生成 src 目录,再生成 test.js 文件。

webpack

webpack 需要提供开发环境下的热加载、编译等服务,还需要提供打包服务。目前 webpack 的代码比较少,功能比较简单。而且生成的项目中,webpack 配置代码是暴露出来的。这留待 v3 版本再改进。

添加新功能

添加一个新功能,需要在两个地方添加代码:分别是 lib/promptModuleslib/generator。在 lib/promptModules 中添加的是这个功能相关的交互提示语。在 lib/generator 中添加的是这个功能相关的依赖和模板代码。

不过不是所有的功能都需要添加模板代码的,例如 babel 就不需要。在添加新功能时,有可能会对已有的模板代码造成影响。例如我现在需要项目支持 ts。除了添加 ts 相关的依赖,还得在 webpackvuevue-routervuexlinter 等功能中修改原有的模板代码。

举个例子,在 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 版本的创建过程:

创建成功的项目截图:

目录
相关文章
|
10月前
|
前端开发 JavaScript 开发工具
手把手教你写一个脚手架(上)
手把手教你写一个脚手架
120 2
|
10月前
|
资源调度 监控 前端开发
手把手教你写一个脚手架(下)
手把手教你写一个脚手架(下)
52 0
|
10月前
|
前端开发 JavaScript 测试技术
手把手带你入门前端工程化——超详细教程(一)
手把手带你入门前端工程化——超详细教程
258 0
|
1月前
|
JavaScript 前端开发 编译器
Vue快速上手笔记2 - 开发环境的搭建
Vue快速上手笔记2 - 开发环境的搭建
29 0
|
1月前
|
JavaScript 前端开发
Vue快速上手笔记1 - 使用初体验
Vue快速上手笔记1 - 使用初体验
26 0
|
10月前
|
前端开发 Shell 项目管理
手把手教你写一个脚手架(二)
手把手教你写一个脚手架(二)
42 0
|
10月前
|
监控 前端开发 JavaScript
手把手带你入门前端工程化——超详细教程(三)
手把手带你入门前端工程化——超详细教程(三)
85 0
|
10月前
|
监控 前端开发 测试技术
手把手带你入门前端工程化——超详细教程(二)
手把手带你入门前端工程化——超详细教程(二)
68 0
|
10月前
|
Web App开发 监控 前端开发
手把手带你入门前端工程化——超详细教程(四)
手把手带你入门前端工程化——超详细教程(四)
94 0
|
11月前
|
JavaScript
vue项目创建手把手教会绝不迷路(赞赞赞)
vue项目创建手把手教会绝不迷路(赞赞赞)
37 0