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

·  阅读 947

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

  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)》

分类:
前端
标签: