Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create-react-app 源码学习(上) #36

Open
Nealyang opened this issue Jun 18, 2019 · 5 comments
Open

create-react-app 源码学习(上) #36

Nealyang opened this issue Jun 18, 2019 · 5 comments

Comments

@Nealyang
Copy link
Owner

Nealyang commented Jun 18, 2019

前言

对于前端工程构建,很多公司、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

img

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 张这个样纸:

img

接着往下走

img

当我们 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)
}

img

最终,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 组件autoprefixer、bable-preset-env、eslint-plugin-compat、postcss-normalize共享使用的配置项 (感谢网友指正)
  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,然后写一个自己的一些项目架构脚本~

@boycgit
Copy link

boycgit commented Jun 28, 2019

思路清晰,阅读起来荡气回肠

@Nealyang
Copy link
Owner Author

Nealyang commented Jul 2, 2019

思路清晰,阅读起来荡气回肠

师兄又过奖了~ 😯

@kumbaya123
Copy link

老哥,下篇呢

@Nealyang
Copy link
Owner Author

老哥,下篇呢
这个吧,cra 里面都是工具函数,感觉介绍不如把工具函数拿来用。所以就自己写了个 cli。#72

@Nealyang
Copy link
Owner Author

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants