从0开始搭建一个React开发框架

1,119 阅读6分钟

起因

在公司入职也很久了,发现很多下面的同学都不理解我们所用开发框架是如何搭建起来的,为了加强小伙伴们的基础功底,所以有了这篇文章。

此次我会带领大家一步步的来搭建一个开发框架,一共分四篇文章来写。

  • 基础React开发框架的搭建
  • 基础的node端抽取
  • cli集成
  • 开发规范

本文会实现什么

从0到1搭建一个基于webpack的react应用

搭建

准备工作

先来介绍一下我的开发环境

  • MacBook
  • node@15.5.0
  • yarn@1.22.10

在进行下一步前为了加快我们的下载速度,请先执行如下命令

npm install -g yarn
npm install -g nrm 
npm install -g n

nrm use taobao

# 设置依赖安装过程中内部模块下载Node的淘宝镜像
npm config set disturl https://npm.taobao.org/mirrors/node/
# 设置常用模块的淘宝镜像
npm config set sass_binary_site https://npm.taobao.org/mirrors/node-sass/
npm config set sharp_dist_base_url https://npm.taobao.org/mirrors/sharp-libvips/
npm config set electron_mirror https://npm.taobao.org/mirrors/electron/
npm config set puppeteer_download_host https://npm.taobao.org/mirrors/
npm config set phantomjs_cdnurl https://npm.taobao.org/mirrors/phantomjs/
npm config set sentrycli_cdnurl https://npm.taobao.org/mirrors/sentry-cli/
npm config set sqlite3_binary_site https://npm.taobao.org/mirrors/sqlite3/
npm config set python_mirror https://npm.taobao.org/mirrors/python/

本文会用到的全局的node模块

npm install -g http-server

代码篇

首先我们先来创建一个简单的开发项目

cd ~/Documents
mkdir react-base && cd react-base
yarn init -y
mkdir src config types public

我们现在的目录结构是这样的

├── config  存放webpack配置文件
├── package.json
├── public  存放与业务无关的静态资源文件
├── src	 存放入口文件和业务相关代码
└── types 存放ts声明文件

我们先来安装一下我们的模块化打包工具webpack

yarn add webpack webpack-cli -D

src目录下创建main.js文件当做入口文件,执行如下命令

echo "console.log('Hello World');" > src/index.js

此时运行yarn webpack,会发现在dist目录下生成一个main.js文件,可以看到如下是webpack的工作机制。

因为webpack只对模块化语法exportimport 提供了支持,对于es6的其他语法并没有提供支持,此处我们要编译es6jsx语法,需要使用babelwebpack的工作机制是先通过loader对静态资源进行转换后输出到目标文件,此处引入babel-loader,并在根目录下创建.babelrc文件提供配置,如下是babel为我们完成的转换工作。

yarn add babel-loader @babel/core @babel/preset-env @babel/preset-react -D
yarn add react react-dom
touch .babelrc

在.babelrc文件中配置如下

{
    "presets": [
        "@babel/preset-env", "@babel/preset-react"
    ]
}
  • @babel/preset-react 是将jsx代码转换成函数api的方式
  • @babel/preset-env 则是对es的新特性进行转换, 要注意转换是从后向前进行的

创建src/App.js,内容如下:

import React from 'react';

const App = () => {
  return <div>Hello World</div>
}

export default App;

修改index.js,内容如下

import React from 'react';
import ReactDom from 'react-dom';
import App from './App';

ReactDom.render(<App />, document.getElementById('root'))

在项目根目录下创建webpack.config.mjs文件,此处使用mjs后缀是为了避免在同一个项目中既使用CommonJs也是用ES Module,内容如下

import path from 'path';

const config = {
    entry: './src/index.js',
    output: {
        path: path.resolve(path.resolve(), 'dist'),
        filename: 'bundle.js'
    },
    module: {
        rules: [{
            test: /\.m?jsx?$/,
            exclude: /node_modules/,
            use: 'babel-loader'
        }],
    }
};

export default config;

要想让代码能够跑起来,我们还需要一个html文件,在dist目录下,创建index.html文件,内容如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="root"></div>
    <script src="bundle.js"></script>
</body>
</html>

此时我们执行yarn webpack --mode none,可以看到有文件dist/bundle.js文件生成,将该文件进行折叠,可以看到是一个立即执行函数,有兴趣的小伙伴可以仔细看一下里面的内容,此时我们通过上面安装的http-server来启动一个http服务器,执行命令http-server dist,可以看到已经为我们启动了8080端口。

通过浏览器打开http://127.0.0.1:8080,我们可以看到是白屏的,打开console可以看到如下报错

我们分析一下打包后的代码,可以看到会根据环境变量的配置来判断引入是引入开发版本的react还是生产版本的react,此时process变量没有被定义。

我们可以借助于webpack的插件机制来解决该问题, 我们在webpack.config.mjsmodule节点下添加如下代码

+ import webpack from 'webpack';

const config = {
	...,
    plugins: [
      new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify('development')
      })
    ]
}

重新打包yarn webpack --mode none,可以看到process.env.NODE_ENV === 'production'已经被替换成false。重新运行清空缓存刷新一下,可以看到页面上已经显示出Hello World,代表我们的程序没有问题。

此处有一个问题就是html文件和bundle文件都是我们写死的,我们可以使用webpack的另外一个插件html-webpack-plugin,来根据模板来生成index.html文件,并动态注入打包后的js文件。

创建文件src/index.html,内容如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

安装依赖

yarn add html-webpack-plugin -D

比较幸运的是用这个包的时候刚好发布了5.0.0的正式版本

更改webpack.config.mjs,添加如下内容

...
+ import HtmlWebpackPlugin from 'html-webpack-plugin';

const config = {
	...
    plugins: [
    	...
+         new HtmlWebpackPlugin({
+            template: './src/index.html',
+            inject: 'body'
+        })
    ]
};

运行yarn webpack --mode none,可以看到生成的在dist目录下生成的index.html文件中已经自动注入了bundle.js

引入typescript

同样的我们的框架也要支持typescript,我们将src/app.js更改为src/app.tsx,内容不变,此处我们要添加ts相关的依赖的内容。

yarn add typescript ts-loader -D
yarn add @types/react @types/react-dom -D
yarn tsc --init

此时根目录下会生成一个tsconfig.json文件,我们不需要做过多的配置,将jsx节点放开,并配置为react,如下

"jsx": "react",            

webpack.config.mjs文件的rules节点下添加如下内容

{
    test: /\.tsx?$/,
    exclude: /node_modules/,
    use: 'ts-loader'
},

output节点下添加如下内容,此处的内容是为了避免添加后缀。

resolve: {
    extensions: ['.mjs', '.js', '.json', ".ts", ".tsx"],
},

现在目录结构如下

├── config
├── dist
│   ├── bundle.js
│   └── index.html
├── package.json
├── public
├── src
│   ├── App.tsx
│   ├── index.html
│   └── index.js
├── tsconfig.json
├── types
├── webpack.config.mjs
└── yarn.lock

执行yarn webpack --mode none同样可以正常显示。

添加css/less/scss支持

添加所用loader

yarn add style-loader css-loader less-loader sass-loader less sass -D 

添加测试文件src/index.css,src/index.less,src/index.scss。 src/index.css

.css {
    background-color: #f00;
}
.less {
    background-color: #0f0;
}
.scss {
    background-color: #00f;
}

src/App.tsx代码如下

import React from 'react';
import './index.css';
import './index.less';
import './index.scss';

const App = () => {
  return <div>
    <h1 className="css">Hello CSS</h1>
    <h1 className="less">Hello Less</h1>
    <h1 className="scss">Hello SCSS</h1>
  </div>
}

export default App;

webpack.config.mjsrules节点下添加如下内容

{
  test: /\.css$/i,
  use: ['style-loader', 'css-loader'],
},
{
  test: /\.less$/,
  use: ['style-loader', 'css-loader', 'less-loader'],
},
{
  test: /\.s[ac]ss$/i,
  use: [
    "style-loader",
    "css-loader",
    "sass-loader",
  ],
},

使用yarn webpack --mode none进行打包,然后用http-server dist启动,访问http://127.0.0.1:8080/ 发现已经可以正常显示了。

简单介绍一下各个loader的作用,sass-loaderless-loader是将.scss.less代码转换成.css代码,而css-loader的作用是将css的代码转换成js脚本,style-loader的作用是将生成的css文件嵌入到style标签中,简单看一下打包后的代码可以看到webpackcss代码当做一个模块放到了IIFE中。

静态资源

我们在开发过程中也会使用到一些静态资源,比方说图片,此处为图片添加依赖,在webpack@5版本下,我们一般会使用file-loaderurl-loaderfile-loader来处理一些比较大的文件内容,而url-loader则处理一些小的文件内容。在webpack@5版本中功能已经被优化,请看如下描述。

我们新建一个页面src/pages/Home.tsx,内容如下

import React from 'react';
import git from '@static/img/git.png';

const Home = () => {
    return <div>
        <h1>Home</h1>
        <img src={git} />
    </div>
}

export default Home;

添加图片src/static/img/git.png

此时我们可以看到已经编译报错了,错误信息如下:

找不到模块“@static/img/git.png”或其相应的类型声明。

这个地方是因为ts没有办法解析.png的类型声明,我们在创建types/static.d.ts文件,内容如下

declare module '*.svg'
declare module '*.png'
declare module '*.jpg'
declare module '*.jpeg'
declare module '*.gif'
declare module '*.bmp'
declare module '*.tiff'

此处因为我们不想一直通过相对路径的方式去找文件,所以我们对文件路径定义了别名,更改webpack.config.mjs,在extensions节点下添加如下配置

alias: {
   '@pages': path.resolve(path.resolve(), './src/pages/'),
   '@components': path.resolve(path.resolve(), './src/components/'),
   '@static': path.resolve(path.resolve(), './src/static/'),
},

更改tsconfig.json,添加如下配置

"baseUrl": "./",
  "paths": {
    "@pages/*": [
      "./src/pages/"
    ],
    "@static/*": [
      "./src/static/"
    ],
    "@components/*": [
      "./src/components/"
    ]
  },

然后我们添加loader对图片文件进行处理,在webpack.config.mjsrules节点下添加如下内容。

 {
   test: /\.png/,
   type: 'asset/resource'
 }

再运行打包,我们可以发现已经有一个图片文件被打包到了dist目录下。

简单分析一下打包后的文件,我们发现loader只是对文件名进行了处理,然后放入到了模块中。

另外一种比较常规的静态资源打包是如下的方式,这个时候webpack会将文件转换成data:url的形式放入到bundle.js文件中,这种方式只建议小文件去使用,若太大则会影响到bundle.js文件的体积,如下图,可以看到是一个很长的字符串。

{
  test: /\.svg/,
  type: 'asset/inline',
}

加快我们的开发步骤

上面的操作都是我们手动去完成的,目的是为了加深大家对打包过程的理解,真实开发过程中我们不会把生成文件直接写入到磁盘上,而是会放到内存中,加快编译速度。

webpack-dev-server会我们的开发过程提供了更好的体验,他的便利如下

  • 提供http-server
  • 提供热更新
  • 提供代理

下面让我们来一步步的使用webpack-dev-server

提供http-server

安装依赖

yarn add webpack-dev-server -D

更改package.json脚本,在跟节点下添加如下脚本

"scripts": {
  "start": "webpack serve --mode development"
},

此时浏览器已经为我们开启了8080端口 有些静态资源例如favicon.ico放入到public目录下,想要让应用访问到这个文件,就需要为webpack-dev-server添加一个静态资源的路径转发,在webpack.config.mjsconfig对象上添加如下属性

devServer: {
    contentBase: path.join(path.resolve(), './public'),
},

此时运行yarn start可以看到webpack已经开始长期监听文件的变化,并自动进行了编译,打开http://localhost:8080/可以看到favicon.ico也已经被正常加载了。

提供热更新

在我们的开发过程中,不想每次更改代码后都要手工去刷新浏览器,那webpack-dev-server为我们提供了自动刷新浏览器的配置,我们在webpack.config.mjsdevServer属性上添加hotOnly:true,此时我们去修改css文件,可以发现浏览器已经自动的刷新了。

Tips: webpack提供了两个用于热重载的属性,一个是hotOnly,一个是hot,两者的区别在于hot不论是否编译成功均会去同步刷新浏览器,而hotOnly只有在编译通过之后才会去刷新浏览器。

这个地方有的同学可能会好奇为什么js文件就没有提供这样的功能呢,这是因为css的处理是比较common的,所以style-loader已经进行了处理。

js则需要我们根据使用框架的不同来定制化处理,好在社区已经有了现成的方案,我们选用react-hot-loader,下面我们来安装一下

yarn add react-hot-loader @hot-loader/react-dom

.babelrc中添加plugins,如下

{
  "plugins": ["react-hot-loader/babel"]
}

src/App.tsx进行调整,调整后代码如下

+ import { hot } from 'react-hot-loader/root';
import React from 'react';
import './index.css';
import './index.less';
import './index.scss';
import Home from '@pages/Home';

const App = () => {
  return <div>
    <h1 className="css">Hello CSS</h1>
    <h1 className="less">Hello Less</h1>
    <h1 className="scss">Hello SCSS</h1>
    <Home />
  </div>
}
- export default App;
+ export default hot(App);

停掉服务重新运行yarn start一下,此时我们在App组件中添加一个输入框,然后在页面上输入内容123,然后在App组件中在添加一个输入框,发现第一个输入框的内容并没有消失,第二个输入框也同样被添加上去了,代表热重载成功。

提供代理

一个基于ReactSPA应用,一般都会向服务端发起请求,而浏览器的安全策略经常会让我们遇到跨域的问题,此处我们使用github/users接口来模拟一个简单的应用。

先安装所需依赖

yarn add axios

创建文件src/pages/Users/index.tsx,文件内容如下

import React, { useEffect, useState } from 'react';
import axios from 'axios';

interface User {
    login: string;
}

const Users = () => {
    const [users, setUsers] = useState<Array<User>>([])
    useEffect(() => {
        axios.get('/api/users').then(res => {
            setUsers(res.data)
        })
    }, []);
    return <>
        <ul>
            {users.map(item => <li key={item.login}>{item.login}</li>)}
        </ul>
    </>
}

export default Users;

此时我们需要在webpack.config.mjs文件为其配置代理,内容如下:

devServer {
	...
    proxy: {
      '/api': {
        target: 'https://api.github.com/',
        pathRewrite: { '^/api': '' },
        changeOrigin: true
      }
    }
}

简单说一下作用,就是将http://localhost:8080/api/users 转换成 https://api.github.com/userschangeOrigin:true,代表更改请求源,此处不更改会报错。

App.tsx文件中引入该组件,并放到Home组件后,重启后可以发现user列表已经正常的展现出来了。 src/App.tsx

...
import Users from '@pages/Users';

const App = () => {
	return (
    	...
        <Users />
    );
}

生产环境配置

现在我么已经有了一套可以在开发环境使用的框架,但是在开发环境和生产环境我们的打包策略是不一样的,此时我们对webpack配置文件进行抽取,将公共各部分放到webpack.base.mjs文件中,通过webpack-merge对配置进行结合,更改我们现有的文件结构,如下

├── config
│   ├── webpack.base.mjs
│   ├── webpack.dev.mjs
│   └── webpack.prd.mjs
├── package.json
├── public
│   └── favicon.ico
├── src
│   ├── App.tsx
│   ├── index.css
│   ├── index.html
│   ├── index.js
│   ├── index.less
│   ├── index.scss
│   ├── pages
│   │   ├── Home
│   │   │   └── index.tsx
│   │   └── Users
│   │       └── index.tsx
│   └── static
│       └── img
│           └── git.png
├── tsconfig.json
├── types
│   └── static.d.ts
└── yarn.lock

安装所需依赖

yarn add webpack-merge -D 

调整后config/webpack.base.mjs文件内容如下

import path from 'path';
import HtmlWebpackPlugin from 'html-webpack-plugin';

const config = {
    entry: './src/index.js',
    output: {
        path: path.resolve(path.resolve(), './dist'),
        filename: 'bundle.js'
    },
    resolve: {
        extensions: ['.mjs', '.js', '.json', ".ts", ".tsx"],
        alias: {
            '@pages': path.resolve(path.resolve(), './src/pages/'),
            '@components': path.resolve(path.resolve(), './src/components/'),
            '@static': path.resolve(path.resolve(), './src/static/'),
        },
    },
    module: {
        rules: [
            {
                test: /\.m?jsx?$/,
                exclude: /node_modules/,
                use: 'babel-loader'
            },
            {
                test: /\.tsx?$/,
                exclude: /node_modules/,
                use: 'ts-loader'
            },
            {
                test: /\.png/,
                type: 'asset/inline'
            }
        ],
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html',
            inject: 'body'
        })
    ],
};

export default config;

调整后config/webpack.dev.mjs内容如下

import { merge } from 'webpack-merge';
import base  from './webpack.base.mjs';
import path from 'path';
import webpack from 'webpack';

export default merge(base, {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map',
  devServer: {
    contentBase: path.join(path.resolve(), './public'),
    hotOnly: true,
    proxy: {
      '/api': {
        target: 'https://api.github.com/',
        pathRewrite: { '^/api': '' },
        changeOrigin: true
      }
    }
  },
  resolve: {
    alias: {
      'react-dom': '@hot-loader/react-dom',
    },
  },
  module: {
    rules: [
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /\.less$/,
        use: ['style-loader', 'css-loader', 'less-loader'],
      },
      {
        test: /\.s[ac]ss$/i,
        use: [
          "style-loader",
          "css-loader",
          "sass-loader",
        ],
      },
    ]
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('development')
    }),
  ]
})

可以看到这个地方我们添加了 devtool: 'eval-cheap-module-source-map', 它可以在应用产生错误的时候帮助我们定位到源代码。 上面我将css/less/sassloader也同样放到了开发配置中,这是因为当样式文件过大的时候,我们可以通过另一个插件mini-css-extract-plugin来从bundle.js文件中提取出我们使用的样式文件。

优化后的config/webpack.prd.mjs内容如下

import { CleanWebpackPlugin } from 'clean-webpack-plugin';
import { merge } from 'webpack-merge';
import CopyPlugin from "copy-webpack-plugin";
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import base from './webpack.base.mjs';
import webpack from 'webpack';

export default merge(base, {
    mode: 'production',
    devtool: false,
    module: {
        rules: [
            {
                test: /\.css$/i,
                use: [MiniCssExtractPlugin.loader, 'css-loader'],
            },
            {
                test: /\.less$/,
                use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'],
            },
            {
                test: /\.s[ac]ss$/i,
                use: [
                    MiniCssExtractPlugin.loader,
                    "css-loader",
                    "sass-loader",
                ],
            },
        ],
    },
    optimization: {
        minimize: true,
        minimizer: [
            // For webpack@5 you can use the `...` syntax to extend existing minimizers (i.e. `terser-webpack-plugin`), uncomment the next line
            `...`,
            new CssMinimizerPlugin(),
        ],
    },
    plugins: [
        new CleanWebpackPlugin(),
        new CopyPlugin({
            patterns: [
                { from: "public", to: "." },
            ]
        }),
        new webpack.DefinePlugin({
            'process.env.NODE_ENV': JSON.stringify('production')
        }),
        new MiniCssExtractPlugin(),
    ],
})

此次添加了几个插件, 简单来介绍一下作用。

  • clean-webpack-plugin在每次打包前清空dist目录下内容
  • copy-webpack-plugin打包的时候将public目录下的内容拷贝到dist目录下
  • mini-css-extract-pluginbundle.js中的样式文件抽取到css文件中
  • css-minimizer-webpack-plugin 对抽取的css文件进行压缩。

更改package.json中的scripts

"start": "webpack serve  --config config/webpack.dev.mjs",
"build:prd": "webpack --config config/webpack.prd.mjs"

安装依赖并执行构建

yarn add clean-webpack-plugin -D
yarn add copy-webpack-plugin -D
yarn add mini-css-extract-plugin -D
yarn add css-minimizer-webpack-plugin -D
yarn build:prd

下图是打包后的内容,可以看到未通过gzip压缩的bundle.js大小为143k

按需加载

当我们的项目越来越大后,bundle.js文件的大小也会随之增大,那么页面的加载速度也会变慢,这个时候我们可以引入按需加载,也就是lazy load,当我们需要用到一个组件的时候,再去加载这个页面的内容。 让我们来改进一下我们的项目,此处我们使用的是@loadable/component,为了体现出效果引入react-router

yarn add react-router react-router-dom @loadable/component 
yarn add @types/react-router @types/react-router-dom @types/loadable__component -D

修改src/App.jssrc/App.tsx,目前发现以.tsx为后缀的文件实现按需加载,此问题后续进行补充。 src/App.js内容如下

import { hot } from 'react-hot-loader/root';
import React from 'react';
import './index.css';
import './index.less';
import './index.scss';
import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom';
import loadable from "@loadable/component";

const Loading = () => {
  return <div>Loading...</div>
}

const Home = loadable(() => import("@pages/Home"), {
  fallback: <Loading />
});
const Users = loadable(() => import("@pages/Users"), {
  fallback: <Loading />
});

const App = () => {
  return <div>
    <h1 className="css">Hello CSS</h1>
    <h1 className="less">Hello Less</h1>
    <h1 className="scss">Hello SCSS</h1>
    return <>
      <Router>
        <div>
          <nav>
            <ul>
              <li><Link to="/">Home</Link></li>
              <li><Link to="/users">Users</Link></li>
            </ul>
          </nav>
          <main>
            <input />
            <Switch>
              <Route path="/" exact>
                <Home />
              </Route>
              <Route path="/users">
                <Users />
              </Route>
            </Switch>
          </main>
        </div>
      </Router>
    </>
  </div>
}

export default hot(App);

.babelrc添加plugin

{
    "presets": [
        "@babel/preset-env",
        "@babel/preset-react"
    ],
    "plugins": [
        "react-hot-loader/babel",
        "@babel/plugin-syntax-dynamic-import"
    ]
}

执行yarn build:prd,可以看到每个路由都进行了切分

此时dist目录下的内容我们就可以和服务端代码部署到一起使用了。