使用Webpack等搭建一个适用于React项目的脚手架(1 - React、TypeScript)

这个脚手架适用以下这种项目:

  1. 使用 React 作为构建用户界面的库。
  2. 使用 TypeScript 进行类型检查。
  3. 使用 React Router 进行路由管理。
  4. 使用 Redux 进行状态管理。
  5. 使用 Sass 编写样式。
  6. 使用 Eslint 保持代码风格的一致。
  7. 使用 Jest 等进行单元测试。

打包代码的工具使用了 Webpack ,并且用 Bable 将JavaScript编译为浏览器兼容的版本。

以下文章:

《使用Webpack等搭建一个适用于React项目的脚手架(1 - React、TypeScript)》

《使用Webpack等搭建一个适用于React项目的脚手架(2 - React Router、Redux、Sass)》

《使用Webpack等搭建一个适用于React项目的脚手架(3 - Eslint、Jest)》

《使用Webpack等搭建一个适用于React项目的脚手架(4 - 优化)》

记录使用Webpack等搭建一个适用于React项目的开发环境。

《使用Webpack等搭建一个适用于React项目的脚手架(5 - 脚手架)》中记录搭建一个脚手架,脚手架的功能是使用指令获取前几篇文章中写好的代码,创建一个项目。

这几篇文章一步一步记录搭建过程,代码中还有不足之处,后续工作之余再慢慢完善。

初始化

创建一个文件夹并进入文件目录下:

mkdir simple-scaffold && cd simple-scaffold
复制代码

初始化项目:

npm init -y
复制代码

-y 的意思是初始化项目的过程中所有的选项选择默认项,执行完命令后文件目录下多了一个package.json文件,这个文件目前是这样的(以下注释只是说明用,json文件中不存在):

{
  "name": "simple-scaffold", // 项目名称,默认取所在文件夹的名称
  "version": "1.0.0", // 版本号,发布包的时候会用到
  "description": "", // 描述,在npm搜索中会用到
  "main": "index.js", // 程序的主入口,假如发布了包用户又require了这个包,那么require返回的内容就是index.js中导出的内容
  "scripts": { // scripts中的内容是配置的脚本命令
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [], // 程序的关键字,在npm搜索中会用到
  "author": "",
  "license": "ISC"
}
复制代码

创建一个src文件夹,并在src文件夹下创建index.js和index.html文件:

mkdir src && touch src/index.js src/index.html
复制代码

index.js 的文件内容如下:

window.onload = function () {
  var root = document.getElementById('root');
  var content = document.createElement('h1');
  content.textContent = '使用Webpack等搭建一个适用于React项目的脚手架';
  root.appendChild(content);
}
复制代码

Index.html的文件内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>simple-scaffold</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
复制代码

Webpack配置

在项目根目录执行以下语句,安装好 webpackwebpack-cli

npm i --save-dev webpack webpack-cli
复制代码

创建一个文件夹并进入文件目录下:

mkdir config && cd config
复制代码

在config文件夹下创建三个文件,分别用于放置通用的webpack配置,开发环境的webpack配置以及生成环境的webpack配置:

touch webpack.common.js webpack.dev.js webpack.prod.js
复制代码

打包html、js文件

安装 html-loaderhtml-webpack-pluginclean-webpack-plugin

npm i --save-dev html-webpack-plugin html-loader clean-webpack-plugin
复制代码

webpack.common.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'none',
  entry: {
    app: path.resolve(__dirname, '../src/index.js'),
  },
  output: {
    filename: '[name].[hash].js',
    path: path.resolve(__dirname, '../dist'),
    publicPath: './',
  },
  module: {
    rules: [
      {
        test: /\.html$/,
        exclude: /[\\/]node_modules[\\/]/,
        loader: 'html-loader',
      }
    ]
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'simple-scaffold',
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html',
    }),
  ],
}
复制代码

mode 指webpack打包的模式,webpack会根据不同的配置模式进行相应的优化。代码中的 mode: 'none' 表示不使用任何优化。

entry 指定打包的入口,webpack会从entry指定的文件开始,生成一个依赖关系图。当entry以对象的方式定义的时候,键值就是输出文件的name。

output 中filename定义了输出的文件名,上述代码 [name].[hash].js 中的hash是模块标志符的hash值。publicPath指定生成的文件的公共路径,比如以上代码生成的html文件中,引入的js的路径为 src="./app.17934a47c82529729b11.js" 。如果把 publicPath: './' 改为 publicPath: '/test/' ,那么生成的js文件的引入路径为 src="/test/app.17934a47c82529729b11.js"

webpack在不配置loader的情况下只能打包JavaScript文件,使用loader之后能处理各式各样的文件( modules )。上述代码中的html-loader就是专门用来将html文件解析为字符串的,html-webpack-plugin用于生成一个html文件。clean-webpack-plugin用于清除上次打包的文件。

package.json 中添加:

"scripts": {
    "build": "webpack --config ./config/webpack.common.js"
  },
复制代码

这样当执行 npm run build 的时候,就相当于执行 webpack --config ./config/webpack.common.js ,--config指定webpack的配置文件。

执行 npm run build 打包完文件之后,查看打包好的 app.17934a47c82529729b11.js 文件,发现里面已经包含了入口文件 src/index.js 中的代码。

/***/ (function(module, exports) {

window.onload = function () {
  var root = document.getElementById('root');
  var content = document.createElement('h1');
  content.textContent = '使用Webpack等搭建一个适用于React项目的脚手架';
  root.appendChild(content);
}

/***/ })
复制代码

打包后的html文件中引入了打包好的js文件:

...
<script type="text/javascript" src="./app.17934a47c82529729b11.js"></script></body>
</html>
复制代码

在浏览器中打开生成的html文件能看见页面内容为“使用Webpack等搭建一个适用于React项目的脚手架”。

区分开发/生产环境

上文中打包好的文件中,打包后的js文件和html文件都是没有压缩的,但是生产环境需要保证代码体积尽量小,所以在生产环境需要压缩代码。开发环境一般会使用 devServer 来配置 webpack-dev-server ,webpack-dev-server提供了一个服务器,可以在本地服务器上访问打包好的文件(webpack-dev-server将文件内容放在了内存中,并没有将内容写(write)成文件),而生产环境不需要webpack-dev-server。打包后的文件如果在执行中报错了,只能在打包后文件(app.17934a47c82529729b11.js)中定位到错误的位置,不能定位到源码(src/index.js),所以开发环境一定需要使用 devtool 或者别的方式(比如SourceMapDevToolPlugin)来将打包后代码映射到打包前的代码,源码映射(source map)在生产环境是可有可无的。总之,开发环境和生产环境有很多不同,所以需要分别配置。

安装 cross-env

npm i --save-dev cross-env
复制代码

修改 package.json 文件的scripts部分:

"scripts": {
    "start": "cross-env NODE_ENV=development webpack-dev-server --config ./config/webpack.dev.js",
    "build": "cross-env NODE_ENV=production webpack --config ./config/webpack.prod.js",
    "build:dev": "cross-env NODE_ENV=development webpack --config ./config/webpack.dev.js"
},
复制代码

设置 build:dev 是为了方便查看以不压缩的方式打包后的代码内容。

cross-env 是用于 跨平台设置和使用环境变量的 ,不同操作系统设置环境变量的方式不一定相同,比如Mac电脑上使用 export NODE_ENV=development ,而Windows电脑上使用的是 set NODE_ENV=development ,使用 cross-env NODE_ENV=development 时不论在Windows电脑上还是Mac电脑上,都能成功设置环境变量NODE_ENV为development。

安装 webpack-mergewebpack-dev-server ,webpack-merge用来合并webpack配置, webpack-dev-server提供一个服务器。

npm i --save-dev webpack-merge webpack-dev-server
复制代码

稍微修改一下webpack.common.js、 webpack.dev.js、webpack.prod.js三个文件中的内容:

webpack.common.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const devMode = process.env.NODE_ENV !== 'production';

module.exports = {
  entry: {
    app: path.resolve(__dirname, '../src/index.js'),
  },
  output: {
    filename: devMode ? '[name].js' : '[name].[hash].js',
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/',
  },
  module: {
    rules: [
      {
        test: /\.html$/,
        exclude: /[\\/]node_modules[\\/]/,
        loader: 'html-loader',
        options: {
          minimize: !devMode,
        },
      },
    ],
  },
  plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      title: 'simple-scaffold',
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html',
    }),
  ],
}
复制代码

删除了mode, mode: 'none', ,改变了publicPath的值 publicPath: '/',publicPath: './' )。

process.env.NODE_ENV !== 'production' 时表明是开发环境,在开发环境不对html进行压缩 minimize: !devMode, 。在下文的webpack.prod.js文件中已经配置了mode为production,当设置mode值为production的时候,会自动设置process.env.NODE_ENV 为production。在入口文件中拿到的process.env.NODE_ENV为production,但是要想在配置文件中使用环境变量,必须使用 cross-env NODE_ENV=production 先设置好环境变量,否则webpack.common.js文件中是拿到的process.env.NODE_ENV为undefined。

在生产环境,假如打包后的文件为 app.js ,使用CDN的时候,用户请求的路径 app.js ,这个路径拿到的可能是服务器上缓存的数据,而不是最新的数据,所以打包后的文件需要一个hash值,当文件内容变化的时候这个hash值也会变化,保证用户拿到的是正确的文件。但是开发环境中没有这个需求,所以开发环境的文件名不需要使用hash( filename: devMode ? '[name].js' : '[name].[hash].js', )。

webpack.dev.js:

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'development',
  devtool: 'inline-source-map',
  devServer: {
    open: true,
    hot: true,
  },
});
复制代码

mode设置为development会针对开发环境进行一些处理,比如将process.env.NODE_ENV设置为development。

使用devtool配置为inline-source-map,源码映射会以一个DataUrl的方式添加到打包的文件中。执行 npm run build:dev 将文件打包后,可以看见打包后的js文件多了以下代码:

//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vd2...
复制代码

在devServer中进行webpack-dev-server的配置,open表示打开默认浏览器,配置hot为true的时候会使用 hot-module-replacement 让文件内容改变时,重新加载相应模块(不是重新加载整个应用)。执行 npm start 就会启动服务,打开默认浏览器,在浏览器中看到“使用Webpack等搭建一个适用于React项目的脚手架”这几个字。

webpack.prod.js:

const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
  mode: 'production',
  devtool: 'source-map',
  output: {
    publicPath: '/', 
  },
});
复制代码

当设置mode为production的时候,Webpack会将process.env.NODE_ENV设置为production。并且自动配置了 terser-webpack-plugin ,这个插件利用 terser 过滤掉js中多余的内容,包括去掉js中的注释和空格。

设置devtool为source-map会生成单独的source map文件,并且在打包好的js中有一行注释,指明去哪儿找这个文件。执行 npm run build 打包代码:

Asset       Size  Chunks                         Chunk Names
    app.da19f0220d534a1d34d1.js   1.15 KiB       0  [emitted] [immutable]  app
app.da19f0220d534a1d34d1.js.map   5.05 KiB       0  [emitted] [dev]        app
                     index.html  333 bytes          [emitted]              
复制代码

可以看见有一个.map文件,并且app.da19f0220d534a1d34d1.js 中的有以下注释:

//# sourceMappingURL=app.da19f0220d534a1d34d1.js.map
复制代码

使用React

1.使用React创建一个简单的页面。

安装react和react-dom:

npm i --save react react-dom
复制代码

react 只包含了定义React组件的必要功能,它一般和React渲染器配合使用,web应用是用 react-dom 渲染器,native应用使用react-native渲染器。

src包含文件:

...
└── src
    ├── components
    │   └── Header
    │       └── Header.js
    ├── index.html
    ├── index.js
    └── pages
        └── Home
            └── Home.js
复制代码

index.js文件内容:

import ReactDOM from 'react-dom';
import Home from './pages/Home/Home';

ReactDOM.render(
  <Home />,
  document.getElementById('root')
);
复制代码

Home.js文件内容:

import Header from '@/components/Header/Header';

export default function Home() {
  return (
    <div>
      <Header userName="任沫" />
      <h1>使用Webpack等搭建一个适用于React项目的脚手架</h1>
    </div>
  );
}
复制代码

Header.js文件的内容:

export default function Header (props) {
  return (
    <div>
      <p>{props.userName}</p>
    </div>
  );
}
复制代码

2.使用 @babel/preset-react 转化JSX语法。

安装 babel-loader@babel/core 、@babel/preset-react:

npm i --save-dev babel-loader @babel/core @babel/preset-react
复制代码

babel-loader使用babel解析文件,@babel/core是babel的核心模块,

在根目录下创建 .babelrc.js 文件用于进行babel配置。

touch .babelrc.js
复制代码

.babelrc.js文件内容:

module.exports = {
  presets: ['@babel/preset-react'],
};
复制代码

presets 相当于一系列插件的合集,使用presets就不用一个个设置插件了。比如@babel/preset-react一般情况下会包含@babel/plugin-syntax-jsx、@babel/plugin-transform-react-jsx、@babel/plugin-transform-react-display-name这几个babel插件。

3.Webpack配置( webpack.common.js ):

const webpack = require('webpack');
...

module.exports = { 
  ...
  resolve: {
    extensions: ['.js', '.jsx'],
    alias: {
      '@': path.resolve(__dirname, '../src'),
    },
  },
  module: {
    rules: [
      ...
      {
        test: /\.js(x?)$/,
        exclude: /[\\/]node_modules[\\/]/,
        loader: 'babel-loader?cacheDirectory=true',
      },
    ],
  },
  plugins: [
    ...
    new webpack.ProvidePlugin({
      React: 'react',
    }),
  ],
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        },
      },
    },
  },
}
复制代码

extensions 中定义好文件后缀名后,在import文件的时候,就可以不加文件后缀名了。webpack会按照定义的后缀名的顺序依次处理文件,比如上文配置 extensions: ['.js', '.jsx'] ,引入模块 import Home from './pages/Home/Home'; 的时候,webpack会先尝试加上.js后缀,看找得到文件不,如果找不到就尝试加上.jsx后缀名继续查找。

alias 中定义了src文件目录的别名是@,这样在文件中引入别的文件的时候,可以直接使用@,而不是去找文件的相对路径。

使用 webpack.ProvidePlugin 定义自动查找的标志符,上面代码中的 React: 'react', 指的是当需要变量React的时候,会自动到当前目录或者node_modules中去找react模块。这样就不用在每个组件文件中都使用一次 import React from 'react' 了。

使用 cacheDirectory 缓存loader的执行结果。 loader: 'babel-loader?cacheDirectory=true', 这样设置会使用默认缓存目录 node_modules/.cache/babel-loader

splitChunks 将通用的模块打包为单独的一个文件,如果不配置splitChunks,那么代码会全部打包到app.hash.js中,导致app.hash.js文件很大,js越大,请求js文件和执行文件的时间越长,页面呈现给用户的耗时就越久。上面代码中的配置只将node_modules中用到的模块打包成一个文件(不会打包node_modules外的模块)。

执行 npm start 查看页面。

使用TypeScript

首先安装好所需依赖:

npm i --save-dev typescript @babel/preset-typescript
npm i --save-dev @types/react @types/react-dom
复制代码

@types/react 中包含react的类型定义, @types/react-dom 中包含react-dom的类型定义。

@babel/preset-typescript 是一个babel的preset,用于处理TypeScript。

1.修改**.babelrc.js**:

module.exports = {
  presets: [
    '@babel/preset-react',
    '@babel/preset-typescript',
  ],
};
复制代码

preset的执行顺序 是从后到前的。根据以上代码的babel配置,会先执行@babel/preset-typescript,然后再执行@babel/preset-react。

2.在根目录创建 tsconfig.json 文件,用于TypeScript配置:

{
  "compilerOptions": {
    "baseUrl": "./",
    "jsx": "react",
    "paths": {
      "@/*": ["src/*"]
    },
    "esModuleInterop": true,
  },
  "include": [
    "src/*",
    "typings/*"
  ]
}
复制代码

baseUrl设置基础路径。jsx用来设置jsx语法是以什么方式转换为JavaScript的。esModuleInterop为true允许使用 import React from 'react' ,否则对于没有默认导出的模块,比如react,必须使用 import * as React from 'react' 。include设置typescript处理的文件范围。

之前的代码中,通过webpack.ProvidePlugin定义了代表react包的标志符 React ,TypeScript中需要为React定义类型,按照 这个issue 中geekflyer的回答进行了全局的类型定义:

mkdir typings && cd typings && touch react.d.ts
复制代码

react.d.ts文件的内容为:

import React from 'react';

declare global {
  const React: typeof React;
}
复制代码

上文的Webpack中定义了路径的别名@,在tsconfig.json中也需要做相应的配置:

"baseUrl": "./",
"paths": {
  "@/*": ["src/*"]
},
复制代码

在paths中定义路径的别名。必须设置了baseUrl选项,才能使用paths选项。

更多的Typescript配置在 tsconfig 文档能找到。

3.Webpack配置

使用TypeScript后, webpack.common.js 中的内容也许做以下调整:

...
module.exports = {
  entry: {
    app: path.resolve(__dirname, '../src/index.tsx'),
  },
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
    ...
  },
  module: {
    rules: [
      ...
      {
        test: /(\.js(x?))|(\.ts(x?))$/,
        exclude: /[\\/]node_modules[\\/]/,
        loader: 'babel-loader?cacheDirectory=true',
      },
    ],
  },
...
复制代码

4.使用TypeScript

将index.js、Home.js、Header.js的后缀改为.tsx。

修改Header.tsx

interface HeaderProps {
  userName: string;
}
export default function Header (props: HeaderProps): JSX.Element {
  return (
    <div>
      <p>{props.userName}</p>
    </div>
  );
}
复制代码

将Home.tsx中的 <Header userName="任沫" /> 改为 <Header /> 就会出现一个类型错误的提示:

Property 'userName' is missing in type '{}' but required in type 'HeaderProps'.ts(2741)
复制代码

执行 npm start 查看页面。

使用babel

babel的作用是将代码转换为在浏览器上能正常运行的代码。其实上文中已经使用babel来解析JSX和TypeScript了。但是解析过JSX和TypeScript之后得到的JavaScript可能依然无法在某些浏览器上正常运行,所以需要使用 @babel/preset-env ,@babel/preset-env根据设置的目标环境找出所需的插件,并将插件列表传给babel,这样只需配置好目标环境,其他的babel会进行处理。

安装依赖: npm i --save-dev @babel/preset-env

修改**.babelrc.js**:

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: '> 2% in CN and not ie <= 8 and not dead',
      },
    ],
    '@babel/preset-react',
    '@babel/preset-typescript',
  ],
};
复制代码

targets: '> 2% in CN and not ie <= 8 and not dead', 这里配置的 targets 的意思是,选择目标环境为:中国区统计数据为2%以上的浏览器,不包括版本号小于8的IE浏览,不包括官方已经不维护的浏览器。

执行 npm start ,在谷歌浏览器中能看到预期的页面。

下一篇: 《使用Webpack等搭建一个适用于React项目的脚手架(2 - React Router、Redux、Sass)》

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章