原文地址Nealyang/personalBlog

前言

对于前端工程构建,很多公司、BU 都有自己的一套构建体系,比如我们正在使用的 def,或者 vue-cli 或者 create-react-app,由于笔者最近一直想搭建一个个人网站,秉持着呼吸不停,折腾不止的原则,编码的过程中,还是不想太过于枯燥。在 coding 之前,搭建自己的项目架构的时候,突然想,为什么之前搭建过很多的项目架构不能直接拿来用,却还是要从 0 到 1 的去写 webpack 去下载相关配置呢?遂!学习下 create-react-app 源码,然后自己搞一套吧~

create-react-app 源码

代码的入口在 packages/create-react-app/index.js下,核心代码在createReactApp.js中,虽然有大概 900+行代码,但是删除注释和一些友好提示啥的大概核心代码也就六百多行吧,我们直接来看

index.js

index.js 的代码非常的简单,其实就是对 node 的版本做了一下校验,如果版本号低于 8,就退出应用程序,否则直接进入到核心文件中,createReactApp.js

createReactApp.js

createReactApp 的功能也非常简单其实,大概流程:

  • 命令初始化,比如自定义create-react-app --info 的输出等
  • 判断是否输入项目名称,如果有,则根据参数去跑安装,如果没有,给提示,然后退出程序
  • 修改 package.json
  • 拷贝 react-script 下的模板文件

准备工作:配置 vscode 的 debug 文件

        {            "type": "node",            "request": "launch",            "name": "CreateReactApp",            "program": "${workspaceFolder}/packages/create-react-app/index.js",            "args": [                "study-create-react-app-source"            ]        },        {            "type": "node",            "request": "launch",            "name": "CreateReactAppNoArgs",            "program": "${workspaceFolder}/packages/create-react-app/index.js"        },        {            "type": "node",            "request": "launch",            "name": "CreateReactAppTs",            "program": "${workspaceFolder}/packages/create-react-app/index.js",            "args": [                "study-create-react-app-source-ts --typescript"            ]        }

这里我们添加三种环境,其实就是 create-react-app 的不同种使用方式

  • create-react-app study-create-react-app-source
  • create-react-app
  • create-react-app study-create-react-app-source-ts --typescript

commander 命令行处理程序

commander 文档传送门

let projectName;const program = new commander.Command(packageJson.name)  .version(packageJson.version)//create-react-app -v 时候输出的值 packageJson 来自上面 const packageJson = require('./package.json');  .arguments('<project-directory>') //定义 project-directory ,必填项  .usage(`${chalk.green('<project-directory>')} [options]`)  .action(name => {    projectName = name;//获取用户的输入,存为 projectName  })  .option('--verbose', 'print additional logs')  .option('--info', 'print environment debug info')  .option(    '--scripts-version <alternative-package>',    'use a non-standard version of react-scripts'  )  .option('--use-npm')  .option('--use-pnp')  .option('--typescript')  .allowUnknownOption()  .on('--help', () => {// on('option', cb) 语法,输入 create-react-app --help 自动执行后面的操作输出帮助    console.log(`    Only ${chalk.green('<project-directory>')} is required.`);    console.log();    console.log(      `    A custom ${chalk.cyan('--scripts-version')} can be one of:`    );    console.log(`      - a specific npm version: ${chalk.green('0.8.2')}`);    console.log(`      - a specific npm tag: ${chalk.green('@next')}`);    console.log(      `      - a custom fork published on npm: ${chalk.green(        'my-react-scripts'      )}`    );    console.log(      `      - a local path relative to the current working directory: ${chalk.green(        'file:../my-react-scripts'      )}`    );    console.log(      `      - a .tgz archive: ${chalk.green(        'https://mysite.com/my-react-scripts-0.8.2.tgz'      )}`    );    console.log(      `      - a .tar.gz archive: ${chalk.green(        'https://mysite.com/my-react-scripts-0.8.2.tar.gz'      )}`    );    console.log(      `    It is not needed unless you specifically want to use a fork.`    );    console.log();    console.log(      `    If you have any problems, do not hesitate to file an issue:`    );    console.log(      `      ${chalk.cyan(        'https://github.com/facebook/create-react-app/issues/new'      )}`    );    console.log();  })  .parse(process.argv);

关于 commander 的使用,这里就不介绍了,对于 create-react-app 的流程我们需要知道的是,它,初始化了一些 create-react-app 的命令行环境,这一波操作后,我们可以看到 program 张这个样纸:

接着往下走

当我们 debug 启动 noArgs 环境的时候,走到这里就结束了,判断 projectName 是否为 undefined,然后输出相关提示信息,退出~

createApp

在查看 createApp function 之前,我们再回头看下命令行的一些参数定义,方便我们理解 createApp 的一些参数

我们使用

        {            "type": "node",            "request": "launch",            "name": "CreateReactAppTs",            "program": "${workspaceFolder}/packages/create-react-app/index.js",            "args": [                "study-create-react-app-source-ts",                "--typescript",                "--use-npm"            ]        }

debugger 我们项目的时候,就可以看到,program.typescripttrueuseNpmtrue,当然,这些也都是我们在commander中定义的 options,所以源码里面 createApp 中,我们传入的参数分别为:

  • projectName : 项目名称
  • program.verbose 是否输出额外信息
  • program.scriptsVersion 传入的脚本版本
  • program.useNpm 是否使用 npm
  • program.usePnp 是否使用 Pnp
  • program.typescript 是否使用 ts
  • hiddenProgram.internalTestingTemplate 给开发者用的调试模板路径
function createApp(  name,  verbose,  version,  useNpm,  usePnp,  useTypescript,  template) {  const root = path.resolve(name);//path 拼接路径  const appName = path.basename(root);//获取文件名  checkAppName(appName);//检查传入的文件名合法性  fs.ensureDirSync(name);//确保目录存在,如果不存在则创建一个  if (!isSafeToCreateProjectIn(root, name)) { //判断新建这个文件夹是否安全,否则直接退出    process.exit(1);  }  console.log(`Creating a new React app in ${chalk.green(root)}.`);  console.log();  const packageJson = {    name: appName,    version: '0.1.0',    private: true,  };  fs.writeFileSync(    path.join(root, 'package.json'),    JSON.stringify(packageJson, null, 2) + os.EOL  );//写入 package.json 文件  const useYarn = useNpm ? false : shouldUseYarn();//判断是使用 yarn 呢还是 npm  const originalDirectory = process.cwd();  process.chdir(root);  if (!useYarn && !checkThatNpmCanReadCwd()) {//如果是使用npm,检测npm是否在正确目录下执行    process.exit(1);  }  if (!semver.satisfies(process.version, '>=8.10.0')) {//判断node环境,输出一些提示信息, 并采用旧版本的 react-scripts    console.log(      chalk.yellow(        `You are using Node ${          process.version        } so the project will be bootstrapped with an old unsupported version of tools.\n\n` +          `Please update to Node 8.10 or higher for a better, fully supported experience.\n`      )    );    // Fall back to latest supported react-scripts on Node 4    version = 'react-scripts@0.9.x';  }  if (!useYarn) {//关于 npm、pnp、yarn 的使用判断,版本校验等    const npmInfo = checkNpmVersion();    if (!npmInfo.hasMinNpm) {      if (npmInfo.npmVersion) {        console.log(          chalk.yellow(            `You are using npm ${              npmInfo.npmVersion            } so the project will be bootstrapped with an old unsupported version of tools.\n\n` +              `Please update to npm 5 or higher for a better, fully supported experience.\n`          )        );      }      // Fall back to latest supported react-scripts for npm 3      version = 'react-scripts@0.9.x';    }  } else if (usePnp) {    const yarnInfo = checkYarnVersion();    if (!yarnInfo.hasMinYarnPnp) {      if (yarnInfo.yarnVersion) {        console.log(          chalk.yellow(            `You are using Yarn ${              yarnInfo.yarnVersion            } together with the --use-pnp flag, but Plug'n'Play is only supported starting from the 1.12 release.\n\n` +              `Please update to Yarn 1.12 or higher for a better, fully supported experience.\n`          )        );      }      // 1.11 had an issue with webpack-dev-middleware, so better not use PnP with it (never reached stable, but still)      usePnp = false;    }  }  if (useYarn) {    let yarnUsesDefaultRegistry = true;    try {      yarnUsesDefaultRegistry =        execSync('yarnpkg config get registry')          .toString()          .trim() === 'https://registry.yarnpkg.com';    } catch (e) {      // ignore    }    if (yarnUsesDefaultRegistry) {      fs.copySync(        require.resolve('./yarn.lock.cached'),        path.join(root, 'yarn.lock')      );    }  }  run(    root,    appName,    version,    verbose,    originalDirectory,    template,    useYarn,    usePnp,    useTypescript  );} 

代码非常简单,部分注释已经加载代码中,简单的说就是对一个本地环境的一些校验,版本检查啊、目录创建啊啥的,如果创建失败,则退出,如果版本较低,则使用对应低版本的create-react-app,最后调用 run 方法

checkAppName

这些工具方法,其实在写我们自己的构建工具的时候,也可以直接 copy 的哈,所以这里我们也是简单看下里面的实现,

checkAPPName 方法主要的核心代码是validate-npm-package-name package,从名字即可看出,检查是否为合法的 npm 包名

var done = function (warnings, errors) {  var result = {    validForNewPackages: errors.length === 0 && warnings.length === 0,    validForOldPackages: errors.length === 0,    warnings: warnings,    errors: errors  }  if (!result.warnings.length) delete result.warnings  if (!result.errors.length) delete result.errors  return result}......var validate = module.exports = function (name) {  var warnings = []  var errors = []  if (name === null) {    errors.push('name 不能使 null')    return done(warnings, errors)  }  if (name === undefined) {    errors.push('name 不能是 undefined')    return done(warnings, errors)  }  if (typeof name !== 'string') {    errors.push('name 必须是 string 类型')    return done(warnings, errors)  }  if (!name.length) {    errors.push('name 的长度必须大于 0')  }  if (name.match(/^\./)) {    errors.push('name 不能以点开头')  }  if (name.match(/^_/)) {    errors.push('name 不能以下划线开头')  }  if (name.trim() !== name) {    errors.push('name 不能包含前空格和尾空格')  }  // No funny business  // var blacklist = [  //   'node_modules',  //   'favicon.ico'  // ]  blacklist.forEach(function (blacklistedName) {    if (name.toLowerCase() === blacklistedName) { //不能是“黑名单”内的      errors.push(blacklistedName + ' is a blacklisted name')    }  })  // Generate warnings for stuff that used to be allowed  // 为以前允许的内容生成警告 // 后面的就不再赘述了  return done(warnings, errors)}

最终,checkAPPName返回的东西如截图所示,后面写代码可以直接拿来借鉴!借鉴~

isSafeToCreateProjectIn

所谓安全性校验,其实就是检查当前目录下是否存在已有文件。

checkNpmVersion

后面的代码也都比较简单,这里就不展开说了,版本比较实用的是一个semver package.

run

代码跑到这里,该检查的都检查了,鸡也不叫了、狗也不咬了,该干点正事了~

run 主要做的事情就是安装依赖、拷贝模板。

getInstallPackage做的事情非常简单,根据传入的 version 和原始路径 originalDirectory 去获取要安装的 package 列表,默认情况下version 为 undefined,获取到的 packageToInstall 为react-scripts,也就是我们如上图的 resolve 回调。

最终,我们拿到需要安装的 info 为

{  isOnline:true,  packageName:"react-scripts"}

当我们梳理好需要安装的 package 后,就交给 npm 或者 yarn 去安装我们的依赖即可

spawn执行完命令后会有一个回调,判断code是否为 0,然后 resolve Promise,

 .then(async packageName => {         // 安装完 react, react-dom, react-scripts 之后检查当前环境运行的node版本是否符合要求        checkNodeVersion(packageName);        // 检查 package.json 中的版本号        setCaretRangeForRuntimeDeps(packageName);        const pnpPath = path.resolve(process.cwd(), '.pnp.js');        const nodeArgs = fs.existsSync(pnpPath) ? ['--require', pnpPath] : [];        await executeNodeScript(          {            cwd: process.cwd(),            args: nodeArgs,          },          [root, appName, verbose, originalDirectory, template],          `        var init = require('${packageName}/scripts/init.js');        init.apply(null, JSON.parse(process.argv[1]));      `        );

create-react-app之前的版本中,这里是通过调用react-script下的 init方法来执行后续动作的。这里通过调用executeNodeScript 方法

function executeNodeScript({ cwd, args }, data, source) {  // cwd:"/Users/nealyang/Desktop/create-react-app/study-create-react-app-source"  // data:  // 0:"/Users/nealyang/Desktop/create-react-app/study-create-react-app-source"  // 1:"study-create-react-app-source"  // 2:undefined  // 3:"/Users/nealyang/Desktop/create-react-app"  // 4:undefined  // source  // "  var init = require('react-scripts/scripts/init.js');  //   init.apply(null, JSON.parse(process.argv[1]));  // "    return new Promise((resolve, reject) => {    const child = spawn(      process.execPath,      [...args, '-e', source, '--', JSON.stringify(data)],      { cwd, stdio: 'inherit' }    );    child.on('close', code => {      if (code !== 0) {        reject({          command: `node ${args.join(' ')}`,        });        return;      }      resolve();    });  });}

executeNodeScript 方法主要是通过 spawn 来通过 node命令执行react-script下的 init 方法。所以截止当前,create-react-app完成了他的工作: npm i ,

react-script/init.js

修改 vscode 的 debugger 配置,然后我们来 debugger react-script 下的 init 方法

function init(appPath, appName, verbose, originalDirectory, template) {  // 获取当前包中包含 package.json 所在的文件夹路径  const ownPath = path.dirname(    //"/Users/nealyang/Desktop/create-react-app/packages/react-scripts"    require.resolve(path.join(__dirname, '..', 'package.json'))  );  const appPackage = require(path.join(appPath, 'package.json')); //项目目录下的 package.json  const useYarn = fs.existsSync(path.join(appPath, 'yarn.lock')); //通过判断目录下是否有 yarn.lock 来判断是否使用 yarn  // Copy over some of the devDependencies  appPackage.dependencies = appPackage.dependencies || {};  //   react:"16.8.6"  // react-dom:"16.8.6"  // react-scripts:"3.0.1"  const useTypeScript = appPackage.dependencies['typescript'] != null;  // Setup the script rules 设置 script 命令  appPackage.scripts = {    start: 'react-scripts start',    build: 'react-scripts build',    test: 'react-scripts test',    eject: 'react-scripts eject',  };  // Setup the eslint config 这是 eslint 的配置  appPackage.eslintConfig = {    extends: 'react-app',  };  // Setup the browsers list 这是浏览器 openBrowser  appPackage.browserslist = defaultBrowsers;  // 写入我们需要创建的目录下的 package.json 中  fs.writeFileSync(    path.join(appPath, 'package.json'),    JSON.stringify(appPackage, null, 2) + os.EOL  );  const readmeExists = fs.existsSync(path.join(appPath, 'README.md'));  if (readmeExists) {    fs.renameSync(      path.join(appPath, 'README.md'),      path.join(appPath, 'README.old.md')    );  }  // Copy the files for the user  获取模板的路径  const templatePath = template //"/Users/nealyang/Desktop/create-react-app/packages/react-scripts/template"    ? path.resolve(originalDirectory, template)    : path.join(ownPath, useTypeScript ? 'template-typescript' : 'template');  if (fs.existsSync(templatePath)) {    // 这一步就过分了, 直接 copy!  appPath:"/Users/nealyang/Desktop/create-react-app/study-create-react-app-source"    fs.copySync(templatePath, appPath);  } else {    console.error(      `Could not locate supplied template: ${chalk.green(templatePath)}`    );    return;  }  // Rename gitignore after the fact to prevent npm from renaming it to .npmignore 重命名gitignore以防止npm将其重命名为.npmignore  // See: https://github.com/npm/npm/issues/1862  try {    fs.moveSync(      path.join(appPath, 'gitignore'),      path.join(appPath, '.gitignore'),      []    );  } catch (err) {    // Append if there's already a `.gitignore` file there    if (err.code === 'EEXIST') {      const data = fs.readFileSync(path.join(appPath, 'gitignore'));      fs.appendFileSync(path.join(appPath, '.gitignore'), data);      fs.unlinkSync(path.join(appPath, 'gitignore'));    } else {      throw err;    }  }  let command;  let args;  if (useYarn) {    command = 'yarnpkg';    args = ['add'];  } else {    command = 'npm';    args = ['install', '--save', verbose && '--verbose'].filter(e => e);  }  args.push('react', 'react-dom');  // args Array  // 0:"install"  // 1:"--save"  // 2:"react"  // 3:"react-dom"  // 安装其他模板依赖项(如果存在)  const templateDependenciesPath = path.join(//"/Users/nealyang/Desktop/create-react-app/study-create-react-app-source/.template.dependencies.json"    appPath,    '.template.dependencies.json'  );  if (fs.existsSync(templateDependenciesPath)) {    const templateDependencies = require(templateDependenciesPath).dependencies;    args = args.concat(      Object.keys(templateDependencies).map(key => {        return `${key}@${templateDependencies[key]}`;      })    );    fs.unlinkSync(templateDependenciesPath);  }  // 安装react和react-dom以便与旧CRA cli向后兼容  // 没有安装react和react-dom以及react-scripts  // 或模板是presetend(通过--internal-testing-template)  if (!isReactInstalled(appPackage) || template) {    console.log(`Installing react and react-dom using ${command}...`);    console.log();    const proc = spawn.sync(command, args, { stdio: 'inherit' });    if (proc.status !== 0) {      console.error(`\`${command} ${args.join(' ')}\` failed`);      return;    }  }  if (useTypeScript) {    verifyTypeScriptSetup();  }  if (tryGitInit(appPath)) {    console.log();    console.log('Initialized a git repository.');  }  // 显示最优雅的cd方式。  // 这需要处理未定义的originalDirectory  // 向后兼容旧的global-cli。  let cdpath;  if (originalDirectory && path.join(originalDirectory, appName) === appPath) {    cdpath = appName;  } else {    cdpath = appPath;  }  // Change displayed command to yarn instead of yarnpkg  const displayedCommand = useYarn ? 'yarn' : 'npm';  console.log('xxxx....xxxxx');}

初始化方法主要做的事情就是修改目标路径下的 package.json,添加一些配置命令,然后 copy!react-script 下的模板到目标路径下。

走到这一步,我们的项目基本已经初始化完成了。

所以我们 copy 了这么多 scripts

    start: 'react-scripts start',    build: 'react-scripts build',    test: 'react-scripts test',    eject: 'react-scripts eject',

究竟是如何工作的呢,其实也不难,就是一些开发、测试、生产的环境配置。鉴于篇幅,咱就下一篇来分享下大佬们的前端构建的代码写法吧~~

总结

本来想用一张流程图解释下,但是。。。create-react-app 着实没有做啥!咱还是等下一篇分析完,自己写构建脚本的时候再画一下整体流程图(架构图)吧~

ok~ 简单概述下:

  • 判断 node 版本,如果大版本小于 8 ,则直接退出(截止目前是 8)
  • createReactApp.js 初始化一些命令参数,然后再去判断是否传入了 packageName,否则直接退出
  • 各种版本的判断,然后通过cross-spawn来用命令行执行所有的安装
  • 当所有的依赖安装完后,依旧通过命令行,初始化 node 环境,来执行 react-script 下的初始化方法:修改 package.json 中的一些配置、以及 copy 模板文件
  • 处理完成,给出用户友好提示

通篇看完 package 的职能后,发现,哇,这有点简答啊~~其实,我们学习源码的其实就是为了学习大佬们的一些边界情况处理,在后面自己开发的时候再去 copy~ 借鉴一些判断方法的编写。后面会再简单分析下react-scripts,然后写一个自己的一些项目架构脚本~