Repository: lowerfish/js-stack-from-scratch
Branch: master
Commit: e5c33bafec94
Files: 18
Total size: 91.1 KB
Directory structure:
gitextract_r4xfcy63/
├── .github/
│ └── ISSUE_TEMPLATE
├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── how-to-translate.md
├── mdlint.js
├── package.json
└── tutorial/
├── 01-node-yarn-package-json.md
├── 02-babel-es6-eslint-flow-jest-husky.md
├── 03-express-nodemon-pm2.md
├── 04-webpack-react-hmr.md
├── 05-redux-immutable-fetch.md
├── 06-react-router-ssr-helmet.md
├── 07-socket-io.md
├── 08-bootstrap-jss.md
└── 09-travis-coveralls-heroku.md
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE
================================================
### Type of issue: (feature suggestion, bug?)
### Chapter:
### If it's a bug:
Please try using the code provided instead of your own to see if that solves the issue. If it does, compare the content of relevant files to see if anything is missing in your version. Every chapter is automatically tested, so issues are likely coming from missing an instruction in the tutorial or a typo. Feel free to open an issue if there is a problem with instructions though.
================================================
FILE: .gitignore
================================================
.DS_Store
npm-debug.log
node_modules
================================================
FILE: .travis.yml
================================================
language: node_js
node_js: node
================================================
FILE: CHANGELOG.md
================================================
# Change Log
## v2.4.5
- Add `babel-plugin-flow-react-proptypes`.
- Add `eslint-plugin-compat`.
- Add JSS `composes` example.
## v2.4.4
- Update Immutable to remove the `import * as Immutable from 'immutable'` syntax.
- Declare Flow types outside of function params for React components.
- Improve Webpack `publicPath`.
## V2, up to v2.4.3
- Gulp is gone, replaced by NPM (Yarn) scripts.
- Express has been added, with template strings for static HTML. Gzip compression enabled.
- Support for development environment with Nodemon and production environment with PM2.
- Minification or sourcemaps depending on the environment via Webpack.
- Add Webpack Dev Server, with Hot Module Replacement and `react-hot-loader`.
- Add an asynchronous call example with `redux-thunk`.
- Linting / type checking / testing is not launched at every file change anymore, but triggered by Git Hooks via Husky.
- Some chapters have been combined to make it easier to maintain the tutorial.
- Replace Chai and Mocha by Jest.
- Add React-Router, Server-Side rendering, `react-helmet`.
- Rename all "dog" things and replaced it by "hello" things. It's a Hello World app after all.
- Add Twitter Bootstrap, JSS, and `react-jss` for styling.
- Add a Websocket example with Socket.IO.
- Add optional Heroku, Travis, and Coveralls integrations.
================================================
FILE: LICENSE.md
================================================
# MIT License
Copyright (c) 2017 Jonathan Verrecchia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# 从零开始构建 JavaScript 技术栈 - 中文版
阅读[从零开始构建 JavaScript 技术栈 - 英文版](https://github.com/verekia/js-stack-from-scratch)
[](https://github.com/verekia/js-stack-from-scratch/releases)
[](https://facebook.github.io/react/)
[](http://redux.js.org/)
[](https://github.com/ReactTraining/react-router)
[](https://flowtype.org/)
[](http://eslint.org/)
[](https://facebook.github.io/jest/)
[](https://yarnpkg.com/)
[](https://webpack.github.io/)
[](http://getbootstrap.com/)
欢迎来到 JavaScript 技术栈指南: **从零开始构建 JavaScript 技术栈**.
> 🎉 **这是本教程的第二版,与2016年发布的版本相比,更新了不少东西。欢迎查看 [更新日志](/CHANGELOG.md)!**
该指南讲的都是一些可实操的知识。即使是这样,阅读者还是需要具备一些简单的编程知识和 JavaScript 基础。 **教程的核心是把各种工具结合起来** 并且为每一种工具提供了 **最简单的例子**。 学习该教程后,你可以尝试 *从零编写你自己的 JavaScript 技术栈模板*。 因为本教程的核心是结合各种工具的使用,我并没有详细地讲解每一种工具该怎么用。如果你想要进一步了解这些工具的使用,请参考它们的文档或指南。
如果你只是想构建一个简单的 web 页面,你可能并不需要这个完整的技术栈(把 Browserify/Webpack + Babel + jQuery 结合起来就足够了);但如果你需要构建一个可伸缩的 web app,并且需要配置各种环境,那这个教程正好适合你。
该教程的大量描述都和 React 有关。如果你是个新手并且想要学习 React,[create-react-app](https://github.com/facebookincubator/create-react-app) 预设的配置能让你迅速搭建好 React 的运行环境。对于那些刚加入使用 React 团队的人来说,我建议他们使用 create-react-app 来快速搭建学习环境。在本教程中,你不会使用任何预配置,因为我希望你能够理解那些配置到底起了什么作用。
每一章的代码示例都包含在教程中,你可以通过 `yarn && yarn start` 来运行这些例子。不过,我建议你按着 **详细指南** 来把每一行代码都自己写一遍。
最终代码在 [JS-Stack-Boilerplate repository](https://github.com/verekia/js-stack-boilerplate), 和 [releases](https://github.com/verekia/js-stack-from-scratch/releases). 在线示例: [live demo](https://js-stack.herokuapp.com/) 。
可运行于 Linux, macOS, 以及 Windows。
> 注意:本教程写作与2017年5月,因此,部分库的 API 已经做了修改。但教程中 95% 的内容仍然是有效的。如果你在阅读的时候遇到了问题,请到 [issues](https://github.com/verekia/js-stack-from-scratch/issues?q=is%3Aopen+is%3Aissue+label%3Abug) 中查询。
## 目录
[01 - Node, Yarn, `package.json`](/tutorial/01-node-yarn-package-json.md#readme)
[02 - Babel, ES6, ESLint, Flow, Jest, Husky](/tutorial/02-babel-es6-eslint-flow-jest-husky.md#readme)
[03 - Express, Nodemon, PM2](/tutorial/03-express-nodemon-pm2.md#readme)
[04 - Webpack, React, HMR](/tutorial/04-webpack-react-hmr.md#readme)
[05 - Redux, Immutable, Fetch](/tutorial/05-redux-immutable-fetch.md#readme)
[06 - React Router, Server-Side Rendering, Helmet](/tutorial/06-react-router-ssr-helmet.md#readme)
[07 - Socket.IO](/tutorial/07-socket-io.md#readme)
[08 - Bootstrap, JSS](/tutorial/08-bootstrap-jss.md#readme)
[09 - Travis, Coveralls, Heroku](/tutorial/09-travis-coveralls-heroku.md#readme)
## 即将添加的内容
配置你的编辑器 (Atom 优先), MongoDB, Progressive Web App, E2E testing。
## 翻译
如果想添加你的翻译,请先阅读 [translation recommendations](/how-to-translate.md) 。
### V2
- [Bulgarian](https://github.com/mihailgaberov/js-stack-from-scratch) by [mihailgaberov](http://github.com/mihailgaberov)
- [Chinese (simplified)](https://github.com/yepbug/js-stack-from-scratch/) by [@yepbug](https://github.com/yepbug)
- [French](https://github.com/naomihauret/js-stack-from-scratch/) by [Naomi Hauret](https://twitter.com/naomihauret)
- [Italian](https://github.com/fbertone/guida-javascript-moderno) by [Fabrizio Bertone](https://github.com/fbertone) - [fbertone.it](http://fbertone.it)
查看 [进行中的翻译](https://github.com/verekia/js-stack-from-scratch/issues/147).
### V1
- [Chinese (simplified)](https://github.com/pd4d10/js-stack-from-scratch) by [@pd4d10](http://github.com/pd4d10)
- [Italian](https://github.com/fbertone/js-stack-from-scratch) by [Fabrizio Bertone](https://github.com/fbertone)
- [Japanese](https://github.com/takahashim/js-stack-from-scratch) by [@takahashim](https://github.com/takahashim)
- [Russian](https://github.com/UsulPro/js-stack-from-scratch) by [React Theming](https://github.com/sm-react/react-theming)
- [Thai](https://github.com/MicroBenz/js-stack-from-scratch) by [MicroBenz](https://github.com/MicroBenz)
## Credits
Created by [@verekia](https://twitter.com/verekia) – [verekia.com](http://verekia.com/).
License: MIT
================================================
FILE: how-to-translate.md
================================================
# How to translate this tutorial
Thank you for your interest in translating my tutorial! Here are a few recommendations to get started.
This tutorial is in constant evolution to provide the best learning experience to readers. Both the code and `README.md` files will change over time. It is great if you do a one-shot translation that won't evolve, but it would be even better if you could try to keep up with the original English version as it changes!
Here is what I think is a good workflow:
- Check if there is already an [ongoing translation](https://github.com/verekia/js-stack-from-scratch/issues/147) for your language. If that's the case, get in touch with the folks who opened it and consider collaborating. All maintainers will be mentioned on the English repo, so team work is encouraged! You can open issues on their translation fork project to offer your help on certain chapters for instance.
- Join the [Translations Gitter room](https://gitter.im/js-stack-from-scratch/Translations) if you're feeling chatty.
- Fork the main [English repository](https://github.com/verekia/js-stack-from-scratch).
- Post in [this issue](https://github.com/verekia/js-stack-from-scratch/issues/147) the language and URL of your forked repo.
- Translate the `README.md` files.
- Add a note somewhere explaining on the main `README.md` that this is a translation, with a link to the English repository. If you don't plan to make the translation evolve over time, you can maybe add a little note saying to refer to the English one for an up-to-date version of the tutorial. I'll leave that up to your preference.
- Submit a Pull Request to the English repo to add a link to your forked repository under the Translations section of the main `README.md`. It could look like this:
```md
## Translations
- [Language](http://github.com/yourprofile/your-fork) by [You](http://yourwebsite.com)
or
- [Language](http://github.com/yourprofile/your-fork) by [@You](http://twitter.com/yourprofile)
or
- [Language](http://github.com/yourprofile/your-fork) by [@You](http://github.com/yourprofile)
```
Since I want to reward you for your good work as much as possible, you can put any link you like on your name (to your personal website, Twitter profile, or Github profile for instance).
- After your original one-shot translation, if you want to update your repo with the latest change from the main English repo, [sync your fork](https://help.github.com/articles/syncing-a-fork/) with my repo. To make it easy to see what changed since your initial translation, you can use Github's feature to [compare commits](https://help.github.com/articles/comparing-commits-across-time/#comparing-commits). Set the **base** to the last commit from the English repo you used to translate, and compare it to **master**, like so:
https://github.com/verekia/js-stack-from-scratch/compare/c65dfa65d02c21063d94f0955de90947ba5273ad...master
That should give you a easy-to-read diff to see exactly what changed in `README.md` files since your translation!
================================================
FILE: mdlint.js
================================================
const glob = require('glob')
const markdownlint = require('markdownlint')
const config = {
'default': true,
'line_length': false,
'no-emphasis-as-header': false,
}
const files = glob.sync('**/*.md', { ignore: '**/node_modules/**' })
markdownlint({ files, config }, (err, result) => {
if (!err) {
const resultString = result.toString()
console.log('== Linting Markdown Files...')
if (resultString) {
console.log(resultString)
process.exit(1)
} else {
console.log('== OK!')
}
}
})
================================================
FILE: package.json
================================================
{
"name": "js-stack-from-scratch",
"version": "2.4.5",
"description": "JavaScript Stack from Scratch - Step-by-step tutorial to build a modern JavaScript stack",
"scripts": {
"test": "node mdlint.js"
},
"devDependencies": {
"glob": "^7.1.1",
"markdownlint": "^0.4.0"
},
"repository": "verekia/js-stack-from-scratch",
"author": "Jonathan Verrecchia - @verekia",
"license": "MIT"
}
================================================
FILE: tutorial/01-node-yarn-package-json.md
================================================
# 01 - Node, Yarn, and `package.json`
本章代码在 [这里](https://github.com/verekia/js-stack-walkthrough/tree/master/01-node-yarn-package-json)。
这一节中我们会配置 Node, Yarn, 初始的 `package.json` 文件,并尝试安装一个包。
## Node
> 💡 **[Node.js](https://nodejs.org/)** 是一个 JavaScript 运行环境。它多用于后端开发,但也可用于脚本。在前端开发环境中,它被用来做一大堆事情,比如:检查代码规范、测试以及组合文件。
在该教程中,我们处处用到 Node,所以你需要安装它。**macOS** 和 **Windows** 用户可访问[下载页](https://nodejs.org/en/download/current/) ,Linux 版本系统用户 [安装地址](https://nodejs.org/en/download/package-manager/) 。
举例,**Ubuntu / Debian** 用户,请运行下面的命令来安装 Node:
```sh
curl -sL https://deb.nodesource.com/setup_7.x | sudo -E bash -
sudo apt-get install -y nodejs
```
Node 版本需要高于 6.5.0。
## Node 版本管理工具
如果你想灵活切换 Node 版本,请查看 [NVM](https://github.com/creationix/nvm) 或 [tj/n](https://github.com/tj/n)。
## NPM
NPM 是 Node 默认的包管理器。安装 Node 的时候,它也会自动安装。包管理器用来安装和管理包(包就是你或者他人写的代码模块)。在该教程中,我们会用到大量包,不过,我们用到的是另一个包管理器 `Yarn`。
## Yarn
> 💡 **[Yarn](https://yarnpkg.com/)** 是一个 Node.js 的包管理器,与 NPM 相比,它更快,提供离线支持,依赖关系确定性 [更多](https://yarnpkg.com/en/docs/yarn-lock).
自2016年10月[发布](https://code.facebook.com/posts/1840075619545360)以来,它一跃成为 JavaScript 社区流行的包管理器。如果你坚持使用 NPM,那么请把本教程中所有的 `yarn add` 和 `yarn add --dev` 命令,替换为 `npm install --save` 和 `npm install --save-dev`。
根据下面的[说明](https://yarnpkg.com/en/docs/install)来安装 Yarn。为了[避免](https://github.com/yarnpkg/yarn/issues/1505)依赖于其他包管理器的问题,如果你的系统是 macOS 或者 Unix,我建议你使用 **脚本安装**。
```sh
curl -o- -L https://yarnpkg.com/install.sh | bash
```
## `package.json`
> 💡 **[package.json](https://yarnpkg.com/en/docs/package-json)** 用来描述和配置你的 JavaScript 项目。它包含了项目的基本信息(项目名、版本号、贡献者、证书等等),工具的配置以及一系列可运行的 *任务*。
- 创建一个新的工作文件夹, `cd` 进入。
- 运行 `yarn init` 回答问题(或者运行 `yarn init -y` 跳过所有问题)来自动创建 `package.json` 文件。
下面是一个初始化的 `package.json` 文件,我会在教程中使用它。
```json
{
"name": "your-project",
"version": "1.0.0",
"license": "MIT"
}
```
## Hello World
- 创建 `index.js` 文件,加入一行代码: `console.log('Hello world')`
🏁 在该文件夹下运行 `node .` (Node 默认执行 `index.js`). 命令行正确的输出应该是 "Hello world"。
**注意**:看到 🏁 这个 emoji 表情了么?每次你到达 **检查点** 的时候,我都会用这个表情。有时候,我们需要连续对代码做出一大堆更改,在到达检查点之前,你的代码可能还不能正常运行。
## `start` 脚本
运行 `node .` 来跑我们的程序有点太 low 了。接下来,我们要用 NPM/Yarn 脚本来执行代码。这样,即使我们的程序变得越来越复杂,我们也能通过 `yarn start` 来运行程序。
- 在 `package.json` 中, 添加 `scripts` 对象,如下所示:
```json
{
"name": "your-project",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"start": "node ."
}
}
```
`start` 是我们给 *任务* 的名字。接下来的教程中,我们会在 `scripts` 对象中,创建一系列不同的任务。一般来说, `start` 是一个应用的默认任务名;其他标准的任务名称有 `stop` 和 `test`。
`package.json` 必须是一个正确的 JSON 文件,你不能随意添加逗号。所以,手动修改 `package.json` 的时候,一定要小心。
🏁 运行 `yarn start`。正确输出是: `Hello world`.
## Git 和 `.gitignore`
- 运行 `git init` 来初始化 git 仓库。
- 创建 `.gitignore` 文件,并添加内容。
```gitignore
.DS_Store
/*.log
```
`.DS_Store` 文件是 macOS 系统自动生成的文件,你不需要在 git 仓库中提交这些文件。
`npm-debug.log` 和 `yarn-error.log` 是包管理器出错的时候生成的文件,仓库中也不需要提交这些文件。
## 安装并使用一个包
在这一节中,我们将要安装并使用一个包。简单来说,一个“包”是他人写的一段代码,你可以在你自己的代码中使用它。这段代码可以是任何东西。下面的例子中,我们将使用一个和颜色相关的包。
- 运行 `yarn add color` 来安装社区提供的包 `color`
打开 `package.json` 会发现 Yarn 在 `dependencies` 中自动添加了 `color`。
`node_modules` 文件夹被自动创建,它用来存储刚刚下载的包。
- 在 `.gitignore` 添加 `node_modules/`
你可能已经注意到,`yarn.lock` 已经被 Yarn 自动生成了。你需要在仓库中提交这个文件,因为它保证团队成员使用的包属于同一个版本。如果你坚持使用 NPM,相对应的是使用 `npm shrinkwrap` 命令,这个命令会创建一个 `npm-shrinkwrap.json` 文件,与 `yarn.lock` 作用相同。
- 在 `index.js` 文件中添加如下代码:
```js
const color = require('color')
const redHexa = color({ r: 255, g: 0, b: 0 }).hex()
console.log(redHexa)
```
🏁 运行 `yarn start`。 正确输出: `#FF0000`。
恭喜!你已经成功地安装并运行了一个包。
本节中使用的 `color` 只是为了说明怎样使用一个简单的包。我们不再需要他,可以用命令卸载:
- 运行 `yarn remove color`
## 两种不同的依赖
包的依赖有两种,`"dependencies"` 和 `"devDependencies"`:
**Dependencies** 是那些在应用中被直接使用的包 (例如 React, Redux, Lodash, jQuery, 等等)。运行命令 `yarn add [package]` 来安装这些包。
**Dev Dependencies** 是那些开发时或者是打包时要用到的包 (例如 Webpack, SASS, linters, testing frameworks, 等等)。运行命令 `yarn add --dev [package]` 来安装这些包。
下一章: [02 - Babel, ES6, ESLint, Flow, Jest, Husky](02-babel-es6-eslint-flow-jest-husky.md#readme)
回到[目录](https://github.com/verekia/js-stack-from-scratch#table-of-contents)。
================================================
FILE: tutorial/02-babel-es6-eslint-flow-jest-husky.md
================================================
# 02 - Babel, ES6, ESLint, Flow, Jest, and Husky
本章代码在 [这里](https://github.com/verekia/js-stack-walkthrough/tree/master/02-babel-es6-eslint-flow-jest-husky)。
我们将在本章使用 ES6 语法,与 ES5 语法相比,它更加优雅。几乎所有的浏览器和 JS 环境都能理解 ES5,但 ES6 还没有得到广泛支持。为此,一个名为 Babel 的工具应运而生。
## Babel
> 💡 **[Babel](https://babeljs.io/)** 是一个将 ES6 代码 转换为 ES5 代码的编译器(一些其他语法,比如 JSX 语法也能够被编译)。 它非常模块化,可被运用于各种 [环境](https://babeljs.io/docs/setup/)。目前为止,它是 React 社区最受推崇的 ES5 编译器。
- 把 `index.js` 移动到新创建的 `src` 文件夹下。 在该文件夹下的文件,使用 ES6 语法。 删除和 `color` 有关的代码,用下面的内容替换:
```js
const str = 'ES6'
console.log(`Hello ${str}`)
```
我们这里使用的 ES6 语法是 *模板字符串*,它允许我们在字符串中通过 `${}` 来插入变量。注意模板语法使用 **反引号**.
- 运行 `yarn add --dev babel-cli` 安装 Babel CLI(命令行工具)。
Babel CLI 有 [两种执行方式](https://babeljs.io/docs/usage/cli/): `babel`,将 ES6 文件转换为 ES5 文件; `babel-node` 可以替换 `node`,用来轻量级地直接执行 ES6 文件。 `babel-node` 适用于开发,但对于生产环境来说,它太笨重了。在本章中,我们使用 `babel-node` 来配置开发环境;接下来,我们将使用 `babel` 来为生产环境打包 ES5 文件。
- 在 `package.json` `start` 脚本中, 用 `babel-node src` 来替换 `node .` (`index.js` 是 Node 默认执行的文件,因此我们可以省略 `index.js`)。
如果你现在运行 `yarn start` ,输出应该没啥问题;不过 Babel 这时候并起到什么作用。因为我们还没有告诉 Babel 进行何种转换。输出正确的唯一原因是 Node 原生支持了 ES6 语法。然而,某些浏览器以及老版本的 Node 却不一定支持这些最新的语法。
- 运行 `yarn add --dev babel-preset-env` 来安装一个叫 `env` 的 Babel preset 包,它包含了大部分 Babel 支持转化的 ECMAScript 语法配置。
- 在根目录创建 `.babelrc` 文件,它是一个用来配置 Babel 的 JSON 文件。 添加如下内容,使 Babel 使用 `env` preset:
```json
{
"presets": [
"env"
]
}
```
🏁 `yarn start` 现在能够运行成功,并且 Babel 确实起了点作用。但是为了轻量开发,我们使用了 `babel-node`,所以我们还不能搞清楚 Babel 到底干了什么。 不过,当你读到这一节 [ES6 模块语法](#the-es6-modules-syntax) 时,你就会明白 Babel 的作用了。
## ES6
> 💡 **[ES6](http://es6-features.org/)**: ES6 是 JavaScript 最振奋人心的更新。ES6 新语法太多,我们不可能完整地罗列在这里;不过,我们会简单地介绍一下 `class`, `const` , `let`, 字符串语法以及箭头函数 (`(text) => { console.log(text) }`)。
### 创建 ES6 class
- 创建一个新文件 `src/dog.js`,使用 ES6 class 语法:
```js
class Dog {
constructor(name) {
this.name = name
}
bark() {
return `Wah wah, I am ${this.name}`
}
}
module.exports = Dog
```
如果你之前用过面向对象语言,上面的代码对你来说应该并不陌生。但对 JavaScript 来说,这种语法确实挺新奇的。我们把这个类赋值给 `module.exports`,就导出了它。
在 `src/index.js` 文件中,添加如下代码:
```js
const Dog = require('./dog')
const toby = new Dog('Toby')
console.log(toby.bark())
```
注意,使用我们自己写的包,和使用社区提供的包 `color` 时,引用包的方式是不一样的。当引用我们自己的文件时,我们在 `require()` 中,使用的是相对路径 `./`。
🏁 运行 `yarn start`,正确输出 "Wah wah, I am Toby"。
### ES6 模块语法
我们将用 `import Dog from './dog'` 来替换 `const Dog = require('./dog')`,这是最新的 ES6 模块语法(和 "CommonJS" 模块与法相对应)。但现在 NodeJS 还并不支持这种语法,所以如果代码仍能正常运行的话,就说明 Babel 确实正确处理了我们的代码。
同时,在 `dog.js` 文件中, 用 `export default Dog` 替换 `module.exports = Dog` 。
🏁 `yarn start` 应该能够正常运行,并输出 "Wah wah, I am Toby"。
## ESLint
> 💡 **[ESLint](http://eslint.org)** 是 ES6 代码的检查器。它会根据代码规范,给出合理的提示。ESLint 给出的提示,也能帮你更好地学习 JavaScript。
ESLint 需要和各种 *规范* 结合使用,[规范](http://eslint.org/docs/rules/) 可不少哦。我选择的是 Airbnb 提供的代码规范。为了使用规范,我们需要先安装一些插件。
查阅 Airbnb 最新 [说明](https://www.npmjs.com/package/eslint-config-airbnb),安装配置包以及它的依赖包。 截至 2017-02-03, Airbnb 建议使用以下命令行:
```sh
npm info eslint-config-airbnb@latest peerDependencies --json | command sed 's/[\{\},]//g ; s/: /@/g' | xargs yarn add --dev eslint-config-airbnb@latest
```
这条命令能安装好所有需要的包,并且在 `package.json` 文件中自动添加了 `eslint-config-airbnb`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y` 以及 `eslint-plugin-react` 。
**注意**: 我把命令中的 `npm install` 替换为 `yarn add`,然而,Windows 系统并不支持。所以如果你遇到了这个问题,请查看 `package.json` 文件,然后用 `yarn add --dev packagename@^#.#.#` 来手动安装所需要的包。 `#.#.#` 代表 `package.json` 每个包的版本号。
- 项目根目录下,创建 `.eslintrc.json`,并添加如下代码:
```json
{
"extends": "airbnb"
}
```
为了使用 `eslint` 命令行,我们需要安装这个包:
- 运行 `yarn add --dev eslint`
【译者注】目前,airbnb 插件和 eslint4 并没有完全兼容,如果用教程中的方法,可能会有报错。[链接](https://github.com/airbnb/javascript/issues/1454) 所以,我建议安装 eslint3。 `yarn add eslint@3.19.0 --dev`
修改 `package.json` 的 `scripts` ,添加一个 `test` 任务:
```json
"scripts": {
"start": "babel-node src",
"test": "eslint src"
},
```
这条命令的意思是检测 `src` 目录下所有的 JS 文件。
我们会用 `test` 任务运行一系列命令来验证我们的代码,包括之后的类型检查和单元测试。
- 运行 `yarn test`,你应该看到 `index.js` 文件中的错误有:分号缺失,使用 `console.log()` 。该文件顶部添加 `/* eslint-disable no-console */` 来允许我们在该文件中使用 `console` 。
**注意**: 如果你是 Windows 系统用户,请保证你已经设置你的 Git 和编辑器使用 Unix 的 LF 换行模式,而不是 Windows 默认的 CRLF 换行模式。如果你的项目只会跑在 Windows 上,你可以在 ESLint 配置文件的 `rules` 中添加 `"linebreak-style": [2, "windows"]` 来强制使用 CRLF 换行模式。
### 分号
这可能是 JS 社区中最容易引起撕逼的话题了,我们也来唠唠。因为 JS 的自动分号插入机制,你可以省略分号。我觉得这个问题只关个人喜好,而无关对错。如果你喜欢 Python, Ruby, 或者 Scala,你可能挺享受省略分号的写法。 但要是你喜欢的是 Java, C#, 或者 PHP,那你应该更喜欢加上分号。
大多数人出于习惯会在写 JS 的时候加上分号。最开始我也是分号党,直到有一天我看了 Redux 文档中的代码示例,然后尝试了一下不写分号。刚开始的感觉有点奇怪,但只是因为不习惯;不过在写了一天代码后,我就变成了一个不折不扣的无分号党。在我看来,不写分号更直观,写起来也更简单。
我建议你读一下 [关于分号的 ESLint 文档](http://eslint.org/docs/rules/semi)。正如在文档中提到的,如果你也想成为一个无分号党,你应该知道在一些极端情况下,分号又是必要的。 ESLint 能够帮助你应对这些极端情况。你需要在 `.eslintrc.json` 中添加 `no-unexpected-multiline` 规则:
```json
{
"extends": "airbnb",
"rules": {
"semi": [2, "never"],
"no-unexpected-multiline": 2
}
}
```
🏁 现在再运行 `yarn test`,就应该没什么错误提示了。在不需要分号的地方加一下分号,看看有没有错误提示。
如果你们坚持使用分号,我表示理解;不过,这可能让你们在使用这个教程的时候不太方便。如果你看这个教程只是为了学习一下,那我保证你在学习过程中不使用分号,是可以忍一忍的;如果你想用教程提供的模板并且是个分号党,你可能要在某些地方做些修改 —— 有了 ESLint 的提示,你改得应该也挺快的。
### Compat
如果你想让你的项目支持更多的浏览器,但不知道要支持的浏览器是否支持某个 API 怎么办?用 [Compat](https://github.com/amilajack/eslint-plugin-compat)!请查看根据 [Can I Use](http://caniuse.com/) 制定的 [浏览器列表](https://github.com/ai/browserslist)
- 运行 `yarn add --dev eslint-plugin-compat`
- 在 `package.json` 中添加如下内容,告诉插件,只要是市场占有率超过百分之一的浏览器,我们都想支持。
```json
"browserslist": ["> 1%"],
```
- 相应的,`.eslintrc.json` 文件也要做出修改:
```json
{
"extends": "airbnb",
"plugins": [
"compat"
],
"rules": {
"semi": [2, "never"],
"no-unexpected-multiline": 2,
"compat/compat": 2
}
}
```
为了试试插件好不好用,你可以在你的代码中使用 `navigator.serviceWorker` 或 `fetch` 。一般来说, ESLint 这时候会给出错误提示。
### 编辑器中的 ESLint
本章中我们在命令行中配置了 ESLint,在构建代码或提交代码的时候,会得到错误提示。其实,你也可以让你的 IDE 使用你的配置,这样你就能得到更及时的错误反馈了。别使用 IDE 原生的 ES6 错误提示!通过相应的配置,让编辑器使用你指定的包;这样,你才能在使用其他的编辑器时,得到同样的提示。
(译者注:如果你的编辑器是 Atom 并且安装了 lint 插件,那么在配置完之后,编辑器会自动根据 `.eslintrc.json` 中的配置来检测代码;初次生成配置文件,可能不会检测,可以尝试重启 Atom)
## Flow
> 💡 **[Flow](https://flowtype.org/)**: Facebook 提供的一个静态类型检查器。举个例子,如果你把一个字符串类型的值赋值给一个数值类型的变量,它就会报错。
现在,我们的 JS 代码是标准的 ES6 格式。Flow 能够检查这样的代码,但为了发挥它的最大威力,我们要在我们的代码中加入类型的注解;但这样,我们的代码就不那么符合标准了。为了让 Babel 和 ESLint 在解析我们的代码时不崩溃,我们需要通过配置让它们理解注释。
- 运行 `yarn add --dev flow-bin babel-preset-flow babel-eslint eslint-plugin-flowtype`
`flow-bin` 是在 `scripts` 任务中用的, `babel-preset-flow` 帮助 Babel 理解 Flow 注释, `babel-eslint` 让 ESLint *依赖于 Babel 解析器*, `eslint-plugin-flowtype` 是一个用来检查注释错误的 ESLint 插件。
- 像下面这样修改 `.babelrc` 文件:
```json
{
"presets": [
"env",
"flow"
]
}
```
- `.eslintrc.json` 也得改:
```json
{
"extends": [
"airbnb",
"plugin:flowtype/recommended"
],
"plugins": [
"flowtype",
"compat"
],
"rules": {
"semi": [2, "never"],
"no-unexpected-multiline": 2,
"compat/compat": 2
}
}
```
**注意**: `plugin:flowtype/recommended` 已经告诉 Babel 该用什么解析器了;不过,要是你想更明确点,可以在 `.eslintrc.json` 加上 `"parser": "babel-eslint"` 。
这一节的东西好像有点多,你可以先花几分钟消化一下。我到现在还感到很惊奇,ESLint 竟然能用 Babel 的解析器来解析 Flow 的注释!这俩工具实在是太模块化了。
- 把 `flow` 加入到 `test` 任务:
```json
"scripts": {
"start": "babel-node src",
"test": "eslint src && flow"
},
```
- 根目录下创建一个 `.flowconfig` 文件:
```flowconfig
[options]
suppress_comment= \\(.\\|\n\\)*\\flow-disable-next-line
```
【译者注】因为 `eslint-plugin-jsx-a11y` 版本与 `flow` 不完全兼容,所以,需要在 `.flowconfig` 中,加入如下内容:
```
[ignore]
.*/node_modules/eslint-plugin-jsx-a11y/*
```
如果你想让 Flow 忽略下一行代码,你可以用上面的注释方法;它的用法和 `eslint-disable` 很像:
```js
// flow-disable-next-line
something.flow(doesnt.like).for.instance()
```
配置部分差不多可以告一段落了。
- 在 `src/dog.js` 加入注释:
```js
// @flow
class Dog {
name: string
constructor(name: string) {
this.name = name
}
bark() {
return `Wah wah, I am ${this.name}`
}
}
export default Dog
```
`// @flow` 注释告诉 Flow: 这个文件需要进行类型检查。为了测试,我们的注释基本上就是在参数或方法名后加一个冒号,关于 Flow 注释的更多使用方法,请查看 [文档](https://flowtype.org/docs/quick-reference.html) 。
- 在 `index.js` 文件里也加上 `// @flow` 。
`yarn test` 现在不光进行规范检查,还进行类型检查。
你可以进行下面的尝试:
- 在 `dog.js` 文件中,替换 `constructor(name: string)` 为 `constructor(name: number)`, 然后运行 `yarn test`。如果 **Flow** 提示类型出错,那说明 Flow 运行成功了。
- 替换 `constructor(name: string)` 为 `constructor(name:string)`,运行 `yarn test`。如果 **ESLint** 运行成功地话,它会提示你在冒号后添加一个空格。
🏁 如果你能得到以上两个错误,那说明你的 ESLint 和 Flow 配置成功了;记得把刚刚修改的东西改回去。
### 在编辑器中配置 Flow
和 ESLint 一样,你也应该在你的编辑器中配置 Flow,从而得到及时反馈。
## Jest
> 💡 **[Jest](https://facebook.github.io/jest/)**: Facebook 提供的一个 JS 测试库,配置简单,一步到位,甚至还能用来测试 React 组件。
- 运行 `yarn add --dev jest babel-jest` 安装 Jest 以及对应的 Babel 包。
- 在 `.eslintrc.json` 加入如下内容之后,你就不用再在测试文件里引用 Jest 包了。
```json
"env": {
"jest": true
}
```
- 创建 `src/dog.test.js` 文件,代码如下:
```js
import Dog from './dog'
test('Dog.bark', () => {
const testDog = new Dog('Test')
expect(testDog.bark()).toBe('Wah wah, I am Test')
})
```
- 把 `jest` 添加到 `test` 任务:
```json
"scripts": {
"start": "babel-node src",
"test": "eslint src && flow && jest --coverage"
},
```
`--coverage` 让 Jest 自动生成测试覆盖率信息。观察覆盖率信息,就能知道哪些文件缺乏测试了。覆盖率信息保存在 `coverage` 文件夹下。
- 把 `/coverage/` 添加到 `.gitignore`
🏁 运行 `yarn test`,在规范检测和类型检测之后,它应该会进行覆盖率测试并展示覆盖率表,因为测试通过,展示的结果应该是绿色的。
## 用 Husky 添加 Git 钩子
> 💡 **[Git Hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks)**: 在特定操作(例如 push 或者 commit 操作)前会被执行的操作。
目前,`test` 任务帮助我们进行代码测试;为了避免向代码仓库中提交垃圾代码,我们需要在每一次 `git commit` 和 `git push` 前,自动检测代码。
[Husky](https://github.com/typicode/husky) 一个便捷的设置 Git 钩子的包。
- 运行 `yarn add --dev husky`
我们只需要在 `scripts` 添加两个任务 —— `precommit` 和 `prepush`:
```json
"scripts": {
"start": "babel-node src",
"test": "eslint src && flow && jest --coverage",
"precommit": "yarn test",
"prepush": "yarn test"
},
```
🏁 现在试着 commit 或者 push 代码, `test` 任务就会自动执行。
如果运行出错,那一般是因为 `yarn add --dev husky` 这个命令没有正确安装 Git 钩子。我自己没遇到过这种情况,但其他运气不怎么好的人遇到过。 如果你是不幸者之一,试试 `yarn add --dev husky --force`, 可以的话,记得在这里说明一下你的状况 [this issue](https://github.com/typicode/husky/issues/84)。
**注意**:如果你是在 commit 之后执行 push 操作,为了避免重复测试,你应该使用 `git push --no-verify` 命令。
下一章: [03 - Express, Nodemon, PM2](03-express-nodemon-pm2.md#readme)
回到 [上一章](01-node-yarn-package-json.md#readme) 或者 [目录](https://github.com/verekia/js-stack-from-scratch#table-of-contents).
================================================
FILE: tutorial/03-express-nodemon-pm2.md
================================================
# 03 - Express, Nodemon, and PM2
本章代码在 [这里](https://github.com/verekia/js-stack-walkthrough/tree/master/03-express-nodemon-pm2)。
本章我们会创建一个 web 服务器,并同时为这个服务器配置开发和生产两种模式。
## Express
> 💡 **[Express](http://expressjs.com/)** 是 Node 社区最流行的框架,API 非常简单,但被各种 *中间件* 扩展后,威力巨大。
接下来配置一个 Express 服务器来提供 HTML 和 CSS。
- 删除 `src` 目录下所有文件。
创建以下文件和文件夹
- 创建 `public/css/style.css` :
```css
body {
width: 960px;
margin: auto;
font-family: sans-serif;
}
h1 {
color: limegreen;
}
```
- 创建 `src/client/` 文件夹和`src/server/`文件夹
- 创建 `src/shared/` 文件夹
这个文件夹下存放 *同构/通用* 的 JS 代码 - 可以同时被客户端和服务端使用的代码。常见的例子是 *路由代码*,在该教程中,我们会在发起异步请求的时候用到路由。现在为了举例,我们只会在该文件夹下存放一些配置常量。
- 创建 `src/shared/config.js` 文件:
```js
// @flow
export const WEB_PORT = process.env.PORT || 8000
export const STATIC_PATH = '/static'
export const APP_NAME = 'Hello App'
```
如果你的 Node 进程有一个 `process.env.PORT` 环境变量 (比如当你部署到 Heroku 的时候),端口号就是这个环境变量的值。如果没有这个环境变量,我们就把端口号设为 `8000`。
- 创建 `src/shared/util.js` 文件:
```js
// @flow
// eslint-disable-next-line import/prefer-default-export
export const isProd = process.env.NODE_ENV === 'production'
```
这个简单的工具用来判断当前环境是否是生产环境。`// eslint-disable-next-line import/prefer-default-export` 这行注释是因为我们目前只导出了一个变量;当你添加了其他导出内容后,这行注释可以删掉。
- 运行 `yarn add express compression`
`compression` 是一个用来在服务端开启 Gzip 压缩的 Express 中间件。
- 创建 `src/server/index.js` :
```js
// @flow
import compression from 'compression'
import express from 'express'
import { APP_NAME, STATIC_PATH, WEB_PORT } from '../shared/config'
import { isProd } from '../shared/util'
import renderApp from './render-app'
const app = express()
app.use(compression())
app.use(STATIC_PATH, express.static('dist'))
app.use(STATIC_PATH, express.static('public'))
app.get('/', (req, res) => {
res.send(renderApp(APP_NAME))
})
app.listen(WEB_PORT, () => {
// eslint-disable-next-line no-console
console.log(`Server running on port ${WEB_PORT} ${isProd ? '(production)' : '(development)'}.`)
})
```
以上代码没什么好说的,基本就是一个 Express 的 Hello World 教程。我们使用了两个静态文件目录: `dist` 用来存放工具转换后生成的文件,`public` 用来存储固有文件。
- 创建 `src/server/render-app.js` :
```js
// @flow
import { STATIC_PATH } from '../shared/config'
const renderApp = (title: string) =>
`
${title}
${title}
`
export default renderApp
```
我们创建了一个方法,以 `title` 为参数,并把参数插入到页面的 `title` 和 `h1` 标签中,最后返回了 HTML 字符串。我们用 `STATIC_PATH` 常量作为所有静态资源的基础目录。
### HTML 模板字符串语法在 Atom 中高亮 (可选)
如果你的编辑器允许的话,模板字符串中的 HTML 代码可以实现语法高亮。在 Atom 中,如果你的模板字符串以 `html` 开头或结束(例如以`ilovehtml`结束)的话,Atom 会自动高亮其中的字符串。为了利用这一点,我有时候会用 `common-tags` 包的 `html` 标签。
```js
import { html } from `common-tags`
const template = html`
Wow, colors!
`
```
我没有在教程的模板中添加这个技巧,是因为这个技巧目前好像只在 Atom 中适用,并且不太完美。当然,一些 Atom 用户可能觉得很受用。
好了,回到主题!
- 在 `package.json` 中修改 `start` : `"start": "babel-node src/server",`
🏁 运行 `yarn start` ,在浏览器中打开 `localhost:8000`。如果运行正常的话,你应该看到的是一个标题和内容都是 Hello App 的页面。
**注意**: 某些进程 —— 例如服务器进程 —— 可能会阻止命令行的输入。按下 **Ctrl+C** 就能结束进程。如果你想保持进程运行,也可以新开一个命令行窗口。此外,你也可以让这些进程在后台运行 —— 不过这已经超过了本教程讨论的范围。
## Nodemon
> 💡 **[Nodemon](https://nodemon.io/)** 当文件更改时,这个工具会自动重启服务器。
我们会在 **开发模式** 下使用这个包。
- 运行 `yarn add --dev nodemon`
- 修改 `scripts` :
```json
"start": "yarn dev:start",
"dev:start": "nodemon --ignore lib --exec babel-node src/server",
```
`start` 任务现在只是指向另一个任务 —— `dev:start`;这为我们提供了一层抽象,使我们之后可以切换默认任务。
在 `dev:start` 中, `--ignore lib` 的作用是忽略 `lib` 文件夹的修改;如果该文件夹下的文件发生更改, *无需* 重启服务器。现在你还没有这个目录,不过我们将在下一节中生成它。Nodemon 的运行默认依赖于 `node` ,但我们让它使用 `babel-node` ,这样我们就能愉快地书写 ES6/Flow 代码了。
🏁 运行 `yarn start` 然后打开 `localhost:8000`。 修改 `src/shared/config.js` 文件下的 `APP_NAME` 常量,我们的修改会触发服务器重启。刷新页面,就能看到更改已经生效。注意,服务器重启和我们即将要说的 *热替换* 是不同的概念。现在我们还需要手动刷新页面,但至少不需要干掉进程然后手动重启了。
## PM2
> 💡 **[PM2](http://pm2.keymetrics.io/)** 是 Node 的进程管理器,保证生产环境下进程的正常运行,同时允许你处理和监控进程。
我们会在 **生产模式** 下使用 PM2。
- 运行 `yarn add --dev pm2`
生产环境下,服务器越高效越好。每一次执行代码,`babel-node` 都会触发 Babel 的代码转换,因此,生产环境下不能用 `babel-node`。我们需要事先用 Babel 转换好代码,运行在服务器上的,应该是已经转换好的 ES5 代码。
Babel 的一大应用就是把一个文件夹(通常命名为 `src`)下的 ES6 代码转换为 ES5 代码,并保存在另一个文件夹下(通常命名为 `lib`)。
`lib` 文件夹是自动生成的;每次生成新文件前,先删除老文件,是公认的最佳实践 —— 否则,一些老文件可能还保留在该文件夹下。`rimraf` 是一个用来实现删除功能的包,特点是用法简单且跨平台。
- 运行 `yarn add --dev rimraf`
把 `prod:build` 任务添加到 `scripts`:
```json
"prod:build": "rimraf lib && babel src -d lib --ignore .test.js",
```
- 运行 `yarn prod:build` 命令,除了 `.test.js`(`.test.jsx`文件也会被忽略)文件外,其他文件应该都被转化并保存于 `lib` 文件夹下了。
- 把 `/lib/` 添加到 `.gitignore`
最后一件事:我们需要传一个名为 `NODE_ENV` 的环境变量给 PM2。Unix 系统用户可以直接用 `NODE_ENV=production pm2` ,但 Windows 用的却是另一个语法。为了通用,我们需要安装 `cross-env` 包。
- 运行 `yarn add --dev cross-env`
修改 `package.json` :
```json
"scripts": {
"start": "yarn dev:start",
"dev:start": "nodemon --ignore lib --exec babel-node src/server",
"prod:build": "rimraf lib && babel src -d lib --ignore .test.js",
"prod:start": "cross-env NODE_ENV=production pm2 start lib/server && pm2 logs",
"prod:stop": "pm2 delete server",
"test": "eslint src && flow && jest --coverage",
"precommit": "yarn test",
"prepush": "yarn test"
},
```
🏁 运行 `yarn prod:build`,然后运行 `yarn prod:start`。浏览 `http://localhost:8000/` 就能看到你的 APP 了。命令行输出的 logs 内容,应该是 "Server running on port 8000 (production)."。注意:使用 PM2,你的进程是在后台运行的。如果你按下 Ctrl+C,只会终止 `pm2 logs` 命令,但是你的服务器仍在运行。如果想停掉服务器,应该运行 `yarn prod:stop` 命令。
在 push 代码到仓库前,为了保证代码能正常编译,我们应该先运行一下 `prod:build`。但并不是每一次 commit 操作都需要重新编译,所以我建议把它添加到 `prepush` 任务:
```json
"prepush": "yarn test && yarn prod:build"
```
🏁 运行 `yarn prepush` 或者只是 push 代码来触发编译操作。
**注意**: 因为我们没有进行测试,所以 Jest 可能会“抱怨”一下~暂时不去管它!
下一章: [04 - Webpack, React, HMR](04-webpack-react-hmr.md#readme)
回到 [上一章](02-babel-es6-eslint-flow-jest-husky.md#readme) 或者 [目录](https://github.com/verekia/js-stack-from-scratch#table-of-contents).
================================================
FILE: tutorial/04-webpack-react-hmr.md
================================================
# 04 - Webpack, React, and Hot Module Replacement
本章代码在 [这里](https://github.com/verekia/js-stack-walkthrough/tree/master/04-webpack-react-hmr).
## Webpack
> 💡 **[Webpack](https://webpack.js.org/)** 是一个 *用来打包的模块*。它能把各种各样的文件打包进一个文件(通常情况下是这样)内,你只需要引用这一个文件就可以。
下面是一个用 Webpack 来打包的 *hello world* 示例。
- 在 `src/shared/config.js` 文件,添加如下内容:
```js
export const WDS_PORT = 7000
export const APP_CONTAINER_CLASS = 'js-app'
export const APP_CONTAINER_SELECTOR = `.${APP_CONTAINER_CLASS}`
```
- 创建 `src/client/index.js` :
```js
import 'babel-polyfill'
import { APP_CONTAINER_SELECTOR } from '../shared/config'
document.querySelector(APP_CONTAINER_SELECTOR).innerHTML = 'Hello Webpack!
'
```
如果你需要在你的代码里应用 ES 的最新特点,比如 `Promise`,那你需要先在模块里导入 [Babel Polyfill](https://babeljs.io/docs/usage/polyfill/)。
- 运行 `yarn add babel-polyfill`
如果现在运行 ESLint,应该有错误提示 `document` 未定义。
- 修改 `.eslintrc.json` ,允许 `window` 和 `document` 等浏览器对象的使用。
```json
"env": {
"browser": true,
"jest": true
}
```
现在,需要把我们用 ES6 写的客户端代码打包成 ES5 文件。
- 创建 `webpack.config.babel.js` :
```js
// @flow
import path from 'path'
import { WDS_PORT } from './src/shared/config'
import { isProd } from './src/shared/util'
export default {
entry: [
'./src/client',
],
output: {
filename: 'js/bundle.js',
path: path.resolve(__dirname, 'dist'),
publicPath: isProd ? '/static/' : `http://localhost:${WDS_PORT}/dist/`,
},
module: {
rules: [
{ test: /\.(js|jsx)$/, use: 'babel-loader', exclude: /node_modules/ },
],
},
devtool: isProd ? false : 'source-map',
resolve: {
extensions: ['.js', '.jsx'],
},
devServer: {
port: WDS_PORT,
},
}
```
这个文件描述如何打包:`entry` 是我们 app 的入口,`output.filename` 是最终生成的打包文件名,`output.path` 和 `output.publicPath` 代表最终文件夹和 URL 地址。最终自动生成的内容会被打包进 `dist` 文件夹。`module.rules` 告诉 Webpack 对匹配到的文件进行何种操作。比如,我们要求 Webpack 用 `babel-loader` 来处理 `.js` 和 `.jsx` 文件; `node_modules` 文件夹里的内容不需要处理。`resolve` 指明 Webpack 自动识别哪些后缀 —— 当我们用 `import` 导入的时候,文件的扩展名就可以省略了。最后,我们声明了 Webpack 开发服务器的端口。
**注意**: `.babel.js` 扩展名利用了 Webpack 的一个特点:有这个扩展名的配置文件,会自动应用 Babel 转换,所以我们可以在配置文件中使用 ES6 语法。
`babel-loader` 是 Webpack 用来转换 ES6 代码的一个插件。我们在教程开头就做过转换代码的操作,不过这一次,代码需要运行在浏览器上,而不是跑在服务器上。
- 运行 `yarn add --dev webpack webpack-dev-server babel-core babel-loader`
`babel-core` 是 `babel-loader` 的一个依赖。
- 把 `/dist/` 添加到 `.gitignore`
**注意**: 截止2018年3月12日,`CLI`已经移到一个单独的包`webpack-cli`,因此还需要单独安装`yarn add webpack-cli -D`。
### 更新任务
为了在开发环境中使用热替换技术,我们需要用到 `webpack-dev-server`;在生产环境中,我们用到的则是 `webpack` 生成的包。无论在开发环境还是生产环境,`--progress` 都应该加上 —— 它会在命令行展示 Webpack 的运行状况。在生产环境,为了压缩代码,还应该加上 `-p`,并且把 `NODE_ENV` 的值设为 `production`。
`scripts` 修改后:
```json
"scripts": {
"start": "yarn dev:start",
"dev:start": "nodemon -e js,jsx --ignore lib --ignore dist --exec babel-node src/server",
"dev:wds": "webpack-dev-server --progress",
"prod:build": "rimraf lib dist && babel src -d lib --ignore .test.js && cross-env NODE_ENV=production webpack -p --progress",
"prod:start": "cross-env NODE_ENV=production pm2 start lib/server && pm2 logs",
"prod:stop": "pm2 delete server",
"lint": "eslint src webpack.config.babel.js --ext .js,.jsx",
"test": "yarn lint && flow && jest --coverage",
"precommit": "yarn test",
"prepush": "yarn test && yarn prod:build"
},
```
`dev:start` 会监听 `.js` 和 `.jsx` 文件的更新,但会忽略 `dist` 文件夹下的更新。
`lint` 任务还会检查 `webpack.config.babel.js` 文件的代码规范。
- 接下来,在 `src/server/render-app.js` 中,为我们的 app 创建一个容器并导出。
```js
// @flow
import { APP_CONTAINER_CLASS, STATIC_PATH, WDS_PORT } from '../shared/config'
import { isProd } from '../shared/util'
const renderApp = (title: string) =>
`
${title}
`
export default renderApp
```
如果是在开发环境,引用的就是 Webpack 服务器的代码;如果是生产环境,引用的则是 Webpack 打包后的代码。注意,在开发模式下,Webpack 服务器的包是 *虚拟的*,`dist/js/bundle.js` 不是从硬盘里读出来的,而是存储于内存中。Webpack 服务器的端口号应该和主服务器的端口号保持不同。
- 最后,在 `src/server/index.js` 中,把 `console.log` 信息修改成:
```js
console.log(`Server running on port ${WEB_PORT} ${isProd ? '(production)' :
'(development).\nKeep "yarn dev:wds" running in an other terminal'}.`)
```
如果开发者运行了 `yarn start`,但忘记启动 Webpack 服务器,上面的 Log 信息给出了足够的提示。
我们改的东西够多了,让我们来看看运行是否成功:
🏁 命令行运行 `yarn start`,打开另一个命令行窗口,运行 `yarn dev:wds` 。等 Webpack 打完包并且生成好 sourcemaps (两个文件应该都在 600kB 左右),浏览器访问 `http://localhost:8000/`,看到的该是 "Hello Webpack!"。打开 Chrome 开发者模式,在 Source 面板下,看看哪些文件被引入了。`localhost:8000/` 域名下只有 `static/css/style.css` 文件;所有 ES 代码都属于 `webpack://./src`。这说明 sourcemaps 没出错。编辑 `src/client/index.js`,把 `Hello Webpack!` 改成其他的字符串;你一保存修改,Webpack 服务器就会生成一个新的包,Chrome 也会自动重新加载。
- Ctrl+C 关掉进程,运行 `yarn prod:build` 后再运行 `yarn prod:start`。 浏览器打开 `http://localhost:8000/`,查看 Source 面板。现在 `static/js/bundle.js` 应该是属于 `localhost:8000/`,而不是 `webpack://` 了。浏览 `bundle.js`,看看代码是否已经压缩了。使用 `yarn prod:stop` 来结束进程。
干的漂亮!这部分内容有点多,你可以休息下~接下来的内容,相对简单一些。
**注意**:我建议至少打开三个命令行窗口,一个用来运行 Express 服务器,一个用来运行 Webpack 服务器,另一个用来操作 Git,测试和其他常规操作(比如用 `yarn` 安装包)。
## React
> 💡 **[React](https://facebook.github.io/react/)** 是 Facebook 提供的一个构建前端页面的库。 **[JSX](https://facebook.github.io/react/docs/jsx-in-depth.html)** 语法让我们能够在 JS 里创建 HTML 元素和组件。
本节,我们会用 React 和 JSX 来简单渲染一些文本。
首先,安装 React 和 ReactDOM:
- 运行 `yarn add react react-dom`
把 `src/client/index.js` 文件重命名为 `src/client/index.jsx` ,并修改代码:
```js
// @flow
import 'babel-polyfill'
import React from 'react'
import ReactDOM from 'react-dom'
import App from './app'
import { APP_CONTAINER_SELECTOR } from '../shared/config'
ReactDOM.render(, document.querySelector(APP_CONTAINER_SELECTOR))
```
- 创建 `src/client/app.jsx` :
```js
// @flow
import React from 'react'
const App = () => Hello React!
export default App
```
既然我们用了 JSX 语法,我们就要通知 Babel 用 `babel-preset-react` 来进行转换;为了对 React 组件进行类型检查,我们需要安装 `flow-react-proptypes` 插件。
- 运行 `yarn add --dev babel-preset-react babel-plugin-flow-react-proptypes`, 然后修改 `.babelrc`:
```json
{
"presets": [
"env",
"flow",
"react"
],
"plugins": [
"flow-react-proptypes"
]
}
```
🏁 运行 `yarn start` 和 `yarn dev:wds`,浏览器中访问 `http://localhost:8000`,应该看到 "Hello React!"。
修改 `src/client/app.jsx` 中的文本,Webpack 会自动重新加载页面;这已经非常简单了,但接下来,我们要做得更好。
## Hot Module Replacement(热替换)
> 💡 **[Hot Module Replacement](https://webpack.js.org/concepts/hot-module-replacement/)** (*HMR*) —— 不用重新加载全部资源,就能进行实时更新。
为了搭配 HMR 使用 React,我们需要做一些小调整
- 运行 `yarn add react-hot-loader@next`
- 修改 `webpack.config.babel.js` :
```js
import webpack from 'webpack'
// [...]
entry: [
'react-hot-loader/patch',
'./src/client',
],
// [...]
devServer: {
port: WDS_PORT,
hot: true,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
plugins: [
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(),
new webpack.NamedModulesPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
],
```
`headers` 消息头是为了给 HMR 设置跨域资源共享。
- 修改 `src/client/index.jsx` 文件:
```js
// @flow
import 'babel-polyfill'
import React from 'react'
import ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import App from './app'
import { APP_CONTAINER_SELECTOR } from '../shared/config'
const rootEl = document.querySelector(APP_CONTAINER_SELECTOR)
const wrapApp = AppComponent =>
ReactDOM.render(wrapApp(App), rootEl)
if (module.hot) {
// flow-disable-next-line
module.hot.accept('./app', () => {
// eslint-disable-next-line global-require
const NextApp = require('./app').default
ReactDOM.render(wrapApp(NextApp), rootEl)
})
}
```
`App` 必须是 `react-hot-loader` 导出的 `AppContainer` 的一个子元素;热更新的时候,我们需要把 `App` 的最新版本重新 `require`。为了保持代码整洁和 DRY,我们创建了一个名为 `wrapApp` 的方法;在两处需要渲染 `App` 的地方,都用到了这个方法。出于代码可读性的考虑,你可以把 `eslint-disable global-require` 写在该文件的最顶部。
🏁 重启 `yarn dev:wds` 进程并在浏览器访问 `localhost:8000`。在开发者模式下,你会看到浏览器输出了一些和 HMR 相关的日志。随便修改点 `src/client/app.jsx` 文件中的内容,你的修改会很快投射到浏览器中,并且没有整页刷新。
下一章: [05 - Redux, Immutable, Fetch](05-redux-immutable-fetch.md#readme)
回到 [上一章](03-express-nodemon-pm2.md#readme) 或者 [目录](https://github.com/verekia/js-stack-from-scratch#table-of-contents).
================================================
FILE: tutorial/05-redux-immutable-fetch.md
================================================
# 05 - Redux, Immutable, and Fetch
**注意**: 本章中的 `state`,`action`,`reducer` 等术语都不会翻译。如果你之前没有用过 React 系列工具,学习这一章肯定有些吃力,甚至看不懂;但正如作者一开始所说,这个教程只是教你怎么构建一个技术栈,不是教你怎么写代码。如果需要学习 React,还是要去看文档才行。
本章代码在 [这里](https://github.com/verekia/js-stack-walkthrough/tree/master/05-redux-immutable-fetch).
本章我们会结合使用 React 和 Redux,做一个简单的示例 app。这个示例由一个信息按钮组成,当用户点击的时候,信息会改变。
开始之前,我先简单地介绍一下 ImmutableJS —— 它跟 React 和 Redux 完全没关系,但我们会在本章中用到它。
## ImmutableJS
> 💡 **[ImmutableJS](https://facebook.github.io/immutable-js/)** (简称 Immutable) 是 Facebook 提供的一个操作不可变集合(例如 Map 或 List)的库。更改不可变对象时,不会更改原对象,而是会返回一个新对象。
举例,我们不建议这样:
```js
const obj = { a: 1 }
obj.a = 2 // Mutates `obj`
```
我们建议这样:
```js
const obj = Immutable.Map({ a: 1 })
obj.set('a', 2) // 返回一个新对象,没有更改 `obj`
```
这个库和 **函数式编程** 的思想不谋而合,并让 Redux 的使用如虎添翼。
创建不可变集合时,一个常用的方法是 `Immutable.fromJS()`。这个方法以 JS 对象或数组为参数,并返回一个深拷贝的不可变对象。
```js
const immutablePerson = Immutable.fromJS({
name: 'Stan',
friends: ['Kyle', 'Cartman', 'Kenny'],
})
console.log(immutablePerson)
/*
* Map {
* "name": "Stan",
* "friends": List [ "Kyle", "Cartman", "Kenny" ]
* }
*/
```
- 运行 `yarn add immutable@4.0.0-rc.2`
## Redux
> 💡 **[Redux](http://redux.js.org/)** 库用来管理应用的生命周期。它创建一个 *store*,作为应用中 state 的唯一源。
从最简单的部分开始,先声明 Redux actions:
- Run `yarn add redux redux-actions`
- 创建 `src/client/action/hello.js`:
```js
// @flow
import { createAction } from 'redux-actions'
export const SAY_HELLO = 'SAY_HELLO'
export const sayHello = createAction(SAY_HELLO)
```
该文件导出了一个 *action* —— `SAY_HELLO`,以及对应的 *action creator* —— `sayHello`,creator 是一个方法。我们用 [`redux-actions`](https://github.com/acdlite/redux-actions) 来处理 Redux actions。 `redux-actions` 实现了 [Flux Standard Action](https://github.com/acdlite/flux-standard-action) 模型 —— *action creators* 返回的对象包含 `type` 和 `payload` 两个属性。
- 创建 `src/client/reducer/hello.js` :
```js
// @flow
import Immutable from 'immutable'
import type { fromJS as Immut } from 'immutable'
import { SAY_HELLO } from '../action/hello'
const initialState = Immutable.fromJS({
message: 'Initial reducer message',
})
const helloReducer = (state: Immut = initialState, action: { type: string, payload: any }) => {
switch (action.type) {
case SAY_HELLO:
return state.set('message', action.payload)
default:
return state
}
}
export default helloReducer
```
上面的代码用 Immutable Map 初始化了 reducer 的 state,该 state 包含一个属性 `message`,值为 `Initial reducer message`。`helloReducer` 处理 `SAY_HELLO` actions 的方式很简单 —— 只是把 `message` 的值设置为 payload 的值。Flow 注释把 `action` 参数解构为 `type` 和 `payload`;其中,`payload` 的类型为 `any`。为了给 `state` 提供类型注释,我们用 Flow 语法 `import type` 来得到 `fromJS` 的类型。为了保持代码清晰和可读性,我们把这个类型重命名为 `Immut`,因为要是把注释写成 `state: fromJS`,让人看着头大。`import type` 和其他的 Flow 注释一样,不会影响代码运行。注意看一下 `Immutable.fromJS()` 和 `set()` 是怎么用的;在之前那个简单的例子里,我们已经用过一次了。
## React-Redux
> 💡 **[react-redux](https://github.com/reactjs/react-redux)** 把 Redux store 和 React 组件的使用 *结合* 了起来。有了 `react-redux`,当 Redux store 改变的时候,React 组件就会自动更新。
- 运行 `yarn add react-redux`
下一节我们会创建 *Components* 和 *Containers*。
**Components(组件)** 是有点 *傻乎乎* 的 React 组件,某种程度上来说,它们感知不到 Redux state 的更新。 **Containers** 是相对 *聪明* 的组件,它们能感知状态变化。
- 创建 `src/client/component/button.jsx`:
```js
// @flow
import React from 'react'
type Props = {
label: string,
handleClick: Function,
}
const Button = ({ label, handleClick }: Props) =>
export default Button
```
**注意**: 在这里,我们使用了 Flow 的 *类型别名*。我们自定义了 `Props` 类型,来解构组件的 `props`。
- 创建 `src/client/component/message.jsx`:
```js
// @flow
import React from 'react'
type Props = {
message: string,
}
const Message = ({ message }: Props) =>
{message}
export default Message
```
以上的例子属于 *傻乎乎* 的组件。这些组件缺乏逻辑,只会展示通过 *props(属性)* 传进来的值。`button.jsx` 和 `message.jsx` 的区别是, `Button` 组件的属性里包含了一个 action dispatcher,而 `Message` 组件只是用来展示数据。
再强调一下,*components* 不能感知 Redux 的 **actions** 或者 app 的 **state**;所以,我们要创建 **containers**, 从而向这俩组件中传入 action dispatchers 和数据。
- 创建 `src/client/container/hello-button.js` :
```js
// @flow
import { connect } from 'react-redux'
import { sayHello } from '../action/hello'
import Button from '../component/button'
const mapStateToProps = () => ({
label: 'Say hello',
})
const mapDispatchToProps = dispatch => ({
handleClick: () => { dispatch(sayHello('Hello!')) },
})
export default connect(mapStateToProps, mapDispatchToProps)(Button)
```
这个 container 用 `sayHello` action 和 Redux 的 `dispatch` 方法,挂载了 `Button` 组件。
- 创建 `src/client/container/message.js` :
```js
// @flow
import { connect } from 'react-redux'
import Message from '../component/message'
const mapStateToProps = state => ({
message: state.hello.get('message'),
})
export default connect(mapStateToProps)(Message)
```
这个 container 把 Redux 的应用状态和 `Message` 组件相挂载。当装填改变, `Message` 会根据 `message` 属性自动重新渲染。组件和属性之间的联系,是通过 `react-redux` 提供的 `connect` 方法。
- 修改 `src/client/app.jsx`:
```js
// @flow
import React from 'react'
import HelloButton from './container/hello-button'
import Message from './container/message'
import { APP_NAME } from '../shared/config'
const App = () =>
{APP_NAME}
export default App
```
我们还没有初始化 Redux store,也还没有在 app 中应用以上两个 containers:
- 修改 `src/client/index.jsx`:
```js
// @flow
import 'babel-polyfill'
import React from 'react'
import ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux'
import App from './app'
import helloReducer from './reducer/hello'
import { APP_CONTAINER_SELECTOR } from '../shared/config'
import { isProd } from '../shared/util'
const store = createStore(combineReducers({ hello: helloReducer }),
// eslint-disable-next-line no-underscore-dangle
isProd ? undefined : window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())
const rootEl = document.querySelector(APP_CONTAINER_SELECTOR)
const wrapApp = (AppComponent, reduxStore) =>
ReactDOM.render(wrapApp(App, store), rootEl)
if (module.hot) {
// flow-disable-next-line
module.hot.accept('./app', () => {
// eslint-disable-next-line global-require
const NextApp = require('./app').default
ReactDOM.render(wrapApp(NextApp, store), rootEl)
})
}
```
花点时间 review 一下我们的代码。首先,用 `createStore` 方法, 以 reducers 为参数,创建了一个 *store*。我们现在只有一个 reducer,但为了未来代码的扩展性,我们用 `combineReducers` 方法把 reducers 组成了一个集合。最后一个参数,是用来把 Redux 绑定到浏览器的 [开发工具](https://github.com/zalmoxisus/redux-devtools-extension) —— debug 的时候很有用。因为 `__REDUX_DEVTOOLS_EXTENSION__` 的下划线,ESLint 会报错,所以在这一行我们禁用了下划线规则。利用我们之前写的 `wrapApp` 方法,可以非常容易的把 app 包裹在 `Provider` 组件中,并向其传入 store。
🏁 现在可以用运行 `yarn start` 和 `yarn dev:wds`,然后打开 `http://localhost:8000`。页面内容是 "Initial reducer message" 和一个按钮。点击按钮,信息会变成 "Hello!"。如果你的浏览器安装了 Redux 开发者插件,就能更清楚的看到 app 的状态变化了。
恭喜!我们的 app 现在看起来终于有点像那么回事了。虽然表面上看这个 app 没什么厉害的地方,但我们知道,在底层,它是由一个相当牛逼的技术栈作支撑的。
## 用异步请求来拓展 app
接下来,我们要添加一个新按钮;点击这个按钮,会发出一个 AJAX 请求。仅作示例,这个请求会发送一个数据,然后服务器会返回硬编码的 `1234`。
### The server endpoint
- 创建 `src/shared/routes.js` 文件:
```js
// @flow
// eslint-disable-next-line import/prefer-default-export
export const helloEndpointRoute = (num: ?number) => `/ajax/hello/${num || ':num'}`
```
这个方法是个帮助类,这样用:
```js
helloEndpointRoute() // -> '/ajax/hello/:num' (for Express)
helloEndpointRoute(1234) // -> '/ajax/hello/1234' (for the actual call)
```
赶紧先测试下
- 创建 `src/shared/routes.test.js`:
```js
import { helloEndpointRoute } from './routes'
test('helloEndpointRoute', () => {
expect(helloEndpointRoute()).toBe('/ajax/hello/:num')
expect(helloEndpointRoute(123)).toBe('/ajax/hello/123')
})
```
- 运行 `yarn test`
- 在 `src/server/index.js`,添加:
```js
import { helloEndpointRoute } from '../shared/routes'
// [under app.get('/')...]
app.get(helloEndpointRoute(), (req, res) => {
res.json({ serverMessage: `Hello from the server! (received ${req.params.num})` })
})
```
### 创建新的 containers
- 创建 `src/client/container/hello-async-button.js` :
```js
// @flow
import { connect } from 'react-redux'
import { sayHelloAsync } from '../action/hello'
import Button from '../component/button'
const mapStateToProps = () => ({
label: 'Say hello asynchronously and send 1234',
})
const mapDispatchToProps = dispatch => ({
handleClick: () => { dispatch(sayHelloAsync(1234)) },
})
export default connect(mapStateToProps, mapDispatchToProps)(Button)
```
这个例子只是为了说明怎样向异步请求传参数,为了简单,我传的值是硬编码的 `1234` ;一般来说,这个值应该来自用户输入。
- 创建 `src/client/container/message-async.js` :
```js
// @flow
import { connect } from 'react-redux'
import MessageAsync from '../component/message'
const mapStateToProps = state => ({
message: state.hello.get('messageAsync'),
})
export default connect(mapStateToProps)(MessageAsync)
```
在这个 container 中,我们引用了一个 `messageAsync` 属性,我们要在 reducer 中加入这个属性。
现在我们需要创建 `sayHelloAsync` action。
### Fetch
> 💡 **[Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch)** 是发起异步请求的标准方法,受了 jQuery AJAX 方法的启发。
我们在向服务器发起请求的时候会用到 `fetch`,这个方法目前还没有被所有浏览器支持,所以需要 polyfill。 `isomorphic-fetch` 不单单能跨浏览器使用,甚至还能在 Node 环境使用。
- 运行 `yarn add isomorphic-fetch`
因为使用了 `eslint-plugin-compat` 插件,为了在使用 `fetch` 时关闭不必要的警告,配置文件需作出修改:
- 修改 `.eslintrc.json`:
```json
"settings": {
"polyfills": ["fetch"]
},
```
### 3个异步 actions
`sayHelloAsync` 不是一个常规的 action。异步 actions 通常分为三部分,并触发三种状态:一个 *request(请求)* action,一个 *success(成功)* action,一个 *failure(失败)* action。(译者注:如果你用过 Promise,应该感到熟悉~)
- 编辑 `src/client/action/hello.js`:
```js
// @flow
import 'isomorphic-fetch'
import { createAction } from 'redux-actions'
import { helloEndpointRoute } from '../../shared/routes'
export const SAY_HELLO = 'SAY_HELLO'
export const SAY_HELLO_ASYNC_REQUEST = 'SAY_HELLO_ASYNC_REQUEST'
export const SAY_HELLO_ASYNC_SUCCESS = 'SAY_HELLO_ASYNC_SUCCESS'
export const SAY_HELLO_ASYNC_FAILURE = 'SAY_HELLO_ASYNC_FAILURE'
export const sayHello = createAction(SAY_HELLO)
export const sayHelloAsyncRequest = createAction(SAY_HELLO_ASYNC_REQUEST)
export const sayHelloAsyncSuccess = createAction(SAY_HELLO_ASYNC_SUCCESS)
export const sayHelloAsyncFailure = createAction(SAY_HELLO_ASYNC_FAILURE)
export const sayHelloAsync = (num: number) => (dispatch: Function) => {
dispatch(sayHelloAsyncRequest())
return fetch(helloEndpointRoute(num), { method: 'GET' })
.then((res) => {
if (!res.ok) throw Error(res.statusText)
return res.json()
})
.then((data) => {
if (!data.serverMessage) throw Error('No message received')
dispatch(sayHelloAsyncSuccess(data.serverMessage))
})
.catch(() => {
dispatch(sayHelloAsyncFailure())
})
}
```
`sayHelloAsync` 没返回一个 action,而是返回了一个发起 `fetch` 请求的方法。`fetch` 返回一个 `Promise`,根据异步请求的状态,这个请求会 *dispatch(分发)* 不同的 action。
### 3 异步 action 处理器
在 `src/client/reducer/hello.js` 文件中,处理不同的 actions:
```js
// @flow
import Immutable from 'immutable'
import type { fromJS as Immut } from 'immutable'
import {
SAY_HELLO,
SAY_HELLO_ASYNC_REQUEST,
SAY_HELLO_ASYNC_SUCCESS,
SAY_HELLO_ASYNC_FAILURE,
} from '../action/hello'
const initialState = Immutable.fromJS({
message: 'Initial reducer message',
messageAsync: 'Initial reducer message for async call',
})
const helloReducer = (state: Immut = initialState, action: { type: string, payload: any }) => {
switch (action.type) {
case SAY_HELLO:
return state.set('message', action.payload)
case SAY_HELLO_ASYNC_REQUEST:
return state.set('messageAsync', 'Loading...')
case SAY_HELLO_ASYNC_SUCCESS:
return state.set('messageAsync', action.payload)
case SAY_HELLO_ASYNC_FAILURE:
return state.set('messageAsync', 'No message received, please check your connection')
default:
return state
}
}
export default helloReducer
```
在 store 中,我们添加了一个新值: `messageAsync`,收到的 action 不同,值也不同。 如果 action.type 是 `SAY_HELLO_ASYNC_REQUEST`,值设为 `Loading...`。 `SAY_HELLO_ASYNC_SUCCESS` 和 `SAY_HELLO` 处理 `message` 的方式一样。 如果是 `SAY_HELLO_ASYNC_FAILURE`,则给出错误信息。
### Redux-thunk
在 `src/client/action/hello.js`,我们创建了 `sayHelloAsync`,他是一个 action creator,返回一个方法。Redux 原生并不支持这样使用。为了使用异步 actions,我们用 `redux-thunk` *中间件* 来扩展 Redux 功能。
- 安装 `yarn add redux-thunk`
- 修改 `src/client/index.jsx`:
```js
// @flow
import 'babel-polyfill'
import React from 'react'
import ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import { Provider } from 'react-redux'
import { createStore, combineReducers, applyMiddleware, compose } from 'redux'
import thunkMiddleware from 'redux-thunk'
import App from './app'
import helloReducer from './reducer/hello'
import { APP_CONTAINER_SELECTOR } from '../shared/config'
import { isProd } from '../shared/util'
// eslint-disable-next-line no-underscore-dangle
const composeEnhancers = (isProd ? null : window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
const store = createStore(combineReducers({ hello: helloReducer }),
composeEnhancers(applyMiddleware(thunkMiddleware)))
const rootEl = document.querySelector(APP_CONTAINER_SELECTOR)
const wrapApp = (AppComponent, reduxStore) =>
ReactDOM.render(wrapApp(App, store), rootEl)
if (module.hot) {
// flow-disable-next-line
module.hot.accept('./app', () => {
// eslint-disable-next-line global-require
const NextApp = require('./app').default
ReactDOM.render(wrapApp(NextApp, store), rootEl)
})
}
```
我们把 `redux-thunk` 传给 Redux `applyMiddleware` 方法。为了使用 Redux 开发工具,我们还需要使用 Redux 的 `compose` 方法。别太在意这一部分,只要知道我们用 `redux-thunk` 增强了 Redux 就行了。
- 修改 `src/client/app.jsx`:
```js
// @flow
import React from 'react'
import HelloButton from './container/hello-button'
import HelloAsyncButton from './container/hello-async-button'
import Message from './container/message'
import MessageAsync from './container/message-async'
import { APP_NAME } from '../shared/config'
const App = () =>
{APP_NAME}
export default App
```
🏁 运行 `yarn start`和 `yarn dev:wds`, 点击 "Say hello asynchronously and send 1234" 按钮,就能收到服务器返回的信息!因为你的服务器部署在本地,所以请求都是瞬间返回。要是你打开 Redux 开发工具,你就会发现每一次点击都触发了 `SAY_HELLO_ASYNC_REQUEST` and `SAY_HELLO_ASYNC_SUCCESS`;也就是说,你在浏览器里可能看不到(因为太快了),但 `Loading...` 状态确实存在过。
现在可以放松一下,因为这一章的确有点难。现在做一些测试。
## 测试
这一部分,我们会测试 actions 和 reducer。从测试 actions 开始:
为了分离 `action/hello.js` 的代码逻辑,我们需要 *mock(模拟)* 一些东西;`fetch` 操作也需要模拟,测试中我们并不用真的发起 AJAX 操作。
- 运行 `yarn add --dev redux-mock-store fetch-mock`
- 创建 `src/client/action/hello.test.js`:
```js
import fetchMock from 'fetch-mock'
import configureMockStore from 'redux-mock-store'
import thunkMiddleware from 'redux-thunk'
import {
sayHelloAsync,
sayHelloAsyncRequest,
sayHelloAsyncSuccess,
sayHelloAsyncFailure,
} from './hello'
import { helloEndpointRoute } from '../../shared/routes'
const mockStore = configureMockStore([thunkMiddleware])
afterEach(() => {
fetchMock.restore()
})
test('sayHelloAsync success', () => {
fetchMock.get(helloEndpointRoute(666), { serverMessage: 'Async hello success' })
const store = mockStore()
return store.dispatch(sayHelloAsync(666))
.then(() => {
expect(store.getActions()).toEqual([
sayHelloAsyncRequest(),
sayHelloAsyncSuccess('Async hello success'),
])
})
})
test('sayHelloAsync 404', () => {
fetchMock.get(helloEndpointRoute(666), 404)
const store = mockStore()
return store.dispatch(sayHelloAsync(666))
.then(() => {
expect(store.getActions()).toEqual([
sayHelloAsyncRequest(),
sayHelloAsyncFailure(),
])
})
})
test('sayHelloAsync data error', () => {
fetchMock.get(helloEndpointRoute(666), {})
const store = mockStore()
return store.dispatch(sayHelloAsync(666))
.then(() => {
expect(store.getActions()).toEqual([
sayHelloAsyncRequest(),
sayHelloAsyncFailure(),
])
})
})
```
上面的代码首先用 `const mockStore = configureMockStore([thunkMiddleware])` 这行代码模拟 Redux store。这样,当我们 dispatch(分发)actions 时,就不会触发 reducer 的逻辑了。每一个测试,我们都用 `fetchMock.get()` 来模拟 `fetch`,并且返回值是我们自定义的。我们真正测试的东西,是 store 分发的一系列 actions,这里,我们用到了 `redux-mock-store` 提供的 'store.getActions()' 方法。在每一次测试之后,我们用 `fetchMock.restore()` 方法来把 'fetch' 恢复到初始状态。
reducer 的测试相对简单:
- 创建 `src/client/reducer/hello.test.js`:
```js
import {
sayHello,
sayHelloAsyncRequest,
sayHelloAsyncSuccess,
sayHelloAsyncFailure,
} from '../action/hello'
import helloReducer from './hello'
let helloState
beforeEach(() => {
helloState = helloReducer(undefined, {})
})
test('handle default', () => {
expect(helloState.get('message')).toBe('Initial reducer message')
expect(helloState.get('messageAsync')).toBe('Initial reducer message for async call')
})
test('handle SAY_HELLO', () => {
helloState = helloReducer(helloState, sayHello('Test'))
expect(helloState.get('message')).toBe('Test')
})
test('handle SAY_HELLO_ASYNC_REQUEST', () => {
helloState = helloReducer(helloState, sayHelloAsyncRequest())
expect(helloState.get('messageAsync')).toBe('Loading...')
})
test('handle SAY_HELLO_ASYNC_SUCCESS', () => {
helloState = helloReducer(helloState, sayHelloAsyncSuccess('Test async'))
expect(helloState.get('messageAsync')).toBe('Test async')
})
test('handle SAY_HELLO_ASYNC_FAILURE', () => {
helloState = helloReducer(helloState, sayHelloAsyncFailure())
expect(helloState.get('messageAsync')).toBe('No message received, please check your connection')
})
```
每次测试之前,我们都初始化了 `helloState` 的值,把它设为 reducer 的默认返回值( `switch` 的默认返回值是 `initialState` )。
🏁 运行 `yarn test`,所有测试通过。
下一章: [06 - React Router, Server-Side Rendering, Helmet](06-react-router-ssr-helmet.md#readme)
回到 [上一章](04-webpack-react-hmr.md#readme) 或者 [目录](https://github.com/verekia/js-stack-from-scratch#table-of-contents).
================================================
FILE: tutorial/06-react-router-ssr-helmet.md
================================================
# 06 - React Router, Server-Side Rendering, and Helmet
本章代码在 [这里](https://github.com/verekia/js-stack-walkthrough/tree/master/06-react-router-ssr-helmet).
本章我们将为 app 创建多个页面,并使多个页面之间可路由。
## React Router
> 💡 **[React Router](https://reacttraining.com/react-router/)** 用来在 React app 的页面间实现路由。这个包既可以运行在客户端,也能运行在服务端。
- `yarn add react-router react-router-dom`
在客户端,要把 app 包裹在 `BrowserRouter` 组件中。
- 修改 `src/client/index.jsx` :
```js
// [...]
import { BrowserRouter } from 'react-router-dom'
// [...]
const wrapApp = (AppComponent, reduxStore) =>
```
## Pages(页面)
我们的 app 会有四个页面
- Home —— 主页
- Hello 页面 —— 一个按钮加一段同步信息
- Hello 的异步页面 —— 一个按钮加一段异步信息
- 404 页面
- 创建 `src/client/component/page/home.jsx`:
```js
// @flow
import React from 'react'
const HomePage = () => Home
export default HomePage
```
- 创建 `src/client/component/page/hello.jsx`:
```js
// @flow
import React from 'react'
import HelloButton from '../../container/hello-button'
import Message from '../../container/message'
const HelloPage = () =>
export default HelloPage
```
- 创建 `src/client/component/page/hello-async.jsx`:
```js
// @flow
import React from 'react'
import HelloAsyncButton from '../../container/hello-async-button'
import MessageAsync from '../../container/message-async'
const HelloAsyncPage = () =>
export default HelloAsyncPage
```
- 创建 `src/client/component/page/not-found.jsx`:
```js
// @flow
import React from 'react'
const NotFoundPage = () => Page not found
export default NotFoundPage
```
## Navigation(导航/路由)
在前后端共享的配置文件中,加入路由配置
- 修改 `src/shared/routes.js`:
```js
// @flow
export const HOME_PAGE_ROUTE = '/'
export const HELLO_PAGE_ROUTE = '/hello'
export const HELLO_ASYNC_PAGE_ROUTE = '/hello-async'
export const NOT_FOUND_DEMO_PAGE_ROUTE = '/404'
export const helloEndpointRoute = (num: ?number) => `/ajax/hello/${num || ':num'}`
```
`/404` 页面本来应该是在访问不到链接的时候展示的;但为了例子简单,我们把 404 页面设置为一个固定的展示页面。
- 创建 `src/client/component/nav.jsx`:
```js
// @flow
import React from 'react'
import { NavLink } from 'react-router-dom'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
NOT_FOUND_DEMO_PAGE_ROUTE,
} from '../../shared/routes'
const Nav = () =>
export default Nav
```
根据前一个文件声明的路由,我们创建了一些 `NavLink` (导航链接)。
- 最后,修改 `src/client/app.jsx`:
```js
// @flow
import React from 'react'
import { Switch } from 'react-router'
import { Route } from 'react-router-dom'
import { APP_NAME } from '../shared/config'
import Nav from './component/nav'
import HomePage from './component/page/home'
import HelloPage from './component/page/hello'
import HelloAsyncPage from './component/page/hello-async'
import NotFoundPage from './component/page/not-found'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
} from '../shared/routes'
const App = () =>
{APP_NAME}
} />
} />
} />
export default App
```
🏁 运行 `yarn start` 和 `yarn dev:wds`,浏览 `http://localhost:8000` ,点击链接查看各个页面。URL 是动态更新的;使用浏览器的返回功能,看看浏览历史是否正确。
假设你现在再访问 `http://localhost:8000/hello` 页面,刷新一下页面,你会得到一个 404 错误。这是因为 Express 服务器只会响应 `/` 地址。当你在各个页面间跳转的时候,你只是用到了客户端路由。为了解决这个问题,我们要用到服务端渲染。
## Server-Side Rendering(服务端渲染)
> 💡 **服务端渲染** 意味着在页面初始化加载的时候,就已经被渲染好了(服务器返回的就是渲染好的页面),而不是依赖浏览器的渲染。
SSR 的优点是:有利于 SEO 和更好的用户体验。
首先,为了让 React app 在服务端渲染,我们要把大部分客户端代码移动到 shared 文件夹。
### 移动代码到 `shared`
- 除了 `src/client/index.jsx` 之外,`client` 文件夹下的所有文件都移动到 `shared`。
因为路径改变,我们的引用也要修改一下。
- 在 `src/client/index.jsx`中, 3 处 `'./app'` 都修改成 `'../shared/app'`, `'./reducer/hello'` 修改为 `'../shared/reducer/hello'`
- 在 `src/shared/app.jsx` 文件中,把 `'../shared/routes'` 修改为 `'./routes'`, `'../shared/config'` 修改为 `'./config'`
- 在 `src/shared/component/nav.jsx`,把 `'../../shared/routes'` 修改为 `'../routes'`
### 服务端代码调整
- 创建 `src/server/routing.js` :
```js
// @flow
import {
homePage,
helloPage,
helloAsyncPage,
helloEndpoint,
} from './controller'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
helloEndpointRoute,
} from '../shared/routes'
import renderApp from './render-app'
export default (app: Object) => {
app.get(HOME_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, homePage()))
})
app.get(HELLO_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, helloPage()))
})
app.get(HELLO_ASYNC_PAGE_ROUTE, (req, res) => {
res.send(renderApp(req.url, helloAsyncPage()))
})
app.get(helloEndpointRoute(), (req, res) => {
res.json(helloEndpoint(req.params.num))
})
app.get('/500', () => {
throw Error('Fake Internal Server Error')
})
app.get('*', (req, res) => {
res.status(404).send(renderApp(req.url))
})
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
// eslint-disable-next-line no-console
console.error(err.stack)
res.status(500).send('Something went wrong!')
})
}
```
这段代码只用来处理请求和响应;至于业务逻辑的处理,会被放到 `controller` 模块中。
**注意**:你可能看到一些 React Route 示例中,用 `*` 作为服务端的路由 —— 这样做,所有的路由操作都被交给 React Router 处理。因为所有的请求都经过同样的方法,不利于开发 MVC 页面。我们的做法是,明确声明页面路由和返回值。这样做有些繁琐,但能从数据库获取数据,并且很简单地就能把值传递给页面。
- 创建 `src/server/controller.js` :
```js
// @flow
export const homePage = () => null
export const helloPage = () => ({
hello: { message: 'Server-side preloaded message' },
})
export const helloAsyncPage = () => ({
hello: { messageAsync: 'Server-side preloaded message for async page' },
})
export const helloEndpoint = (num: number) => ({
serverMessage: `Hello from the server! (received ${num})`,
})
```
这就是我们的 controller。它只处理业务逻辑和数据库请求 —— 注意,为了简单,在我们的例子里,数据是写死的硬编码。这些数据被传回到 `routing` 模块,用来初始化服务端的 Redux store。
- 创建 `src/server/init-store.js` :
```js
// @flow
import Immutable from 'immutable'
import { createStore, combineReducers, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import helloReducer from '../shared/reducer/hello'
const initStore = (plainPartialState: ?Object) => {
const preloadedState = plainPartialState ? {} : undefined
if (plainPartialState && plainPartialState.hello) {
// flow-disable-next-line
preloadedState.hello = helloReducer(undefined, {})
.merge(Immutable.fromJS(plainPartialState.hello))
}
return createStore(combineReducers({ hello: helloReducer }),
preloadedState, applyMiddleware(thunkMiddleware))
}
export default initStore
```
除了调用 `createStore` 和应用中间件外,我们做的唯一的事情就是把从 `controller` 接收到的 JS 对象转换为不可变对象,然后合并到 Redux state 中。
- 修改 `src/server/index.js` :
```js
// @flow
import compression from 'compression'
import express from 'express'
import routing from './routing'
import { WEB_PORT, STATIC_PATH } from '../shared/config'
import { isProd } from '../shared/util'
const app = express()
app.use(compression())
app.use(STATIC_PATH, express.static('dist'))
app.use(STATIC_PATH, express.static('public'))
routing(app)
app.listen(WEB_PORT, () => {
// eslint-disable-next-line no-console
console.log(`Server running on port ${WEB_PORT} ${isProd ? '(production)' :
'(development).\nKeep "yarn dev:wds" running in an other terminal'}.`)
})
```
这段代码没什么特别的,我们调用 `routing(app)` 方法,而不是在这个文件中实现路由。
- 重命名 `src/server/render-app.js` 为 `src/server/render-app.jsx` ,并修改内容:
```js
// @flow
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Provider } from 'react-redux'
import { StaticRouter } from 'react-router'
import initStore from './init-store'
import App from './../shared/app'
import { APP_CONTAINER_CLASS, STATIC_PATH, WDS_PORT } from '../shared/config'
import { isProd } from '../shared/util'
const renderApp = (location: string, plainPartialState: ?Object, routerContext: ?Object = {}) => {
const store = initStore(plainPartialState)
const appHtml = ReactDOMServer.renderToString(
)
return (
`
FIX ME
${appHtml}
`
)
}
export default renderApp
```
`ReactDOMServer.renderToString` 是核心方法。React 会分析这个 `shared(前后端共享的)` `App`然后返回 HTML 元素的字符串。 `Provider` 和客户端的使用没什么区别,但在服务端,我们需要把 app 用 `StaticRouter` 包裹起来,而不是用 `BrowserRouter` 包裹。为了把 Redux store 从服务端传到客户端,我们把它传给 `window.__PRELOADED_STATE__` (变量名可以任意定义)。
**注意**: 不可变对象实现了 `toJSON()` 方法,因此你可以使用 `JSON.stringify` 来把他们转换为 JS 对象。
- 编辑 `src/client/index.jsx` ,使用预加载的 state:
```js
import Immutable from 'immutable'
// [...]
/* eslint-disable no-underscore-dangle */
const composeEnhancers = (isProd ? null : window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
const preloadedState = window.__PRELOADED_STATE__
/* eslint-enable no-underscore-dangle */
const store = createStore(combineReducers(
{ hello: helloReducer }),
{ hello: Immutable.fromJS(preloadedState.hello) },
composeEnhancers(applyMiddleware(thunkMiddleware)))
```
客户端的 store 被赋值为 `preloadedState`,这个值是服务端传过来的。
🏁 现在运行 `yarn start` 和 `yarn dev:wds`,在页面之间跳转。在 `/hello`, `/hello-async`, 和 `/404`(或者任意其他页面)刷新,应该没有之前的 404 问题了。显示的是 `message` 还是 `messageAsync`,取决于你是在客户端跳转到这个页面,还是直接从服务端拿到的这个页面。
### React Helmet
> 💡 **[React Helmet](https://github.com/nfl/react-helmet)**: 把 `head` 内容注入到 React app,可运行于客户端和服务端。
我建议你在标题写上 `FIX ME`,从而突出一个事实:虽然我们做了服务端渲染,但我们没有正确的把 `title` 标签添加进来(其他 `head` 内的标签也不对,因为它们应该是随着页面改变而改变的)。
- 运行 `yarn add react-helmet`
- 编辑 `src/server/render-app.jsx` :
```js
import Helmet from 'react-helmet'
// [...]
const renderApp = (/* [...] */) => {
// [...]
const appHtml = ReactDOMServer.renderToString(/* [...] */)
const head = Helmet.rewind()
return (
`
${head.title}
${head.meta}
[...]
`
)
}
```
React Helmet 使用 [react-side-effect](https://github.com/gaearon/react-side-effect)的 `rewind`,从 app 的渲染结果中拉取数据,这些数据会被 `` 组件使用。 我们在 `` 组件中为每一个页面设置 `title` 和其他 `head` 标签的值。注意 `Helmet.rewind()` *必须* 写在 `ReactDOMServer.renderToString()` 后面。
- 修改 `src/shared/app.jsx` :
```js
import Helmet from 'react-helmet'
// [...]
const App = () =>
// [...]
```
- 修改 `src/shared/component/page/home.jsx`:
```js
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import { APP_NAME } from '../../config'
const HomePage = () =>
{APP_NAME}
export default HomePage
```
- 修改 `src/shared/component/page/hello.jsx`:
```js
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import HelloButton from '../../container/hello-button'
import Message from '../../container/message'
const title = 'Hello Page'
const HelloPage = () =>
{title}
export default HelloPage
```
- 修改 `src/shared/component/page/hello-async.jsx` :
```js
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import HelloAsyncButton from '../../container/hello-async-button'
import MessageAsync from '../../container/message-async'
const title = 'Async Hello Page'
const HelloAsyncPage = () =>
{title}
export default HelloAsyncPage
```
- 修改 `src/shared/component/page/not-found.jsx` :
```js
// @flow
import React from 'react'
import Helmet from 'react-helmet'
const title = 'Page Not Found'
const NotFoundPage = () =>
{title}
export default NotFoundPage
```
事实上, `
` 组件没有渲染任何东西,它只是向 `head` 标签中插入内容,并且向服务端暴露了相同的内容。
🏁 运行 `yarn start` 和 `yarn dev:wds`,在页面之间做跳转。当你跳转页面的时候,title 应该已经变了;并且当你刷新页面的时候,title 不会更改。查看页面的源文件,研究一下 React Helmet 是怎样为服务端渲染设置 `title` 和 `meta` 值的。
下一章: [07 - Socket.IO](07-socket-io.md#readme)
回到 [上一章](05-redux-immutable-fetch.md#readme) 或者 [目录](https://github.com/verekia/js-stack-from-scratch#table-of-contents).
================================================
FILE: tutorial/07-socket-io.md
================================================
# 07 - Socket.IO
本章代码在 [这里](https://github.com/verekia/js-stack-walkthrough/tree/master/07-socket-io).
> 💡 **[Socket.IO](https://github.com/socketio/socket.io)** 是一个用来处理 Websockets 的库。它的 API 设计简单,并且能为不支持 Websockets 的浏览器提供回退策略。
在本章中,我们会在客户端和服务器之间进行简单的信息交换。为了不加入新的页面 —— 新页面意味着要加入我们不感兴趣的东西 —— 信息交换的结果会展示在浏览器的 console 面板中。所有本章没有 UI 相关的操作。
- 运行 `yarn add socket.io socket.io-client`
## Server-side(服务端)
- 编辑 `src/server/index.js`:
```js
// @flow
import compression from 'compression'
import express from 'express'
import { Server } from 'http'
import socketIO from 'socket.io'
import routing from './routing'
import { WEB_PORT, STATIC_PATH } from '../shared/config'
import { isProd } from '../shared/util'
import setUpSocket from './socket'
const app = express()
// flow-disable-next-line
const http = Server(app)
const io = socketIO(http)
setUpSocket(io)
app.use(compression())
app.use(STATIC_PATH, express.static('dist'))
app.use(STATIC_PATH, express.static('public'))
routing(app)
http.listen(WEB_PORT, () => {
// eslint-disable-next-line no-console
console.log(`Server running on port ${WEB_PORT} ${isProd ? '(production)' :
'(development).\nKeep "yarn dev:wds" running in an other terminal'}.`)
})
```
注意,为了使用 Socket.IO,你需要使用 `http` 包的 `Server` 创建的服务器来 `listen(监听)` 请求,而不是用 Express 创建的 `app` 服务器。幸运的是,我们不用更改太多代码。Websocket 的细节写在另一个文件中,我们用 `setUpSocket` 来调用这个文件。
- 把下面的常量添加到 `src/shared/config.js`:
```js
export const IO_CONNECT = 'connect'
export const IO_DISCONNECT = 'disconnect'
export const IO_CLIENT_HELLO = 'IO_CLIENT_HELLO'
export const IO_CLIENT_JOIN_ROOM = 'IO_CLIENT_JOIN_ROOM'
export const IO_SERVER_HELLO = 'IO_SERVER_HELLO'
```
这些常量代表着浏览器和服务器会进行交换的 *消息类型*。我建议给这些常量加上 `IO_CLIENT` 或者 `IO_SERVER` 前缀,清楚地表示出 *谁* 是消息的发送方。否则,看到那么多消息类型的时候,你可能有点懵。
如你所见,有一个名为 `IO_CLIENT_JOIN_ROOM` 的消息类型。为了演示,我们让客户端加入一个房间(类似聊天室)。当向特定的用户群发送消息的时候,房间非常有用。
- 创建 `src/server/socket.js`:
```js
// @flow
import {
IO_CONNECT,
IO_DISCONNECT,
IO_CLIENT_JOIN_ROOM,
IO_CLIENT_HELLO,
IO_SERVER_HELLO,
} from '../shared/config'
/* eslint-disable no-console */
const setUpSocket = (io: Object) => {
io.on(IO_CONNECT, (socket) => {
console.log('[socket.io] A client connected.')
socket.on(IO_CLIENT_JOIN_ROOM, (room) => {
socket.join(room)
console.log(`[socket.io] A client joined room ${room}.`)
io.emit(IO_SERVER_HELLO, 'Hello everyone!')
io.to(room).emit(IO_SERVER_HELLO, `Hello clients of room ${room}!`)
socket.emit(IO_SERVER_HELLO, 'Hello you!')
})
socket.on(IO_CLIENT_HELLO, (clientMessage) => {
console.log(`[socket.io] Client: ${clientMessage}`)
})
socket.on(IO_DISCONNECT, () => {
console.log('[socket.io] A client disconnected.')
})
})
}
/* eslint-enable no-console */
export default setUpSocket
```
下面,我们解释一下 *当客户端连接到服务端并发送消息的时候,服务端该做些什么*:
- 当客户端连接到服务端,连接信息会在服务端 log 出来,并且得到 `socket` 对象。这个对象可以用来向客户端发起通信。
- 当客户端发送 `IO_CLIENT_JOIN_ROOM` 消息时,如它所愿,我们让它加入一个 `room(房间)`。一旦客户端加入房间,我们发送三条 demo 消息:一条消息发送给所有用户,一条发送给房间内的用户,还有一条只发送给这个客户端。
- 当客户端发送 `IO_CLIENT_HELLO`,在服务端上 log 出来。
- 当客户端断开连接的时候,服务端也会 log 出来。
## Client-side(客户端)
客户端的代码相对简单:
- 编辑 `src/client/index.jsx`:
```js
// [...]
import setUpSocket from './socket'
// [at the very end of the file]
setUpSocket(store)
```
如你所见,我们把 Redux store 传给了 `setUpSocket`。这样,无论什么时候服务端向客户端推送了消息,都会改变 Redux 的 state。我们可以 `dispatch(分发)` actions,但在这个例子中,我们没有 `dispatch(分发)` 任何东西。
- 创建 `src/client/socket.js`:
```js
// @flow
import socketIOClient from 'socket.io-client'
import {
IO_CONNECT,
IO_DISCONNECT,
IO_CLIENT_HELLO,
IO_CLIENT_JOIN_ROOM,
IO_SERVER_HELLO,
} from '../shared/config'
const socket = socketIOClient(window.location.host)
/* eslint-disable no-console */
// eslint-disable-next-line no-unused-vars
const setUpSocket = (store: Object) => {
socket.on(IO_CONNECT, () => {
console.log('[socket.io] Connected.')
socket.emit(IO_CLIENT_JOIN_ROOM, 'hello-1234')
socket.emit(IO_CLIENT_HELLO, 'Hello!')
})
socket.on(IO_SERVER_HELLO, (serverMessage) => {
console.log(`[socket.io] Server: ${serverMessage}`)
})
socket.on(IO_DISCONNECT, () => {
console.log('[socket.io] Disconnected.')
})
}
/* eslint-enable no-console */
export default setUpSocket
```
如果你已经理解了我们在服务端做的事情,那么理解客户端的东西也不难:
- 客户端连接成功后,会在 console 面板 log 出来;并且发送一条 `IO_CLIENT_JOIN_ROOM` 类型的消息,内容是 `hello-1234`。
- 然后发送一条值 `Hello!` 的 `IO_CLIENT_HELLO` 消息。
- 如果服务端推送一条 `IO_SERVER_HELLO`,我们会在客户端 log 出来。
- 在断开连接的时候,也会有 log 信息。
🏁 运行 `yarn start` 和 `yarn dev:wds`,打开 `http://localhost:8000`。然后打开浏览器的 console 面板和 Express 服务器的命令窗口;这样,你就能看到客户端和服务端之间的通信了。
下一章:[08 - Bootstrap, JSS](08-bootstrap-jss.md#readme)
回到 [上一章](06-react-router-ssr-helmet.md#readme) 或者 [目录](https://github.com/verekia/js-stack-from-scratch#table-of-contents).
================================================
FILE: tutorial/08-bootstrap-jss.md
================================================
# 08 - Bootstrap and JSS
本章代码在 [JS-Stack-Boilerplate repository](https://github.com/verekia/js-stack-boilerplate) 的分支 [`master-no-services`](https://github.com/verekia/js-stack-boilerplate/tree/master-no-services)
我们的 app 有点丑,让我们用推特的 Bootstrap 加点样式美化一下。我们会引入 CSS-in-JS 包来加入自定义的样式。
## Twitter Bootstrap
> 💡 **[Twitter Bootstrap](http://getbootstrap.com/)** 是一个 UI 组件库。
有两种方式把 Bootstrap 引入到你的 React app,两种引入方式都有支持者和反对者:
- 使用官方发布版本,**该版本使用了 jQuery 和 Tether**。
- 使用重新实现的第三方库 [React-Bootstrap](https://react-bootstrap.github.io/) 或者 [Reactstrap](https://reactstrap.github.io/).
和官方版本相比,第三方库的 React 组件用起来相当简单。虽然这么说,但我本人并不太想用第三方库。因为第三方版本总是在官方版本 *之后* 发布(有时候要隔很久才更新)。有时,第三方的库还不能和 Bootstrap 的主题相兼容,因为这些主题使用了自己的 JS。Bootstrap 的一大优点就是拥有一个庞大的设计师社区,如果这些设计者提供的主题不被第三方库支持,那实在是有点说不过去。
因为以上原因,我做出了妥协:选择官方版本,并结合 jQuery 和 Tether 使用。但这样的话,打包后的文件大小成了个问题 —— 打包后的文件大约 200KB (开启了 Gzipped 压缩)。我觉得这个大小还可以接受,但如果对你来说文件还是太大,那你可能需要找另一种方式来用 Bootstrap,或者干脆不选择 Bootstrap。
### Bootstrap's CSS
- 删除 `public/css/style.css`
- 运行 `yarn add bootstrap@4.0.0-alpha.6`
- 从 `node_modules/bootstrap/dist/css` 把 `bootstrap.min.css` 和 `bootstrap.min.css.map` 拷贝到 `public/css` 文件夹。
- 修改 `src/server/render-app.jsx`:
```html
```
### 结合了 jQuery 和 Tether 的 Bootstrap JS
Bootstrap 的样式已经会在页面上加载了;为了给组件添加行为,我们需要导入 JS。
- 运行 `yarn add jquery tether`
- 修改 `src/client/index.jsx`:
```js
import $ from 'jquery'
import Tether from 'tether'
// [right after all your imports]
window.jQuery = $
window.Tether = Tether
require('bootstrap')
```
这样 Bootstrap 的 JavaScript 代码会被加载进来。
### Bootstrap 组件
现在,你可以做一些复制粘贴的工作:
- 修改 `src/shared/component/page/hello-async.jsx`:
```js
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import MessageAsync from '../../container/message-async'
import HelloAsyncButton from '../../container/hello-async-button'
const title = 'Async Hello Page'
const HelloAsyncPage = () =>
export default HelloAsyncPage
```
- 修改 `src/shared/component/page/hello.jsx`:
```js
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import Message from '../../container/message'
import HelloButton from '../../container/hello-button'
const title = 'Hello Page'
const HelloPage = () =>
export default HelloPage
```
- 修改 `src/shared/component/page/home.jsx`:
```js
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import ModalExample from '../modal-example'
import { APP_NAME } from '../../config'
const HomePage = () =>
JSS (soon)
Websockets
Open your browser console.
export default HomePage
```
- 修改 `src/shared/component/page/not-found.jsx`:
```js
// @flow
import React from 'react'
import Helmet from 'react-helmet'
import { Link } from 'react-router-dom'
import { HOME_PAGE_ROUTE } from '../../routes'
const title = 'Page Not Found!'
const NotFoundPage = () =>
{title}
Go to the homepage.
export default NotFoundPage
```
- 修改 `src/shared/component/button.jsx`:
```js
// [...]
// [...]
```
- 创建 `src/shared/component/footer.jsx`:
```js
// @flow
import React from 'react'
import { APP_NAME } from '../config'
const Footer = () =>
export default Footer
```
- 创建 `src/shared/component/modal-example.jsx`:
```js
// @flow
import React from 'react'
const ModalExample = () =>
Modal title
This is a Bootstrap modal. It uses jQuery.
export default ModalExample
```
- 修改 `src/shared/app.jsx`:
```js
const App = () =>
```
这是一个 *React 行内样式* 的示例。
这段代码在 DOM 中会被转换成: `
`。 [React 行内样式](https://speakerdeck.com/vjeux/react-css-in-js) 把你从 CSS 全局命名空间里解放出来,让组件作用域成为可能。但是这样做也有代价:某些原生的 CSS 特点还没有被支持,比如说 `:hover`,媒体查询,动画或者 `font-face` 就不能用了。这也是我们稍后引入 CSS-in-JS,JSS 库的[原因之一](https://github.com/cssinjs/jss/blob/master/docs/benefits.md#compared-to-inline-styles)。
- 修改 `src/shared/component/nav.jsx`:
```js
// @flow
import $ from 'jquery'
import React from 'react'
import { Link, NavLink } from 'react-router-dom'
import { APP_NAME } from '../config'
import {
HOME_PAGE_ROUTE,
HELLO_PAGE_ROUTE,
HELLO_ASYNC_PAGE_ROUTE,
NOT_FOUND_DEMO_PAGE_ROUTE,
} from '../routes'
const handleNavLinkClick = () => {
$('body').scrollTop(0)
$('.js-navbar-collapse').collapse('hide')
}
const Nav = () =>
export default Nav
```
这里添加了点新东西:`handleNavLinkClick`。在开发 SPA(单页面应用)时,我用 Bootstrap 的 `navbar` 时遇到了一个问题:在手机上点击链接的时候,菜单栏不会折叠,而且没有滚动到页面顶部。这正好给我机会,向你演示一下怎样在你的 app 中结合使用 jQuery 和 Bootstrap 的某些代码。
```js
import $ from 'jquery'
// [...]
const handleNavLinkClick = () => {
$('body').scrollTop(0)
$('.js-navbar-collapse').collapse('hide')
}
```
**注意**: 为了 *本教程代码* 的可读性,我移除了一些易访问性相关的属性 (比如 `aria` 属性)。在实际开发时,**你当然应该把这些属性加回来**。阅读 Bootstrap 文档和代码示例,研究下怎么使用它们。
🏁 现在你的 app 终于用了 Bootstrap 的样式。
## CSS 发展现状
2016 年的 JavaScript 技术栈之争已经尘埃落定。在本教程中使用到的库和工具应该能让你站在 *工业标准的前沿阵地*(*然而 —— 即使是这样,本教程还是可能在一年后完全过时 —— O__O*)。必须承认,这个技术栈设置起来有点复杂;但是,至少大多数前端开发者认为 React-Redux-Webpack 是前端的发展方向。说到 CSS,我就有点悲观了 —— 什么都没定下来,没有标准化的方向,也没有标准的技术栈。
SASS, BEM, SMACSS, SUIT, Bass CSS, React Inline Styles, LESS, Styled Components, CSSX, JSS, Radium, Web Components, CSS Modules, OOCSS, Tachyons, Stylus, Atomic CSS, PostCSS, Aphrodite, React Native for Web(都是术语,就不翻译啦 O——0 ),还有很多我已经忘了名字,不过照样能完成工作的工具。这些工具都很棒,但问题是,没有一种工具占压倒性优势,这就让人头大了。
React 党偏爱行内样式,CSS-in-JS 或者 CSS Modules,因为这些工具能和 React 组合得完美无瑕;而且还能用编程的方式来解决 CSS 的一些常见 [问题](https://speakerdeck.com/vjeux/react-css-in-js)。
CSS Modules 挺好用,但它不能完全发挥 JavaScript 的威力。它只是提供了不错的封装,但在我看来,React 行内样式和 CSS-in-JS 完全把写样式带到了一个新高度。我个人建议是普通的样式就用 React 行内样式(你在 React Native 中也是用它);当要用 `:hover` 或者媒体查询的时候,就用 CSS-in-JS。
有 [太多 CSS-in-JS 的库](https://github.com/MicheleBertoli/css-in-js)了。JSS 是一个功能全面、写法简单、 [性能优异](https://github.com/cssinjs/jss/blob/master/docs/performance.md) 的库。
## JSS
> 💡 **[JSS](http://cssinjs.org/)** 是一个用 JavaScript 来写样式,并把样式插入 app 中的 CSS-in-JS 库。
基本的 Bootstrap 模板已经定义好了,现在我们加入一些自定义的样式。我之前提到过 React 行内样式处理不了 `:hover` 和媒体查询,所以我们就在首页展示一下怎么用 JSS 来解决这些问题。JSS 可以通过 `react-jss` 实现。
- 运行 `yarn add react-jss`
以下内容添加到 `.flowconfig` 文件,因为 Flow 目前和 JSS 兼容还有点 [问题](https://github.com/cssinjs/jss/issues/411):
```flowconfig
[ignore]
.*/node_modules/jss/.*
```
### Server-side(服务端)
JSS 可以在服务端初始化的时候渲染样式。
- 以下变量添加到 `src/shared/config.js`:
```js
export const JSS_SSR_CLASS = 'jss-ssr'
export const JSS_SSR_SELECTOR = `.${JSS_SSR_CLASS}`
```
- 修改 `src/server/render-app.jsx`:
```js
import { SheetsRegistry, SheetsRegistryProvider } from 'react-jss'
// [...]
import { APP_CONTAINER_CLASS, JSS_SSR_CLASS, STATIC_PATH, WDS_PORT } from '../shared/config'
// [...]
const renderApp = (location: string, plainPartialState: ?Object, routerContext: ?Object = {}) => {
const store = initStore(plainPartialState)
const sheets = new SheetsRegistry()
const appHtml = ReactDOMServer.renderToString(
)
// [...]
// [...]
```
## Client-side(客户端)
客户端渲染 app 后的第一件事情,就是处理服务端生成的 JSS 样式。
- 以下内容放在 `src/client/index.jsx` 文件的 `ReactDOM.render` 方法后面(在 `setUpSocket(store)` 之前):
```js
import { APP_CONTAINER_SELECTOR, JSS_SSR_SELECTOR } from '../shared/config'
// [...]
const jssServerSide = document.querySelector(JSS_SSR_SELECTOR)
// flow-disable-next-line
jssServerSide.parentNode.removeChild(jssServerSide)
setUpSocket(store)
```
修改 `src/shared/component/page/home.jsx` :
```js
import injectSheet from 'react-jss'
// [...]
const styles = {
hoverMe: {
'&:hover': {
color: 'red',
},
},
'@media (max-width: 800px)': {
resizeMe: {
color: 'red',
},
},
specialButton: {
composes: ['btn', 'btn-primary'],
backgroundColor: 'limegreen',
},
}
const HomePage = ({ classes }: { classes: Object }) =>
// [...]
JSS
Hover me.
Resize the window.
// [...]
export default injectSheet(styles)(HomePage)
```
和 React 行内样式不同的是,JSS 使用了 class。样式作为参数传递给 `injectSheet`,最终,CSS 的 class 作为属性传递给组件。
🏁 运行 `yarn start` 和 `yarn dev:wds`。打开主页,查看页面源文件(不是审查元素)。你会发现,初始化渲染的时候,JSS 是在 DOM 中的。初始化时,JSS 在 `