简单看看 React 脚手架中的 Webpack 做了什么?(1)

382 阅读6分钟

前言

React 项目的构建,是基于 Node.js 的。而 webpack 作为一个静态模块打包工具,在构建过程中发挥着至关重要的优化作用:模块化打包、性能优化、资源管理等。这大大简化了项目的构建流程,提高了开发效率和用户体验。

这次我们来看看 React 脚手架项目(通过 create-react-app 创建)中的 webpack 究竟做了哪些工作?

将脚手架中的 webpack 相关文件暴露出来后,我发现不管是配置文件还是脚本文件,里面都涉及到了 Node.js 的 API,像是 process, fs, path,出现的频率非常高。所以这篇文章,我们先了解一下 Node.js 相关的内容。

一、什么是 Node.js?

Node.js 是一个基于 V8 JavaScript 引擎 构建的 JavaScript 运行时。

这是官方对 Node.js 的描述。换句话说,Node.js 是一个可以运行 JavaScript 的环境。浏览器也是一个可以运行 JavaScript 的环境,这是我们平时接触得比较多的。

知道了什么是 Node.js,我们对 React 项目的构建过程就有了更深的理解:先是构建相关的代码在 Node.js 下运行,经过 webpack 的处理会输出一些部署项目所需的文件。然后 webpack 会在本地起一个服务,并将这些文件部署在本地的服务器上,接着我们就可以在浏览器中运行项目了,项目中的代码则是运行在浏览器环境下的。

二、Node.js 相关的 API

简单了解一下构建命令和 webpack 配置中涉及到的 Node.js 的 API。

process

process 对象提供有关当前 Node.js 进程的信息并对其进行控制。

process.argv

process.argv 返回一个数组。

其中第1个元素是 process.execPath,表示 Node.js 进程的可执行文件的绝对路径名,可执行文件即 node.exe,也就是安装 Node.js 的绝对路径。

第2个元素是正在执行的 js 文件的绝对路径。如果 js 文件是被其它文件引用的,那么返回的是主动引用的 js 文件。

其余的元素则是命令行中携带的参数。例:

    node start.js name=Toby age sex=1

携带的参数为:"name=Toby", "age", "sex=1"

process.cwd()

Node.js 进程的当前工作目录,即当前项目的根目录。

process.env

返回包含用户环境的对象,包括一些进程相关的信息和系统环境变量。

但在代码中我们通常很少用到这些环境变量,更多的是将其当作一个全局的对象来保存变量。

例如:

    process.env.BABEL_ENV = 'development';
    process.env.NODE_ENV = 'development';

这里定义了两个环境变量,并将其设置为开发环境。若是开发环境则设置为 production

除了在代码中显式地定义和设置变量,还需要将其注入到程序中,这样在业务代码中才能获取使用。

可以使用 webpack 的 DefinePlugin 插件进行注入。

也可以将变量以键值对的形式写在 .env 文件中,通过 dotenv 库读取文件内容,再通过 dotenv-expand 库将其添加到 process.env 对象中,再进行注入。

path

path 模块提供了用于处理文件和目录的实用工具。

path.basename(string path, string extension)

返回文件路径的最后一部分,即文件名的部分。可选参数 extension,省略对应的后缀名,区分大小写。

path.dirname(string path)

basename 方法相对应,返回文件(夹)所在目录的路径。

path.isAbsolute(string path)

判断路径是否为绝对路径

path.join(string ...paths)

接收多个路径片段,用特定于平台的分隔符作为定界符将其连接在一起,并规范化生成的路径。

path.normalize(string path)

规范化给定的路径。

path.format(Object pathObj) 和 path.parse(string path)

两个相互对应的方法。前者将路径对象转换为路径,后者将路径转换为路径对象

对象属性示例如下:

    {
        root: '/',
        dir: '/home/user/dir',
        base: 'file.txt',
        ext: '.txt',
        name: 'file'
    }

path.relative(string from, string to)

解析为从 from 到 to 的相对路径。若 from 和 to 相同,则返回空字符串。

path.resolve(string ...paths)

将路径或路径片段的序列解析为绝对路径。

接收若干个路径片段,从右往左进行拼接,直至生成一个绝对路径。若未能生成绝对路径,则使用当前工作目录的路径。

未传入路径,则返回当前工作目录的绝对路径

补充

关于路径,再补充几点:

. 表示当前目录

.. 表示父级目录

/ 表示文件系统的根目录

fs

文件系统模块。

fs.existsSync(string path)

判断路径是否存在。

fs.realpathSync(string path)

通过解析 ., .. 和符号链接异步地计算规范路径名。

解析一个路径的真实路径(绝对路径)

补充

在 Node.js 中,当前工作目录不一定是当前文件所在的目录,而是在执行脚本时操作系统指定的默认目录(通常是启动脚本所在的目录)。

运行 start 脚本时,默认的当前工作目录是项目的根目录

三、两个重要的 js 文件

在正式介绍 webpack 相关配置之前,我们先来了解两个在相关代码中不可或缺的两个 js 文件:paths.jsenv.js

paths.js

paths.js 将项目中关键的文件和目录路径整合起来,方便调用。

    module.exports = {
      dotenv: resolveApp('.env'),
      appPath: resolveApp('.'),
      appBuild: resolveApp(buildPath),
      appPublic: resolveApp('public'),
      appHtml: resolveApp('public/index.html'),
      appIndexJs: resolveModule(resolveApp, 'src/index'),
      appPackageJson: resolveApp('package.json'),
      appSrc: resolveApp('src'),
      appTsConfig: resolveApp('tsconfig.json'),
      appJsConfig: resolveApp('jsconfig.json'),
      yarnLockFile: resolveApp('yarn.lock'),
      testsSetup: resolveModule(resolveApp, 'src/setupTests'),
      proxySetup: resolveApp('src/setupProxy.js'),
      appNodeModules: resolveApp('node_modules'),
      appWebpackCache: resolveApp('node_modules/.cache'),
      appTsBuildInfoFile: resolveApp('node_modules/.cache/tsconfig.tsbuildinfo'),
      swSrc: resolveModule(resolveApp, 'src/service-worker'),
      publicUrlOrPath,
    };

resolveApp 方法

    const appDirectory = fs.realpathSync(process.cwd());
    const resolveApp = relativePath => path.resolve(appDirectory, relativePath);

获取当前项目根目录的绝对路径,将传入的文件或目录的相对路径拼接到根目录上,返回一个绝对路径。

resolveModule 方法

    const moduleFileExtensions = [
      'web.mjs',
      'mjs',
      'web.js',
      'js',
      'web.ts',
      'ts',
      'web.tsx',
      'tsx',
      'json',
      'web.jsx',
      'jsx',
    ];

    const resolveModule = (resolveFn, filePath) => {
      const extension = moduleFileExtensions.find(extension =>
        fs.existsSync(resolveFn(`${filePath}.${extension}`))
      );

      if (extension) {
        return resolveFn(`${filePath}.${extension}`);
      }

      return resolveFn(`${filePath}.js`);
    };

由于部分模块文件的后缀名不确定,可能为 js, ts 等,因此对各个后缀名进行遍历,确定真实的后缀名后调用 resolveApp 方法返回其绝对路径。

getPublicUrlOrPath 方法

    const publicUrlOrPath = getPublicUrlOrPath(
      process.env.NODE_ENV === 'development',
      require(resolveApp('package.json')).homepage,
      process.env.PUBLIC_URL
    );

除此之外,还需要设置 public path,当浏览器加载页面的静态资源时,便是根据 public path 去服务器上寻找对应的资源进行加载的。

在这里,从 react-dev-utils 库引入了 getPublicUrlOrPath 方法并传入3个参数:判断是否为开发环境;读取 package.json 中的 homepage 字段;读取环境变量中的 PUBLIC_URL

接下来,看看 getPublicUrlOrPath 方法具体做了什么。

      const stubDomain = 'https://create-react-app.dev';
      
      if (homepage) {
        homepage = homepage.endsWith('/') ? homepage : homepage + '/';

        const validHomepagePathname = new URL(homepage, stubDomain).pathname;
        
        return isEnvDevelopment
          ? homepage.startsWith('.')
            ? '/'
            : validHomepagePathname
          :
          homepage.startsWith('.')
          ? homepage
          : validHomepagePathname;
      }

首先,补全路径末尾的斜杠,保证后续拼接路径正确。

接着,创建一个 URL 对象,取其有效路径的部分(网址中主机名之后的部分,且不包括查询参数和哈希部分)。stubDomain 的作用是保证能够成功创建 URL,并且不影响到有效路径的部分。

经过这一步处理,将会得到一个正确的绝对路径

最后,根据项目环境的不同以及 homepage 的值返回对应的 public path

在目前的项目环境下,homepage 的值为 .,最终得到的 public path/,即为项目根目录。

这是关于 homepage 的处理,关于 PUBLIC_URL 的处理逻辑也大同小异的,就不再赘述了。

env.js

env.js 处理了进程的环境变量 process.env,为后续注入进程序做准备。

    const dotenvFiles = [
      `${paths.dotenv}.${NODE_ENV}.local`,
      NODE_ENV !== 'test' && `${paths.dotenv}.local`,
      `${paths.dotenv}.${NODE_ENV}`,
      paths.dotenv,
    ].filter(Boolean);

    dotenvFiles.forEach(dotenvFile => {
      if (fs.existsSync(dotenvFile)) {
        require('dotenv-expand')(
          require('dotenv').config({
            path: dotenvFile,
          })
        );
      }
    });

首先,构建一个环境变量配置文件路径的数组。这里面包括最常见的 .env 文件,还有与 NODE_ENV 的值相关的配置文件,看起来是动态选取配置文件的逻辑。

接下来,借助 dotenvdotenv-expand 库,将配置文件中的变量注入Node.js 进程的环境变量中去。

    const appDirectory = fs.realpathSync(process.cwd());
    process.env.NODE_PATH = (process.env.NODE_PATH || '')
      .split(path.delimiter)
      .filter(folder => folder && !path.isAbsolute(folder))
      .map(folder => path.resolve(appDirectory, folder))
      .join(path.delimiter);

这一段代码的作用是将环境变量 NODE_PATH 中的相对路径都转换为绝对路径

关于 NODE_PATH 的作用,是用于注册模块所提供的路径的环境变量。举个例子:

比如说,在项目中引入一个模块时,会先寻找项目根目录的 node_modules 目录,再寻找文件系统根目录的 node_modules,最终会去到 NODE_PATH 中注册的路径中寻找。

    const REACT_APP = /^REACT_APP_/i;
    function getClientEnvironment(publicUrl) {
      const raw = Object.keys(process.env)
        .filter(key => REACT_APP.test(key))
        .reduce(
          (env, key) => {
            env[key] = process.env[key];
            return env;
          },
          {
            NODE_ENV: process.env.NODE_ENV || 'development',
            PUBLIC_URL: publicUrl,
            WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST,
            WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH,
            WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT,
            FAST_REFRESH: process.env.FAST_REFRESH !== 'false',
          }
        );

      const stringified = {
        'process.env': Object.keys(raw).reduce((env, key) => {
          env[key] = JSON.stringify(raw[key]);
          return env;
        }, {}),
      };

      return { raw, stringified };
    }

最后,暴露出一个 getClientEnvironment 方法。

这个方法将 process.env 中以 REACT_APP_ 开头的环境变量筛选出来,并添加了几个例如 NODE_ENV, PUBLIC_PATH 的环境变量整合在一起。另外将上述环境变量对象转换为字符串形式,便于通过 DefinePlugin 注入到程序之中。

总结

在这篇文章中,我们了解了 Node.js 的基本概念和常用 API,以及两个在 webpack 配置中将会用到的 js 文件paths.jsenv.js

下一篇文章,将介绍 webpack 的配置代码以及项目构建时 webpack 是如何进行工作的。

下一篇文章指路:简单看看 React 脚手架中的 Webpack 做了什么?(2)