目前,前端两大框架React和Vue分庭抗礼。React语法更接近JS,生态更加繁荣,成熟的企业项目大多由React实现。Vue在中文社区的热度只增不减,封装了许多复杂操作,被认为上手更简单。

两者在响应式系统、模版语法等方面有一些设计上的关键区别,但对于新手来说,不足以成为选择依据。构建第一个Web应用时,个人认为如果有JS基础,可直接尝试React,省去记忆新语法的心智负担。

第一个react项目

初始结构

最常见、简单的React项目,一般会从这三个文件开始

├── index.html
└── src
    ├── App.js
    └── index.js

index.html中,我们设置网页基本信息、引入模块,并创建一个id为 root的元素,后续会将React的 App组件挂载到该元素上

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  </head>
  <body>
    <div id="root"></div>
    <script type="text/babel" src="src/App.js"></script>
    <script type="text/babel" src="src/index.js"></script>
  </body>
</html>

一般本地引入的 <script>,放到 <body>最后,保证脚本执行前DOM元素已存在。CDN引入的放在 <head>内部,使其优先加载,因为后续代码都依赖它

react.development.js是React模块本体,react-dom.development.js负责将组件挂载到DOM节点、更新组件,babel.min.js的作用是将React的JSX语法翻译为JS

模块导入都没有使用ES6语法,所以其内部对象会暴露为公共变量,这也是为何 ReactDOM对象能被直接调用

src/index.js习惯作为一个入口文件,不会写复杂的逻辑,只是将 App组件挂载、渲染到DOM上。由于 index.html中提前全局引入了 src/App.js,此处可以直接调用组件 <App />

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

src/App.js习惯使用JSX语法编写应用主体,JSX支持JS与HTML混写。

const App = () => {  
  return (
    <div>
    <h1>宝可梦</h1>
    </div>
  );
};

该文件定义了一个名为 App的箭头函数,返回了一个h1标签,这种形式称为函数组件。另一种形式是类组件,写法更复杂、逐渐被淘汰了,此处略过。

这时,通过本地HTTP服务器(比如Vscode提供的Live Server),一个极简的React项目就跑起来了。

state的创建与更新

我们希望从远程api获取宝可梦列表,并展示在页面中。App.js可以这样写

const App = () => {
  const [pokemons, setPokemons] = React.useState([]);
  
  React.useEffect(() => {
    fetch("https://pokeapi.co/api/v2/pokemon")
    .then(res => res.json())
    .then(json => {
      json.results.map((result, index) => {
      result.id = index + 1;
      });
      setPokemons(json.results);
    });
  }, []);
  

  return (
    <div>
      <h1>宝可梦</h1>
      <ul>
        { pokemons.map( pokemon => <li key={pokemon.id}>{pokemon.name}</li> ) }
      </ul>
    </div>
  );
};

因为 fetch函数返回 Promise对象而不是解析后的值,对数据源的所有操作都要使用异步编程,而不能做简单的赋值操作

React是全局引入的,可以直接调用 React.useState()。该方法返回一个状态变量、一个更新函数,并通过解构赋值分配给 pokemonssetPokemonspokemons的初始值被设为 []

React.useEffect方法的第一个参数指定了获取宝可梦列表的函数,第二个参数指定了触发条件。该案例第二个参数为 [],代表仅在组件挂载后执行一次;如果不指定第二个参数,则组件的每次更新都会执行;如果给定了带值的Array,则仅当任一值改变时,才触发操作

在返回值中,我们使用JSX语法创建了 <h1>标签和循环的无序列表 <ul>标签。只能return单个页面元素,所以需要最外层的 <div>。在HTML中加入JS逻辑时,需要用 {}包裹。return的HTML片段,则必须用 ()包裹

image.png

组件化

为了项目的可维护性,我们要尽量避免在 App.js中写逻辑。当前返回值中,我们用JS逻辑创建了列表,这部分可被拆分为子组件 components/List.jsx。首字母大写、jsx后缀名是React组件的命名习惯

const List = ({pokemons}) => {
  return (
    <ul>
	    { pokemons.map( pokemon => <li key={pokemon.id}>{pokemon.name}</li> ) }
    </ul>
  )
}

这也是个函数组件,需要父组件在调用时指定 pokemons参数。参数使用解构赋值,实现类似其他语言关键字参数的效果

该组件需要在 index.html中被全局引入,才能暴露给 App.js

...
<div id="root"></div>
<script type="text/babel" src="src/components/List.jsx"></script>
<script type="text/babel" src="src/App.js"></script>
...

App.js中,以如下方式引用。props传参时需要引用JS变量 pokemons,依据JSX的规则加了 {}

return (
  <div>
    <h1>宝可梦</h1>
    <List pokemons={pokemons}/>
  </div>
);

这种从父组件单向传递到子组件的变量,被称为props(properties),props在子组件中不可修改。这些特性都是为了代码行为可预测、提高性能

事件触发

接下来我们加入输入框,允许筛选宝可梦列表。在 components/Input.jsx写下

const Input = ({pokemons, setFunc}) => {
    const onChangeHandler = event => {
        const filtered = pokemons.filter(pokemon => {
          return pokemon.name.includes(event.target.value);
        });
        setFunc(filtered);
    };

    return (
      <input type="search" onChange={ onChangeHandler } />
    );
};

这段代码会返回一个 <input>标签,在检测到输入变化的时候调用 onChangeHandler函数。onChangeHandler内部通过 event.target.value获取输入框的值,并据此过滤列表

按照常理,我们需要将过滤后的列表回传到父组件,再传给 List.jsx组件调用。然而,React不允许子组件修改父组件的值,只能另寻它法。恰好,此前 React.useState方法返回的第二个值是更新函数,我们将过滤后的列表设为state,把更新函数传递给 Input.jsx,就可以在 Input.jsx更新全局的state了

App.js中,也需要加上定义state、调用 Input.jsx组件的逻辑。调用组件时,多个props用空格分隔

...
const [pokemons, setPokemons] = React.useState([]);
const [filteredPokemons, setFilteredPokemons] = React.useState([]);
...
<h1>宝可梦</h1>
<Input pokemons={pokemons} setFunc={setFilteredPokemons}/>
<List pokemons={filteredPokemons}/>
...

自然,index.html中也需要全局引入 Input.jsx

<div id="root"></div>
<script type="text/babel" src="src/components/Input.jsx"></script>
<script type="text/babel" src="src/components/List.jsx"></script>
<script type="text/babel" src="src/App.js"></script>
<script type="text/babel" src="src/index.js"></script>

最终,我们的目录结构是

├── index.html
└── src
    ├── App.js
    ├── components
    │   ├── Input.jsx
    │   └── Lists.jsx
    └── index.js

筛选功能即可实现
image.png

nodejs环境下的开发

项目结构

在上例中,我们用CDN在线引入所有必需的模块,使用传统的全局模块导入,没有用CSS设置网页样式,为的是将注意力集中在React语法本身

实际的Web开发常使用npm/yarn/pnpm等管理器,将第三方模块下载在本地,引用时采用ES6模块语法。依赖本地的nodejs环境,可以开启HTTP服务器,也免去了对Vscode插件Live Server的需要

不使用在线模块,就要求最终的网页文件内含第三方模块的代码,这个操作被跑在nodejs环境下的构建工具完成。传统的构建工具是Webpack,但Vite速度更快、效率更高,目前越来越流行

使用该命令创建Vite项目,根据提示选择React,并安装依赖项。如果你使用WSL2,pnpm可能会遇到ipv6导致的Timeout,可参考这里解决

pnpm create vite
pnpm i

这会产生一个最简项目,以下都是开发环境用到的文件,开发完成后会被Vite构建为浏览器可识别的HTML+JS+CSS(生产环境),并存放在 dist目录(此时还未创建)

├── node_modules
├── public
├── src
│   ├── assets
│   ├── App.css
│   ├── App.jsx
│   ├── index.css
│   └── main.jsx
├── README.md
├── index.html
├── package.json
├── pnpm-lock.yaml
└── vite.config.js

src存放主要的代码、媒体资源。assets存放需要处理的媒体资源,如需要被压缩的图片。main.jsx是入口文件,相当于上个案例的 index.jsApp.jsx是应用主体,相当于上个案例的 App.js

public存放不需要处理、直接拷贝到 dist的文件,如矢量图标。vite.config.js存储了Vite的配置,可以设置JSX解析、HTTP服务器端口、网络代理、路径别名、

node_modules存放本地第三方包,而包的具体版本、依赖关系,被存储在 package.jsonpnpm-lock.yaml

package.jsonscript对象,指定了可以使用的快捷命令。为方便调试,可在 dev, preview命令后添加 --host参数,允许局域网内其他设备访问

"scripts": {
	"dev": "vite --host",
	"build": "vite build",
	"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
	"preview": "vite preview --host"
}

使用快捷命令调用 vite --host,即可开启本地HTTP服务器

pnpm dev

此时网站处于开发模式,所有的代码修改都实时更新

Vite下的案例

在Vite下跑起上述案例,只需对代码稍作修改。index.html不再需要CDN导入,引用 main.jsx也使用ES6语法

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="src/main.jsx"></script>
  </body>
</html>

main.jsx通过 import导入模块和组件。<React.StrictMode>的作用是给出更多的警告信息

import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App.jsx'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

App.jsx需要导出 App,导入 Input.jsxList.jsx组件时 .jsx可省略

import { useState, useEffect } from 'react'
import { Input } from './components/Input';
import { List } from './components/List';

export const App = () => {
  const [pokemons, setPokemons] = useState([]);
  const [filteredPokemons, setFilteredPokemons] = useState([]);
  
  useEffect(() => {
    fetch("https://pokeapi.co/api/v2/pokemon")
    .then(res => res.json())
    .then(json => {
      json.results.map((result, index) => {
      result.id = index + 1;
      });
      setPokemons(json.results);
      setFilteredPokemons(json.results);
    });
  }, []);
 
  return (
    <div>
      <h1>宝可梦</h1>
      <Input pokemons={pokemons} setFunc={setFilteredPokemons}/>
      <List pokemons={filteredPokemons}/>
    </div>
  );
};

两个子组件的代码,只需加上 export导出即可

// src/components/Input.jsx
export const Input = ({pokemons, setFunc}) => {
    const onChangeHandler = event => {
        const filtered = pokemons.filter(pokemon => {
          return pokemon.name.includes(event.target.value);
        });
        setFunc(filtered);
    };

    return (
      <input type="search" onChange={ onChangeHandler } />
    );
};
// src/components/List.jsx
export const List = ({pokemons}) => {
  return (
    <ul>
    { pokemons.map( pokemon => <li key={pokemon.id}>{pokemon.name}</li> ) }
    </ul>
  )
}

添加样式

Input.jsx组件中,可以直接用内联样式。CSS设置要提供一个JS对象,以 style={cssObj}的形式

export const Input = ({pokemons, setFunc}) => {
  const onChangeHandler = event => {
    const filtered = pokemons.filter(pokemon => {
      return pokemon.name.includes(event.target.value);
    });
    setFunc(filtered);
  };

  const inputCSS = {
    padding: '10px 15px', 
    margin: '10px 0', 
    border: '1px solid #ccc', 
    borderRadius: '4px', 
    outline: 'none', 
  };

  return (
    <input type="search" style={ inputCSS } onChange={ onChangeHandler } />
  );
};

也可以将样式写入CSS文件,并作为模块导入。在 src/css/Input.css

.input-style {
  padding: 10px 15px;
  margin: 10px 0;
  border: 1px solid #ccc;
  border-radius: 4px;
  outline: none;
}

.input-style:focus {
  border-color: #66afe9;
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1), 0 0 8px rgba(102, 175, 233, 0.6);
}

Input.jsximport导入,然后用 className指定类名

import '../css/Input.css'

export const Input = ({pokemons, setFunc}) => {
  const onChangeHandler = event => {
    const filtered = pokemons.filter(pokemon => {
      return pokemon.name.includes(event.target.value);
    });
    setFunc(filtered);
  };

  return (
    <input type="search" className='input-style' onChange={ onChangeHandler } />
  );
};

image.png

生产环境

确认开发完成后,可调用快捷命令构建最终的网页文件,并开启HTTP服务器预览

pnpm build
pnpm preview

此时 dist目录下,已经打包好的HTML+JS+CSS便生成了,包含网站所需的全部代码

├── dist
│   ├── assets
│   │   ├── index-0xvw5vP_.js
│   │   └── index-DWT6LCHw.css
│   └── index.html

查看这些文件,会发现它们格式非常紧凑,这都是为了减少文件体积、加速网页载入

如果你只想创建一个静态网页,这时就可以把 dist目录打包带走,架个HTTP服务器(如Nginx)向外提供服务

结语

以上,我们使用React在两种开发环境下创建了简易的Web应用,梳理了React的基本概念和用法。

如果在初始化时,你想要使用更多模版,可以在这里找找。

如果你要构建复杂的应用,React原生的state管理很可能无法胜任,可以引入额外的状态管理模块如Redux,或新秀zustand

当你需要构建单页面应用程序(SPA)以提升页面切换速度,你会需要路由管理模块如React router

如果你需要加快页面加载速率、做搜索引擎优化(Search Engine Optimizatio,SEO)时,可以考虑采用服务端渲染(Server Side Rendering,SSR)和静态站点生成(Static Site Generation,SSG)技术,Next.js是一个很好的实现方案

更多React生态的流行模块,可以在这里找到。祝玩得开心!

2024年React初学者入门路线指南 - 知乎 (zhihu.com)
React入门应该是这样的!!!_哔哩哔哩_bilibili