React 到 Elm 迁移指南
本指南将帮助您学习并迁移到 Elm,假设您已经掌握了 React 的基础知识。Elm指南非常出色,它将以清晰有序的方式,为您提供全面深入的理解。
本指南有所不同。我们将从 JavaScript 和React的基础知识入手,探讨如何在 Elm 中实现等效功能(如果有的话)。如果您已经熟悉 React,我们将利用这些扎实的基础,让您更容易理解 Elm 在描述 React 开发者熟悉的语言和概念时,其背后的含义。
内容
- React是什么?
- 什么是榆树?
- JavaScript 和 Elm 语言类型
- Hello World React
- 你好世界榆树
- DOM模板
- 成分
- 事件处理
- 带状态的事件处理
- 条件渲染
- 列表
- 基本列表组件
- 形式:受控组件
- 用 React 思考
- 榆树的思考
- 发展
- 测试
- 路由
- 误差边界
- HTTP
- 国家管理
React是什么?
React 是一个用于确保 DOM 与数据同步的库。然而,也可以说它是一个框架,因为它提供了构建应用程序所需的许多基本要素。它提供了丰富的功能供你选择,因此使用起来非常灵活。只想使用 JSX 和变量?没问题。想要一个 Context 来模拟 Redux?没问题。想要用Preact之类的工具替换渲染器?没问题。
采用模块化设计,可添加和更换部件,并拥有庞大的社区支持,可根据您的需求进行修改。
假设您能够使用 JavaScript 编写 React 代码。React内置了组件属性的基本运行时类型定义。如果您需要更高级的类型定义,React也添加了对 TypeScript 的支持。
create-react-app 是一个热门项目,它之所以流行,是因为它能够自动处理编译器工具链。团队无需了解 Webpack 或 JavaScript 构建目标,例如 CommonJS、ES6 或 ES5。虽然他们无需维护核心代码,但出于网络安全或构建方面的考虑,您/团队仍然需要进行一些必要的升级。该项目提供开箱即用的简洁开发环境,支持保存文件并实时查看重新加载效果。测试已设置完毕,随时可用。此外,它还提供包含各种优化的生产版本。只需三个简单的基本命令:启动、测试和构建,即可构建大多数应用程序。
虽然您可以使用npm,但 yarn也支持那些想要使用 yarn 提供的额外功能的用户。
什么是榆树?
Elm 是一种强类型函数式语言、编译器、包管理器和框架。您可以使用 Elm 语言编写代码,它会被编译成 JavaScript 以便在浏览器中使用。Elm 编译器有两种基本模式:开发模式和生产模式。它还可选地提供了一个 REPL(交互式解释器),方便您测试一些基本代码。包管理器使用自己的网站和结构,采用 elm.json 文件,而不是 package.json 文件。Elm 最广为人知的就是它的框架, Redux正是基于这个框架开发的。
您可以使用 Elm 语言和 Elm 框架编写代码,安装 Elm 库,并使用 Elm 编译器将其编译成 JavaScript。大多数学习类应用都会编译成一个 HTML 页面,其中会自动包含 JavaScript 和 CSS。对于更高级的应用,您只需将其编译成 JavaScript 并嵌入到您自己的 index.html 文件中即可。当您需要对主 HTML 文件进行额外的 HTML 和 CSS 操作时,这种方法通常效果更好。虽然有一个 create-elm-app 工具,但它往往违背了 Elm 的设计理念,即不使用复杂且难以维护的 JavaScript 构建工具链。
JavaScript 和 Elm 语言类型
以下表格比较了 JavaScript 和 Elm 的基础知识。
字面意义
| JavaScript | 榆树 |
|---|---|
3 |
3 |
3.125 |
3.125 |
"Hello World!" |
"Hello World!" |
'Hello World!' |
字符串不能使用单引号。 |
'Multiline string.'(反引号,不是单引号) |
"""Multiline string""" |
| 字符和字符串之间没有区别。 | 'a' |
true |
True |
[1, 2, 3] |
[1, 2, 3] |
对象/记录
| JavaScript | 榆树 |
|---|---|
| { x: 3, y: 4 } | { x = 3,y = 4 } |
| x点 | x点 |
| point.x = 42 | {点 | x = 42} |
函数
| JavaScript | 榆树 |
|---|---|
function(x, y) { return x + y } |
\x y -> x + y |
Math.max(3, 4) |
max 3 4 |
Math.min(1, Math.pow(2, 4)) |
min 1 (2^4) |
numbers.map(Math.sqrt) |
List.map sqrt numbers |
points.map( p => p.x ) |
List.map .x points |
控制流
| JavaScript | 榆树 |
|---|---|
3 > 2 ? 'cat' : 'dog' |
if 3 > 2 then "cat" else "dog" |
var x = 42; ... |
let x = 42 in ... |
return 42 |
一切皆为表达,无需赘言return |
细绳
| JavaScript | 榆树 |
|---|---|
'abc' + '123' |
"abc" ++ "123" |
'abc'.length |
String.length "abc" |
'abc'.toUpperCase() |
String.toUpper "abc" |
'abc' + 123 |
"abc" ++ String.fromInt 123 |
空值和错误
| JavaScript | 榆树 |
|---|---|
undefined |
Maybe.Nothing |
null |
Maybe.Nothing |
42 |
Maybe.Just 42 |
throw new Error("b00m") |
Result.Err "b00m" |
42 |
Result.Ok 42 |
JavaScript
你经常会看到 JavaScript 使用可选链来模拟上述操作。
// has a value
const person = { age: 42 }
const age = person?.age
// is undefined
const person = { }
const age = person?.age
榆树
type alias Person = { age : Maybe Int }
-- has a value
let person = Person { age = Just 42 }
-- is nothing
let person = Person { age = Nothing }
函数组合(即“管道”)
以下两种语言均可解析以下 JSON 字符串,以获取列表中的人名。
JavaScript
截至撰写本文时,JavaScript Pipeline Operator 提案处于第一阶段,因此我们将在下面使用Promise 。
const isHuman = peep => peep.type === 'Human'
const formatName = ({ firstName, lastName }) => `${firstName} ${lastName}`
const parseNames = json =>
Promise.resolve(json)
.then( JSON.parse )
.then( peeps => peeps.filter( isHuman ) )
.then( humans => humans.map( formatName ) )
榆树
isHuman peep =
peep.type == "Human"
formatName {firstName, lastName} =
firstName ++ " " ++ lastName
parseNames json =
parseJSON
|> Result.withDefault []
|> List.filter isHuman
|> List.map formatName
模式匹配
JavaScript
截至撰写本文时,JavaScript 当前的模式匹配提案处于第一阶段。
switch(result.status) {
case "file upload progress":
return updateProgressBar(result.amount)
case "file upload failed":
return showError(result.error)
case "file upload success":
return showSuccess(result.fileName)
default:
return showError("Unknown error.")
}
榆树
case result.status of
FileUploadProgress amount ->
updateProgressBar amount
FileUploadFailed err ->
showError err
FileUploadSuccess fileName ->
showSuccess filename
_ ->
showError "Unknown error."
Hello World: React
ReactDOM.render(
<h1>Hello, world!</h1>, document.getElementById('body')
)
你好世界:榆树
type Msg = Bruh
type alias Model = {}
update _ model =
model
view _ =
h1 [][ text "Hello World!" ]
main =
Browser.sandbox
{ init = (\ () -> {})
, view = view
, update = update
}
DOM模板
JSX元素
const element = <h1>Hello world!</h1>;
榆树元素
let element = h1 [] [text "Hello World!"]
JSX 动态数据
const name = 'Jesse';
<h1>Hello {name}</h1>
榆树动态数据
let name = "Jesse"
h1 [] [text "Hello " ++ name ]
JSX 函数
const format = ({ first, last }) => `${first} ${last}`;
const user = { first: 'Jesse', last: 'Warden' };
<h1>Hello {format(user)}</h1>
Elm 函数
format {first, last} = first ++ " " ++ last
user = { first = "Jesse", last = "Warden" }
h1 [] [text (format user) ]
JSX图像
<img src={user.avatarUrl} />
榆树图像
img [ src user.avatarUrl ] []
JSX 儿童
const element = (
<div>
<h1>Hello!</h1>
<h2>Good to see you here.</h2>
</div>
);
榆树的孩子
let element =
div [] [
h1 [] [text "Hello!"]
h2 [] [text "Good to see you here."]
]
成分
React:定义
const Welcome = props => <h1>Hello {props.name}</h1>
榆树:定义
welcome props = h1 [] [text "Hello " ++ props.name]
React:使用
const element = <Welcome name="Sara" />
榆树:使用
let element = welcome { name = "Sara" }
React:儿童
const Greeting = ({ name }) => (
<div>
<h1>Hello!</h1>
<h2>Good to see you here, {name}!</h2>
</div>
)
榆树:儿童
greeting {name} =
div [] [
h1 [] [text "Hello!"]
, h2 [] [text "Good to see you here, " ++ name ++ "!"]
]
事件处理
React 事件处理器
<button onClick={activateLasers}>Activate Lasers</button>
榆树留言
button [ onClick ActivateLasers ] [ text "Activate Lasers" ]
React 事件参数
<button onClick={(e) => this.deleteRow(23, e)}>Delete Row</button>
Elm 消息参数
type Msg = DeleteRow Int
button [ onClick (DeleteRow 23) ] [ text "Delete Row" ]
带状态的事件处理
React
class Toggle extends React.Component {
constructor(props) {
super(props);
this.state = {isToggleOn: true};
}
handleClick = () => {
this.setState(state => ({ isToggleOn: !state.isToggleOn }));
}
render = () => (
{this.state.isToggleOn ? 'ON' : 'OFF'}
)
}
}
榆树
type alias Model = { isToggleOn : Bool }
initialModel = { isToggleOn = True }
type Msg = Toggle
update _ model =
{ model | isToggleOn = not model.isToggleOn }
toggle model =
div
[ onClick Toggle ]
[ if model.isToggleOn then
text "ON"
else
text "OFF" ]
条件渲染
React
function Greeting(props) {
const isLoggedIn = props.isLoggedIn;
if (isLoggedIn) {
return <UserGreeting />;
}
return <GuestGreeting />;
}
榆树
greeting props =
let
isLoggedIn = props.isLoggedIn
in
if isLoggedIn then
userGreeting()
else
guestGreeting()
列表
React
const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map((number) =>
<li>{number}</li>
);
榆树
let numbers = [1, 2, 3, 4, 5]
let listItems =
List.map
(\number -> li [] [text (String.fromInt number)])
numbers
基本列表组件
React
function NumberList(props) {
const numbers = props.numbers;
const listItems = numbers.map((number) => <li>{number}</li> );
return (
<ul>{listItems}</ul>
);
}
const numbers = [1, 2, 3, 4, 5];
<NumberList numbers={numbers} />
榆树
numberList props =
let
numbers = props.numbers
in
List.map
(\number -> li [] [text (String.fromInt number)])
numbers
let numbers = [1, 2, 3, 4, 5]
numberList numbers
形式:受控组件
React
class NameForm extends React.Component {
constructor(props) {
super(props);
this.state = {value: ''};
}
handleChange = event => {
this.setState({value: event.target.value});
}
handleSubmit = event => {
alert('A name was submitted: ' + this.state.value);
event.preventDefault();
}
render() {
return (
<form onSubmit={this.handleSubmit}>
<label>
Name:
<input type="text" value={this.state.value} onChange={this.handleChange} />
</label>
<input type="submit" value="Submit" />
</form>
);
}
}
榆树
type Msg = TextChanged String | Submit
type alias Model = { value : String }
initialModel = { value = "" }
update msg model =
case msg of
TextChanged string ->
{ model | value = string }
Submit ->
let
_ = Debug.log "A name was submitted: " model.value
in
model
view model =
form [ onSubmit Submit ][
label
[]
[ text "Name:"
, input
[type_ "text", value model.value, onInput TextChanged ] []]
, input [type_ "submit", value "Submit"][]
]
思考
React
React 的核心优势在于组件创建的便捷性,以及将这些组件组合成应用程序的轻松便捷。只需观察用户界面,在脑海中勾勒出各个组件之间的衔接,然后决定由谁来管理不同的状态即可。
- 嘲笑
- 组件层次结构
- 表示用户界面状态
- 确定州的所在地
1 – 模拟数据
在 React 中,你需要模拟从潜在的后端 API 或前端后端获取的数据。下面,我们硬编码了一些模拟 JSON 数据,以便我们的组件可以显示一些内容,并且我们可以围绕这些数据进行可视化设计和编码:
[
{category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
{category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
{category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
{category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
{category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
{category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];
2 – 组件层次结构
接下来,你可以选择根据这些数据创建组件,并观察每个组件如何以各自的方式直观地呈现数据,以及如何处理用户输入……或者,你也可以对设计师提供的设计稿进行同样的操作。无论是创建树状结构中的小型组件,还是创建将所有信息整合在一起的大型组件,都取决于你。
通常情况下,你要么会目测数据,然后各个组件就会开始在你的脑海中形成图像;要么你会看到设计稿,然后开始在脑海中将各个部分切分成组件树。
1. FilterableProductTable(橙色):将所有组件整合在一起
SearchBar(蓝色):接收所有用户输入ProductTable(绿色):根据用户输入显示和筛选数据集合ProductCategoryRow(青绿色):显示每个类别的标题ProductRow(红色):显示每个产品的一行
3 – 表示用户界面状态
第三,如果你在第二步没有“弄明白”,那么你就会认真考虑状态的问题。大多数数据都可以作为 props,但如果一个组件是受控的,它或许可以拥有自己的状态,以便与其他组件交互?尽量使用 props,但在需要将其封装到组件内部时,则使用状态。无论采用面向对象的类方法还是函数式方法,组件通常都会包含一些你认为最好由其内部管理的内容。
4 – 确定州的所在地
最后,确定谁拥有数据源。虽然许多组件可以拥有自己的内部状态,但“应用程序的整体状态”通常由一个或少数几个组件负责。这些组件之间的交互将帮助你弄清楚状态应该放在哪里,以及如何管理它(事件、Context、Hooks、Redux 等)。
榆树
虽然包括我在内的许多人都希望立即着手构建组件,但 Elm 鼓励先认真思考你的模型。Elm 的类型系统让你能够消除不可能的应用程序状态,并简化你表示事物的方式。好消息是,即使你搞砸了,Elm 编译器拥有业内最好的错误信息,让你能够毫无顾虑地进行重构。
- 模型数据
- 组件层次结构
- 模型数据变更
- 处理事件
1 – 模型数据
第一步是使用 Elm 的类型系统对数据进行建模。与 React 类似,有些类型会像 API 一样被定义,有些则需要通过 BFF 进行自定义。不过,这也会受到设计师设计稿的很大影响。
type alias Product = {
category : String
, price : String
, stocked : Bool
, name : String }
type alias Model = {
products : List Product
}
initialModel =
[
Product {category = "Sporting Goods", price = "$49.99", stocked = True, name = "Football"}
, Product {category = "Sporting Goods", price = "$9.99", stocked = True, name = "Baseball"}
, Product {category = "Sporting Goods", price = "$29.99", stocked = False, name = "Basketball"}
, Product {category = "Electronics", price = "$99.99", stocked = True, name = "iPod Touch"}
, Product {category = "Electronics", price = "$399.99", stocked = False, name = "iPhone 5"}
, Product {category = "Electronics", price = "$199.99", stocked = True, name = "Nexus 7"}
]
2 – 组件层次结构
它几乎与 React 完全相同,区别在于组件中没有状态;所有状态都包含在你的模型中。你的 `<component>` FilterableProductTable、SearchBar`<component>` 等只是函数,它们通常将模型作为第一个也是唯一的参数。
3 – 模型数据变更
即使在 React 中使用 Redux,你仍然可以偶尔维护组件的内部状态。Elm 则不然;所有状态都保存在你的模型中。这意味着你的模型中SearchBar (blue)会有一个`state` 属性currentFilter : String来捕获当前筛选器(如果有)的状态。你还需要一个 `state` 属性onlyInStock : Bool来表示复选框的状态。在 React 中,这两者都可以实现:
- 组件中的状态
this.state - 组件中的状态
FilterableProductTable可以通过事件传递。 - Redux 中的状态
- 钩子状态
- 共享上下文中的状态
在 Elm 中,位置没有问题:就在模型中。
4 – 模型事件变更
在 Elm 中,你无需决定“UI 状态存放在哪里”,因为……所有数据都存在于模型(Model)中。相反,你需要决定如何更改这些数据。对于简单的应用程序,这与 Redux 中的操作非常相似:创建一个包含新数据的 Message,然后编写代码根据该消息更改模型。
type Msg = ToggleOnlyInStock Bool
现在你已经有了消息,当用户点击复选框时,你就可以发送消息了:
label
[ ]
[ input [ type_ "checkbox", onClick (ToggleOnlyInStock not model.onlyInStock) ] []
, text "Only show products in stock"]
最后,根据消息内容更改数据:
update msg model =
...
ToggleOnlyInStock toggle ->
{ model | onlyInStock = toggle }
发展
React
使用create-react-app,运行后npm start,您的更改和编译错误将很快反映在打开的浏览器窗口中。
对于生产环境构建,请运行npm run build。
榆树
使用elm-live,您运行 elm-live 后,您的更改和编译错误将很快反映在打开的浏览器窗口中。
对于生产环境构建,请elm make使用该--optimize标志运行。建议您先使用uglifyjs进行压缩,然后再使用 mangle,或者使用其他压缩器 + mangle 库。
测试
React
使用 create-react-app,你会运行一个内部npm test使用Jest 的测试环境。如果你的 UI 需要处理大量数据,或者使用了TypeScript,那么可以使用JSVerify来进行属性测试。对于端到端测试,Cypress是一个不错的选择。
榆树
对于 Elm 来说,由于编译器本身的正确性,单元测试通常意义不大。使用端到端测试效果更好,也更容易发现竞态条件。如果 UI 上需要处理大量数据,可以使用elm-test进行属性测试。虽然它通常用于单元测试,但内置了模糊测试和缩减器。对于端到端测试,Cypress 是一个不错的选择。
路由
React
虽然有很多选择,但react-router是许多人最终选择的一种。
function Home() {
return <h2>Home</h2>;
}
function About() {
return <h2>About</h2>;
}
function Users() {
return <h2>Users</h2>;
}
function App() {
return (
<Router>
<div>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/users">Users</Link>
</li>
</ul>
</nav>
</div>
</Router>
)
}
榆树
Elm内置了使用Browser 库的路由功能。
home =
h2 [] [ text "Home" ]
about =
h2 [] [ text "About" ]
users =
h2 [] [ text "Users" ]
app =
div [] [
nav [] [
ul [] [
li [] [
a [ href "/home" ] [ text "Home" ]
]
, li [] [
a [ href "/about" ] [ text "About" ]
]
, li [] [
a [ href "/users" ] [ text "Users" ]
]
]
]
]
误差边界
React
在 React 中,你需要构建一个或一组组件来封装常见的错误区域,这样,即使 UI 中某个不稳定的部分抛出异常,你也可以在 UI 中优雅地处理它。首先创建一个基本的封装组件:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI. return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
一旦你有了包含日志记录和备用 UI 的组件,你只需要把那些危险的组件包裹起来即可:
<ErrorBoundary>
<MyWidget />
</ErrorBoundary>
榆树
Elm 没有运行时错误(注意:下文将讨论移植风险)。编译器会确保处理所有可能的错误。这意味着你可以选择在模型中对这些错误状态进行建模,或者用空字符串忽略它们,或者为这些状态设计不同的用户界面。
数据不存在?您需要自行处理:
case dataMaybe of
Just data ->
addProduct data
Nothing ->
-- Your UI or data must compensate somehow here.
-- For now we just return all the products unchanged
model.products
HTTP 操作失败?您必须妥善处理:
case result of
Error err ->
{ model | result = ProductSaveFailed err }
Ok data ->
{ mdoel | result = ProductSaveSuccess data }
-- in UI
case result of
ProductSaveFailed err ->
errorViewAndRetry err
ProductSaveSuccess _ ->
goToProductView
HTTP
React
class Weather extends React.Component {
constructor(props) {
super(props);
this.state = { temperature: undefined, loading: true };
}
componentDidMount = () => {
this.setState({ loading: true })
fetch("server.com/weather/temperature")
.then( response => response.json() )
.then(
({ temperature }) => {
this.setState({ temperature, loading: false, isError: false }) )
}
)
.catch(
error => {
this.setState({ loading: false, isError: true, error: error.message })
}
)
}
render() {
if(this.state.loading) {
return <p>Loading...</p>
} else if(this.state.isError === false) {
return <p>Temperature: {this.state.temperature}</p>
} else {
return <p>Error: {this.state.error}</p>
}
}
}
榆树
type Msg = LoadWeather | GotWeather (Result Http.Error String)
type Model
= Loading
| Success String
| Failure Http.Error
init : () -> (Model, Cmd Msg)
init _ =
( Loading
, loadTemperature
)
loadTemperature =
Http.get
{ url = "server.com/weather/temperature"
, expect = Http.expectJson GotWeather temperatureDecoder
}
temperatureDecoder =
field "temperature" string
update msg model =
case msg of
LoadWeather ->
(Loading, loadTemperature)
GotWeather result ->
case result of
Err err ->
( Failure err, Cmd.none )
Ok temperature ->
( Success temperature, Cmd.none )
view model =
case model of
Loading ->
p [][text "Loading..."]
Success temperature ->
p [][text ("Temperature: " ++ temperature) ]
Failure _ ->
p [][text "Failed to load temperature."]
国家管理
重制版
// Action Creator
const addTodo = text => ({ type: 'ADD_TODO', text })
// Dispatch
const goSwimming = () => store.dispatch(addTodo('Go Swimming.'))
// trigger from button
<button onClick={goSwimming}>Add</button>
// update model
const todos = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO':
return state.concat([{ text: action.text, completed: false }])
default:
return state
}
}
榆树
-- Type for Todo
type alias Todo = { text : String, completed: Bool }
-- Message
type Msg = AddTodo String
-- trigger from button
button [ onClick (AddTodo "Go Swimming.")] [ text "Add" ]
-- update model
update msg model =
case msg of
AddTodo text ->
{ model | todos = List.append model.todos [Todo text, False] }
...