本文由Vildan Softic进行同行评审。 感谢所有SitePoint的同行评审人员使SitePoint内容达到最佳状态!
我是喜欢从头开始做事情并了解一切工作原理的开发人员之一。 尽管我知道自己所从事的(不必要的)工作,但它无疑可以帮助我理解和理解特定框架,库或模块的本质。
最近,我又遇到了其中之一,开始使用Redux开发 Web应用程序,除了香草JavaScript之外,别无其他 。 在本文中,我想概述一下如何构建应用程序,检查一些较早的(最终是不成功的)迭代,然后再查看我确定的解决方案以及在此过程中学到的知识。
设置
您可能已经听说过流行的React.js和Redux结合使用最新的前端技术来构建快速而强大的Web应用程序。
由Facebook制作的React是一个基于组件的开源库,用于构建用户界面。 尽管React只是一个视图层 (不是完整的框架,例如Angular或Ember) ,但是Redux可以管理应用程序的状态。 它起可预测状态容器的作用 ,整个状态存储在单个对象树中,并且只能通过发出所谓的操作来进行更改。 如果您是该主题的新手,建议您阅读这篇说明性文章 。
对于本文的其余部分,并不需要成为Redux的专家,但肯定有助于至少对其概念有基本的了解。
没有React的Redux-Scratch的应用程序
Redux之所以如此出色,是因为它迫使您提前思考并尽早了解应用程序设计。 您开始定义应实际存储的内容,可以更改的数据以及可以访问存储的组件。 但是由于Redux只关心状态,所以我发现自己对如何构造和连接其余应用程序有些困惑。 React可以很好地指导您完成所有事情,但是如果没有它,我就不得不找出最有效的方法。
该应用程序是第一个移动优先的俄罗斯方块克隆,它具有几个不同的视图。 实际的游戏逻辑在Redux中完成,而脱机功能由localStorage
和自定义视图处理提供。 可以在GitHub上找到该存储库,尽管该应用程序仍在积极开发中,我在撰写本文时正在写这篇文章。
定义应用架构
我决定采用Redux和React项目中常见的文件结构。 这是一个逻辑结构,适用于许多不同的设置。 这个主题有很多 变体 ,大多数项目在做事上有些不同,但是总体结构是相同的。
actions/
├── game.js
├── score.js
└── ...
components/
├── router.js
├── pageControls.js
├── canvas.js
└── ...
constants/
├── game.js
├── score.js
└── ...
reducers/
├── game.js
├── score.js
└── ...
store/
├── configureStore.js
├── connect.js
└── index.js
utils/
├── serviceWorker.js
├── localStorage.js
├── dom.js
└── ...
index.js
worker.js
我的标记被分离到另一个目录,并最终由单个index.html
文件呈现。 结构类似于scripts/
,以便在我的代码库中保持一致的体系结构。
layouts/
└── default.html
partials/
├── back-button.html
└── meta.html
pages/
├── about.html
├── settings.html
└── ...
index.html
管理和访问商店
要访问存储,需要一次创建它,并将其传递给应用程序的所有实例。 大多数框架都使用某种类型的依赖项注入容器,因此,作为框架的用户,我们不必提出自己的解决方案。 但是在滚动自己的解决方案时,如何使所有组件都可以访问它?
我的第一次迭代有点被炸了。 我不知道为什么我会认为这是一个好主意,但是我将商店放在了自己的模块( scripts/store/index.js
)中,然后可以由我的应用程序的其他部分导入。 我最终为此感到后悔,并很快地处理了循环依赖 。 问题是,当组件尝试访问商店时,商店没有得到正确的初始化。 我整理了一个图表来演示我正在处理的依赖流:
应用程序的入口点是初始化所有组件,然后直接或通过辅助函数(在此称为connect )在内部使用商店。 但是由于未明确创建存储,而只是在其自己的模块中作为副作用,因此组件最终在创建存储之前就使用了存储。 无法控制何时组件或辅助功能首次调用商店。 太混乱了。
存储模块如下所示:
scripts/store/index.js
(不好)
import { createStore } from 'redux'
import reducers from '../reducers'
const store = createStore(reducers)
export default store
export { getItemList } from './connect'
如上所述,创建商店是一种副作用,然后将其导出。 助手功能也需要存储。
scripts/store/connect.js
/store/connect.js(不好)
import store from './'
export function getItemList () {
return store.getState().items.all
}
这是我的组件最终相互递归的确切时刻。 辅助函数需要store
起作用,并同时从商店初始化文件中导出,以使应用程序的其他部分可以访问它们。 您看到已经听起来很混乱吗?
解决方案
现在似乎很明显,花了我一段时间才能理解。 我通过将初始化移到应用程序入口点 ( scripts/index.js
)并将其传递给所有必需的组件来解决了这个问题。
再次,这非常类似于React实际上使商店可访问(检查源代码 )的方式 。 他们在一起工作如此之好是有原因的,为什么不从其概念中学习呢?
应用程序入口点首先创建商店,然后将其向下传递给所有组件。 然后,组件可以与存储和调度动作连接 ,订阅更改或获取特定数据。
让我们来看看这些变化:
脚本 /store/configureStore.js(✓好)
import { createStore } from 'redux'
import reducers from '../reducers'
export default function configureStore () {
return createStore(reducers)
}
我保留了该模块,但是导出了一个名为configureStore
的函数,该函数在我的代码库的其他地方创建了商店。 注意,这只是基本概念; 我还利用Redux DevTools扩展并通过localStorage
加载持久状态。
scripts / store / connect.js (✓好)
export function getItemList (store) {
return store.getState().items.all
}
connect
helper函数基本上是不变的,但是现在需要将存储作为参数传递。 刚开始我犹豫要使用此解决方案,因为我认为“那么辅助函数的作用是什么?” 。 现在,我认为它们很好并且足够高级,可以使所有内容更具可读性。
import configureStore from './store'
import { PageControls, TetrisGame } from './components'
const store = configureStore()
const pageControls = new PageControls(store)
const tetrisGame = new TetrisGame(store)
// Further initialization logic.
这是应用程序的入口点。 创建store
,并将其传递给所有组件。 PageControls
将全局事件侦听器添加到特定的操作按钮,而TetrisGame
是实际的游戏组件。 在将存储移动到这里之前,它看起来基本相同,但是没有将存储单独传递到所有模块。 如前所述,组件可以通过我失败的connect
方法访问商店。
组件
我决定使用两种组件: presentational和container组件 。
呈现组件除了纯DOM处理外没有其他作用。 他们不知道这家商店。 另一方面,容器组件可以调度操作或订阅更改。
丹·阿布拉莫夫(Dan Abramov)撰写了一篇有关React组件的出色文章 ,但是该方法也可以应用于任何其他组件体系结构。
对我而言,也有例外。 有时,一个组件实际上很少,只能做一件事。 我不想将它们分解为上述模式之一,因此我决定将它们混合使用。 如果组件增加并获得更多逻辑,我将其分离。
import { $$ } from '../utils'
import { startGame, endGame, addScore, openSettings } from '../actions'
export default class PageControls {
constructor ({ selector, store } = {}) {
this.$buttons = [...$$('button, [role=button]')]
this.store = store
}
onClick ({ target }) {
switch (target.getAttribute('data-action')) {
case 'endGame':
this.store.dispatch(endGame())
this.store.dispatch(addScore())
break
case 'startGame':
this.store.dispatch(startGame())
break
case 'openSettings':
this.store.dispatch(openSettings())
break
default:
break
}
target.blur()
}
addEvents () {
this.$buttons.forEach(
$btn => $btn.addEventListener('click', this.onClick.bind(this))
)
}
}
上面的示例是这些组件之一。 它具有元素列表(在本例中为所有具有data-action
属性的元素),并根据属性内容在单击时分派操作。 没有其他的。 然后,其他模块可能会监听存储中的更改并相应地更新自身。 如前所述,如果组件也进行DOM更新,我将其分开。
现在,让我向您展示两种组件类型的清晰区分。
更新DOM
在启动项目时,我最大的问题之一是如何实际更新DOM。 React使用称为虚拟DOM 的DOM的快速内存表示形式将DOM更新保持在最低限度。
我实际上是在考虑做同样的事情,如果我的应用程序应该变得更大并且DOM越来越重,我很可能会切换到Virtual DOM ,但是现在我可以进行经典的 DOM操作,并且可以在Redux上正常工作。
基本流程如下:
- 初始化容器组件的新实例,并将其传递给
store
以供内部使用 - 组件订阅商店中的更改
- 并使用其他呈现组件在DOM中呈现更新
注意:对于JavaScript中与DOM相关的任何内容,我都喜欢$
符号前缀。 您可能已经猜到它是从jQuery的$
。 因此,纯演示文稿文件名以美元符号为前缀。
import configureStore from './store'
import { ScoreObserver } from './components'
const store = configureStore()
const scoreObserver = new ScoreObserver(store)
scoreObserver.init()
这里没有任何幻想。 容器组件ScoreObserver
被导入,创建和初始化。 它实际上是做什么的? 它会更新所有与分数相关的视图元素:高分数列表,以及在游戏过程中当前分数信息。
import { isRunning, getScoreList, getCurrentScore } from '../../store'
import ScoreBoard from './$board'
import ScoreLabel from './$label'
export default class ScoreObserver {
constructor (store) {
this.store = store
this.$board = new ScoreBoard()
this.$label = new ScoreLabel()
}
updateScore () {
if (!isRunning(this.store)) {
return
}
this.$label.updateLabel(getCurrentScore(this.store))
}
// Used in a different place.
updateScoreBoard () {
this.$board.updateBoard(getScoreList(this.store))
}
init () {
this.store.subscribe(this.updateScore.bind(this))
}
}
请记住,这是一个简单的组件; 其他组件可能具有更复杂的逻辑和需要注意的事情。 这里发生了什么? ScoreObserver
组件保存对store
的内部引用,并创建两个演示组件的新实例以供以后使用。 init
方法订阅商店更新,并在每次商店更改时更新$label
组件-但前提是游戏确实在运行。
updateScoreBoard
方法在其他地方使用。 每次发生更改时更新列表都是没有意义的,因为该视图始终处于不活动状态。 还有一个路由组件,该组件会在每次视图更改时更新或停用不同的组件。 其API大致如下所示:
// scripts/index.js
route.onRouteChange((leave, enter) => {
if (enter === 'scoreboard') {
scoreObserver.updateScoreBoard()
}
// more logic...
})
注意: $
(和$$
)不是jQuery引用,而是document.querySelector
的便捷实用程序快捷方式。
import { $ } from '../../utils'
export default class ScoreBoard {
constructor () {
this.$board = $('.tetrys-scoreboard')
}
emptyBoard () {
this.$board.innerHTML = ''
}
createListItem (txt) {
const $li = document.createElement('li')
const $span = document.createElement('span')
$span.appendChild(document.createTextNode(txt))
$li.appendChild($span)
return $li
}
updateBoard (list = []) {
const fragment = document.createDocumentFragment()
list.forEach((score) => fragment.appendChild(this.createListItem(score)))
this.emptyBoard()
this.$board.appendChild(fragment)
}
}
同样,一个基本示例和一个基本组成部分。 updateBoard()
方法获取一个数组,对其进行迭代,然后将其内容插入得分列表。
import { $ } from '../../utils'
export default class ScoreLabel {
constructor () {
this.$label = $('.game-current-score')
this.$labelCount = this.$label.querySelector('span')
this.initScore = 0
}
updateLabel (score = this.initScore) {
this.$labelCount.innerText = score
}
}
此组件的功能几乎与上面的ScoreBoard
完全相同,但是仅更新了一个元素。
其他错误和建议
另一个重要点是实现用例驱动的存储。 我认为仅存储对于应用程序必不可少的内容很重要 。 在开始的时候,我几乎存储了所有内容:当前活动视图,游戏设置,得分,悬停效果, 用户的呼吸模式等。
尽管这可能与一个应用程序相关,但与另一个应用程序无关。 存储当前视图,并在重新加载时在完全相同的位置继续是很好的,但是在我的情况下,这感觉像是糟糕的用户体验,并且比有用的东西更烦人。 您也不想存储菜单或模式的切换,对吗? 用户为什么要回到该特定状态? 在较大的Web应用程序中,这可能很有意义。 但是在我的小型移动游戏中,仅仅因为我离开那里而回到设置屏幕是很烦人的。
结论
我在有和没有React的情况下都参与过Redux项目,而我的主要收获是,不需要在应用程序设计中有巨大的差异。 实际上,React中使用的大多数方法都可以适应任何其他视图处理设置。 我花了一段时间才意识到这一点,因为我开始以为我必须做不同的事情 ,但最终我意识到这是没有必要的。
什么是不同的不过是你初始化模块,您的商店的方式,多少认识一个组件可以有整个应用程序的状态。 概念保持不变,但是代码的实现和数量完全适合您的需求。
Redux是一个很棒的工具,它可以帮助您以更深思熟虑的方式构建应用程序。 单独使用时,如果没有任何视图库,一开始可能会非常棘手,但是一旦您摆脱了最初的困惑,便无法阻止您。
您如何看待我的方法? 您是否一直在单独使用Redux和其他视图处理设置? 我希望得到您的反馈并在评论中进行讨论。
如果您需要有关Redux的更多信息,请查看我们的课程重写和测试Redux以解决设计问题迷你课程。 在本课程中,您将构建一个Redux应用程序,该应用程序通过Websocket连接接收按主题组织的推文。 为了让您尝尝店里的东西,请查看下面的免费课程。
From: https://www.sitepoint.com/redux-without-react-state-management-vanilla-javascript/