最近在学习 vue-cli 的源码,获益良多。为了让本身理解得更加深入,我决定模仿它造一个轮子,争取尽量多的实现原有的功能。html
我将这个轮子分红三个版本:前端
有人可能不懂脚手架是什么。按个人理解,脚手架就是帮助你把项目的基础架子搭好。例如项目依赖、模板、构建工具等等。让你不用从零开始配置一个项目,尽量快的进行业务开发。vue
建议在阅读本文时,可以结合项目源码一块儿配合使用,效果更好。这是项目地址 mini-cli。项目中的每个分支都对应一个版本,例如第一个版本对应的 git 分支为 v1。因此在阅读源码时,记得要切换到对应的分支。node
第一个版本的功能比较简单,大体为:webpack
package.json
文件,并添加对应的依赖项。index.html
、main.js
、App.vue
等文件)。npm install
命令安装依赖。项目目录树:git
├─.vscode ├─bin │ ├─mvc.js # mvc 全局命令 ├─lib │ ├─generator # 各个功能的模板 │ │ ├─babel # babel 模板 │ │ ├─linter # eslint 模板 │ │ ├─router # vue-router 模板 │ │ ├─vue # vue 模板 │ │ ├─vuex # vuex 模板 │ │ └─webpack # webpack 模板 │ ├─promptModules # 各个模块的交互提示语 │ └─utils # 一系列工具函数 │ ├─create.js # create 命令处理函数 │ ├─Creator.js # 处理交互提示 │ ├─Generator.js # 渲染模板 │ ├─PromptModuleAPI.js # 将各个功能的提示语注入 Creator └─scripts # commit message 验证脚本 和项目无关 不需关注
脚手架第一个功能就是处理用户的命令,这须要使用 commander.js。这个库的功能就是解析用户的命令,提取出用户的输入交给脚手架。例如这段代码:github
#!/usr/bin/env node const program = require('commander') const create = require('../lib/create') program .version('0.1.0') .command('create <name>') .description('create a new project') .action(name => { create(name) }) program.parse()
它使用 commander 注册了一个 create
命令,并设置了脚手架的版本和描述。我将这段代码保存在项目下的 bin
目录,并命名为 mvc.js
。而后在 package.json
文件添加这段代码:web
"bin": { "mvc": "./bin/mvc.js" },
再执行 npm link,就能够将 mvc
注册成全局命令。这样在电脑上的任何地方都能使用 mvc
命令了。实际上,就是用 mvc
命令来代替执行 node ./bin/mvc.js
。vue-router
假设用户在命令行上输入 mvc create demo
(实际上执行的是 node ./bin/mvc.js create demo
),commander
解析到命令 create
和参数 demo
。而后脚手架能够在 action
回调里取到参数 name
(值为 demo)。vuex
取到用户要建立的项目名称 demo
以后,就能够弹出交互选项,询问用户要建立的项目须要哪些功能。这须要用到 [
Inquirer.js](https://github.com/SBoudrias/...。Inquirer.js
的功能就是弹出一个问题和一些选项,让用户选择。而且选项能够指定是多选、单选等等。
例以下面的代码:
const prompts = [ { "name": "features", // 选项名称 "message": "Check the features needed for your project:", // 选项提示语 "pageSize": 10, "type": "checkbox", // 选项类型 另外还有 confirm list 等 "choices": [ // 具体的选项 { "name": "Babel", "value": "babel", "short": "Babel", "description": "Transpile modern JavaScript to older versions (for compatibility)", "link": "https://babeljs.io/", "checked": true }, { "name": "Router", "value": "router", "description": "Structure the app with dynamic pages", "link": "https://router.vuejs.org/" }, ] } ] inquirer.prompt(prompts)
弹出的问题和选项以下:
问题的类型 "type": "checkbox"
是 checkbox
说明是多选。若是两个选项都进行选中的话,返回来的值为:
{ features: ['babel', 'router'] }
其中 features
是上面问题中的 name
属性。features
数组中的值则是每一个选项中的 value
。
Inquirer.js
还能够提供具备相关性的问题,也就是上一个问题选择了指定的选项,下一个问题才会显示出来。例以下面的代码:
{ name: 'Router', value: 'router', description: 'Structure the app with dynamic pages', link: 'https://router.vuejs.org/', }, { name: 'historyMode', when: answers => answers.features.includes('router'), type: 'confirm', message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`, description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`, link: 'https://router.vuejs.org/guide/essentials/history-mode.html', },
第二个问题中有一个属性 when
,它的值是一个函数 answers => answers.features.includes('router')
。当函数的执行结果为 true
,第二个问题才会显示出来。若是你在上一个问题中选择了 router
,它的结果就会变为 true
。弹出第二个问题:问你路由模式是否选择 history
模式。
大体了解 Inquirer.js
后,就能够明白这一步咱们要作什么了。主要就是将脚手架支持的功能配合对应的问题、可选值在控制台上展现出来,供用户选择。获取到用户具体的选项值后,再渲染模板和依赖。
先来看一下第一个版本支持哪些功能:
因为这是一个 vue 相关的脚手架,因此 vue 是默认提供的,不须要用户选择。另外构建工具 webpack 提供了开发环境和打包的功能,也是必需的,不用用户进行选择。因此可供用户选择的功能只有 4 个:
如今咱们先来看一下这 4 个功能对应的交互提示语相关的文件。它们所有放在 lib/promptModules
目录下:
-babel.js -linter.js -router.js -vuex.js
每一个文件包含了和它相关的全部交互式问题。例如刚才的示例,说明 router
相关的问题有两个。下面再看一下 babel.js
的代码:
module.exports = (api) => { api.injectFeature({ name: 'Babel', value: 'babel', short: 'Babel', description: 'Transpile modern JavaScript to older versions (for compatibility)', link: 'https://babeljs.io/', checked: true, }) }
只有一个问题,就是问下用户需不须要 babel
功能,默认为 checked: true
,也就是须要。
用户使用 create
命令后,脚手架须要将全部功能的交互提示语句聚合在一块儿:
// craete.js const creator = new Creator() // 获取各个模块的交互提示语 const promptModules = getPromptModules() const promptAPI = new PromptModuleAPI(creator) promptModules.forEach(m => m(promptAPI)) // 清空控制台 clearConsole() // 弹出交互提示语并获取用户的选择 const answers = await inquirer.prompt(creator.getFinalPrompts()) function getPromptModules() { return [ 'babel', 'router', 'vuex', 'linter', ].map(file => require(`./promptModules/${file}`)) } // Creator.js class Creator { constructor() { this.featurePrompt = { name: 'features', message: 'Check the features needed for your project:', pageSize: 10, type: 'checkbox', choices: [], } this.injectedPrompts = [] } getFinalPrompts() { this.injectedPrompts.forEach(prompt => { const originalWhen = prompt.when || (() => true) prompt.when = answers => originalWhen(answers) }) const prompts = [ this.featurePrompt, ...this.injectedPrompts, ] return prompts } } module.exports = Creator // PromptModuleAPI.js module.exports = class PromptModuleAPI { constructor(creator) { this.creator = creator } injectFeature(feature) { this.creator.featurePrompt.choices.push(feature) } injectPrompt(prompt) { this.creator.injectedPrompts.push(prompt) } }
以上代码的逻辑以下:
creator
对象getPromptModules()
获取全部功能的交互提示语PromptModuleAPI
将全部交互提示语注入到 creator
对象const answers = await inquirer.prompt(creator.getFinalPrompts())
在控制台弹出交互语句,并将用户选择结果赋值给 answers
变量。若是全部功能都选上,answers
的值为:
{ features: [ 'vue', 'webpack', 'babel', 'router', 'vuex', 'linter' ], // 项目具备的功能 historyMode: true, // 路由是否使用 history 模式 eslintConfig: 'airbnb', // esilnt 校验代码的默认规则,可被覆盖 lintOn: [ 'save' ] // 保存代码时进行校验 }
获取用户的选项后就该开始渲染模板和生成 package.json
文件了。先来看一下如何生成 package.json
文件:
// package.json 文件内容 const pkg = { name, version: '0.1.0', dependencies: {}, devDependencies: {}, }
先定义一个 pkg
变量来表示 package.json
文件,并设定一些默认值。
全部的项目模板都放在 lib/generator
目录下:
├─lib │ ├─generator # 各个功能的模板 │ │ ├─babel # babel 模板 │ │ ├─linter # eslint 模板 │ │ ├─router # vue-router 模板 │ │ ├─vue # vue 模板 │ │ ├─vuex # vuex 模板 │ │ └─webpack # webpack 模板
每一个模板的功能都差很少:
pkg
变量注入依赖项下面是 babel
相关的代码:
module.exports = (generator) => { generator.extendPackage({ babel: { presets: ['@babel/preset-env'], }, dependencies: { 'core-js': '^3.8.3', }, devDependencies: { '@babel/core': '^7.12.13', '@babel/preset-env': '^7.12.13', 'babel-loader': '^8.2.2', }, }) }
能够看到,模板调用 generator
对象的 extendPackage()
方法向 pkg
变量注入了 babel
相关的全部依赖。
extendPackage(fields) { const pkg = this.pkg for (const key in fields) { const value = fields[key] const existing = pkg[key] if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) { pkg[key] = Object.assign(existing || {}, value) } else { pkg[key] = value } } }
注入依赖的过程就是遍历全部用户已选择的模板,并调用 extendPackage()
注入依赖。
脚手架是怎么渲染模板的呢?用 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')
这里简单描述一下代码的注入过程:
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 配置代码是暴露出来的。这留待 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 版本的建立过程:
建立成功的项目截图:
第二个版本在 v1 的基础上添加了一些辅助功能:
建立项目时,先提早判断一下该项目是否存在:
const targetDir = path.join(process.cwd(), name) // 若是目标目录已存在,询问是覆盖仍是合并 if (fs.existsSync(targetDir)) { // 清空控制台 clearConsole() const { action } = await inquirer.prompt([ { name: 'action', type: 'list', message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`, choices: [ { name: 'Overwrite', value: 'overwrite' }, { name: 'Merge', value: 'merge' }, ], }, ]) if (action === 'overwrite') { console.log(`\nRemoving ${chalk.cyan(targetDir)}...`) await fs.remove(targetDir) } }
若是选择 overwrite
,则进行移除 fs.remove(targetDir)
。
先在代码中提早把默认配置的代码写好:
exports.defaultPreset = { features: ['babel', 'linter'], historyMode: false, eslintConfig: 'airbnb', lintOn: ['save'], }
这个配置默认使用 babel
和 eslint
。
而后生成交互提示语时,先调用 getDefaultPrompts()
方法获取默认配置。
getDefaultPrompts() { const presets = this.getPresets() const presetChoices = Object.entries(presets).map(([name, preset]) => { let displayName = name return { name: `${displayName} (${preset.features})`, value: name, } }) const presetPrompt = { name: 'preset', type: 'list', message: `Please pick a preset:`, choices: [ // 默认配置 ...presetChoices, // 这是手动模式提示语 { name: 'Manually select features', value: '__manual__', }, ], } const featurePrompt = { name: 'features', when: isManualMode, type: 'checkbox', message: 'Check the features needed for your project:', choices: [], pageSize: 10, } return { presetPrompt, featurePrompt, } }
这样配置后,在用户选择功能前会先弹出这样的提示语:
在 vue-cli
建立项目时,会生成一个 .vuerc
文件,里面会记录一些关于项目的配置信息。例如使用哪一个包管理器、npm 源是否使用淘宝源等等。为了不和 vue-cli
冲突,本脚手架生成的配置文件为 .mvcrc
。
这个 .mvcrc
文件保存在用户的 home
目录下(不一样操做系统目录不一样)。个人是 win10 操做系统,保存目录为 C:\Users\bin
。获取用户的 home
目录能够经过如下代码获取:
const os = require('os') os.homedir()
.mvcrc
文件还会保存用户建立项目的配置,这样当用户从新建立项目时,就能够直接选择之前建立过的配置,不用再一步步的选择项目功能。
在第一次建立项目时,.mvcrc
文件是不存在的。若是这时用户还安装了 yarn,脚手架就会提示用户要使用哪一个包管理器:
// 读取 `.mvcrc` 文件 const savedOptions = loadOptions() // 若是没有指定包管理器而且存在 yarn if (!savedOptions.packageManager && hasYarn) { const packageManagerChoices = [] if (hasYarn()) { packageManagerChoices.push({ name: 'Use Yarn', value: 'yarn', short: 'Yarn', }) } packageManagerChoices.push({ name: 'Use NPM', value: 'npm', short: 'NPM', }) otherPrompts.push({ name: 'packageManager', type: 'list', message: 'Pick the package manager to use when installing dependencies:', choices: packageManagerChoices, }) }
当用户选择 yarn 后,下载依赖的命令就会变为 yarn
;若是选择了 npm,下载命令则为 npm install
:
const PACKAGE_MANAGER_CONFIG = { npm: { install: ['install'], }, yarn: { install: [], }, } await executeCommand( this.bin, // 'yarn' or 'npm' [ ...PACKAGE_MANAGER_CONFIG[this.bin][command], ...(args || []), ], this.context, )
当用户选择了项目功能后,会先调用 shouldUseTaobao()
方法判断是否须要切换淘宝源:
const execa = require('execa') const chalk = require('chalk') const request = require('./request') const { hasYarn } = require('./env') const inquirer = require('inquirer') const registries = require('./registries') const { loadOptions, saveOptions } = require('./options') async function ping(registry) { await request.get(`${registry}/vue-cli-version-marker/latest`) return registry } function removeSlash(url) { return url.replace(/\/$/, '') } let checked let result module.exports = async function shouldUseTaobao(command) { if (!command) { command = hasYarn() ? 'yarn' : 'npm' } // ensure this only gets called once. if (checked) return result checked = true // previously saved preference const saved = loadOptions().useTaobaoRegistry if (typeof saved === 'boolean') { return (result = saved) } const save = val => { result = val saveOptions({ useTaobaoRegistry: val }) return val } let userCurrent try { userCurrent = (await execa(command, ['config', 'get', 'registry'])).stdout } catch (registryError) { try { // Yarn 2 uses `npmRegistryServer` instead of `registry` userCurrent = (await execa(command, ['config', 'get', 'npmRegistryServer'])).stdout } catch (npmRegistryServerError) { return save(false) } } const defaultRegistry = registries[command] if (removeSlash(userCurrent) !== removeSlash(defaultRegistry)) { // user has configured custom registry, respect that return save(false) } let faster try { faster = await Promise.race([ ping(defaultRegistry), ping(registries.taobao), ]) } catch (e) { return save(false) } if (faster !== registries.taobao) { // default is already faster return save(false) } if (process.env.VUE_CLI_API_MODE) { return save(true) } // ask and save preference const { useTaobaoRegistry } = await inquirer.prompt([ { name: 'useTaobaoRegistry', type: 'confirm', message: chalk.yellow( ` Your connection to the default ${command} registry seems to be slow.\n` + ` Use ${chalk.cyan(registries.taobao)} for faster installation?`, ), }, ]) // 注册淘宝源 if (useTaobaoRegistry) { await execa(command, ['config', 'set', 'registry', registries.taobao]) } return save(useTaobaoRegistry) }
上面代码的逻辑为:
.mvcrc
是否有 useTaobaoRegistry
选项。若是有,直接将结果返回,无需判断。get
请求,经过 Promise.race()
来调用。这样更快的那个请求会先返回,从而知道是默认源仍是淘宝源速度更快。await execa(command, ['config', 'set', 'registry', registries.taobao])
将当前 npm 的源改成淘宝源,即 npm config set registry https://registry.npm.taobao.org
。若是是 yarn,则命令为 yarn config set registry https://registry.npm.taobao.org
。其实 vue-cli
是没有这段代码的:
// 注册淘宝源 if (useTaobaoRegistry) { await execa(command, ['config', 'set', 'registry', registries.taobao]) }
这是我本身加的。主要是我没有在 vue-cli
中找到显式注册淘宝源的代码,它只是从配置文件读取出是否使用淘宝源,或者将是否使用淘宝源这个选项写入配置文件。另外 npm 的配置文件 .npmrc
是能够更改默认源的,若是在 .npmrc
文件直接写入淘宝的镜像地址,那 npm 就会使用淘宝源下载依赖。但 npm 确定不会去读取 .vuerc
的配置来决定是否使用淘宝源。
对于这一点我没搞明白,因此在用户选择了淘宝源以后,手动调用命令注册一遍。
若是用户建立项目时选择手动模式,在选择完一系列功能后,会弹出下面的提示语:
询问用户是否将此次的项目选择保存为默认配置,若是用户选择是,则弹出下一个提示语:
让用户输入保存配置的名称。
这两句提示语相关的代码为:
const otherPrompts = [ { name: 'save', when: isManualMode, type: 'confirm', message: 'Save this as a preset for future projects?', default: false, }, { name: 'saveName', when: answers => answers.save, type: 'input', message: 'Save preset as:', }, ]
保存配置的代码为:
exports.saveOptions = (toSave) => { const options = Object.assign(cloneDeep(exports.loadOptions()), toSave) for (const key in options) { if (!(key in exports.defaults)) { delete options[key] } } cachedOptions = options try { fs.writeFileSync(rcPath, JSON.stringify(options, null, 2)) return true } catch (e) { error( `Error saving preferences: ` + `make sure you have write access to ${rcPath}.\n` + `(${e.message})`, ) } } exports.savePreset = (name, preset) => { const presets = cloneDeep(exports.loadOptions().presets || {}) presets[name] = preset return exports.saveOptions({ presets }) }
以上代码直接将用户的配置保存到 .mvcrc
文件中。下面是我电脑上的 .mvcrc
的内容:
{ "packageManager": "npm", "presets": { "test": { "features": [ "babel", "linter" ], "eslintConfig": "airbnb", "lintOn": [ "save" ] }, "demo": { "features": [ "babel", "linter" ], "eslintConfig": "airbnb", "lintOn": [ "save" ] } }, "useTaobaoRegistry": true }
下次再建立项目时,脚手架就会先读取这个配置文件的内容,让用户决定是否使用已有的配置来建立项目。
至此,v2 版本的内容就介绍完了。
因为 vue-cli
关于插件的源码我尚未看完,因此这篇文章只讲解前两个版本的源码。v3 版本等我看完 vue-cli
的源码再回来填坑,预计在 3 月初就能够完成。
若是你想了解更多关于前端工程化的文章,能够看一下我写的《带你入门前端工程》。 这里是全文目录: