不使用React构建有状态的Web应用

83 阅读6分钟

React、Angular和Vue都是很好的框架,可以让网络应用以一致的结构快速启动和运行。不过它们都是建立在JavaScript之上的,所以让我们来看看我们如何只用普通的JavaScript就能完成这些大框架所做的漂亮事情。

这篇文章可能会引起那些过去使用过这些框架,但从未完全理解它们在引擎盖下做什么的开发者的兴趣。我们将通过演示如何仅使用vanilla JavaScript构建一个有状态的Web应用,来探索这些框架的不同方面。

状态管理

管理状态是React、Angular和Vue内部或通过Redux或Zustand等库进行的。然而,状态可以是一个简单的JavaScript对象,包含所有你的应用程序感兴趣的属性-价值键对。

如果你正在构建经典的待办事项列表应用,你的状态可能会包含一个类似currentTodoItemID 的属性,如果它的值是null ,你的应用可能会显示所有待办事项的完整列表。

如果currentTodoItemID 被设置为某个特定的todoItem 的ID,应用程序可能会显示该todoItem's 的详细信息。如果你正在构建一个游戏,你的状态可能包含属性和值,如playerHealth = 47.5currentLevel = 2.

其实状态的形状或大小并不重要;重要的是你的应用程序的组件如何改变它的属性,以及其他组件如何对这些变化做出反应。

这就给我们带来了第一个神奇的东西:代理对象

代理对象是JavaScript从ES6开始的原生对象,可以用来监视一个对象的变化。为了了解如何利用JavaScript中的代理对象,让我们看看下面index.js file 中的一些示例代码,其中使用了一个名为on-change的npm模块:

import onChange from 'on-change';

class App = {
  constructor() {
    // create the initial state object
    const state = {
      currentTodoItemID: null
    }
    // listen for changes to the state object
    this.state = onChange(state, this.update);
  }
  // react to state changes
  update(path, current, previous) {
    console.log(`${path} changed from ${previous} to ${current}`);
  }
}

// create a new instance of the App
const app = new App();

// this should log "currentTodoItemID changed from null to 1"
app.state.currentTodoItemID = 1;

注意代理对象不能在Internet Explorer中工作,所以如果这是你的项目的一个要求,你应该考虑到这个警告。没有办法对代理对象进行聚填,所以你需要使用轮询,每秒钟检查几次状态对象,看看它是否有变化,这既不优雅也不高效。

构建组件

React中的组件只是结构的HTML、逻辑的JavaScript和造型的CSS的模块化部分。有些是要单独显示的,有些是要依次显示的,有些可能只是用HTML来容纳一些完全不同的东西,比如可更新的SVG图片或WebGL画布。

无论你构建的是什么类型的组件,它都应该能够访问你的应用程序的状态,或者至少是与之有关的状态部分。下面的代码来自src/index.js ):

import onChange from 'on-change';
import TodoItemList from 'components/TodoItemList';

class App = {
  constructor() {
    const state = {
      currentTodoItemID: null,
      todoItems: [] // *see note below
    }
    this.state = onChange(state, this.update);
   
    // create a container for the app
    this.el = document.createElement('div');
    this.el.className = 'todo';

    // create a TodoItemList, pass it the state object, and add it to the DOM
    this.todoItemList = new TodoItemList(this.state);
    this.el.appendChild(this.todoItemList.el);
  }

  update(path, current, previous) {
    console.log(`${path} changed from ${previous} to ${current}`);
  }
}

const app = new App();
document.body.appendChild(app.el);

当你的应用程序规模扩大时,将像state.todoItems ,可能会增长得相当大的东西从你的状态对象中移出,转移到像数据库这样的持久性存储方法中,是一个很好的做法。

在状态中保持对这些组件的引用,就像下面的src/components/TodoItemList.jssrc/components/TodoItem.js 所示,这样做更好:

import TodoItem from 'components/TodoItem';

export default class TodoItemList {
  constructor(state) {
    this.el = document.createElement('div');
    this.el.className = 'todo-list';

    for(let i = 0; i < state.todoItems.length; i += 1) {
      const todoItem = new TodoItem(state, i);
      this.el.appendChild(todoItem);
    }
  }
}
export default class TodoItem {
  constructor(state, id) {
    this.el = document.createElement('div');
    this.el.className = 'todo-list-item';
    this.title = document.createElement('h1');
    this.button = document.createElement('button');

    this.title.innerText = state.todoItems[id].title;
    this.button.innerText = 'Open';
    this.button.addEventListener('click', () => { state.currentTodoItemID = id });

    this.el.appendChild(this.title);
    this.el.appendChild(this.button);
  }
}

React也有视图的概念,它类似于组件,但不需要任何逻辑。我们可以使用这种虚无缥缈的模式建立类似的容器。我不会包括任何具体的例子,但它们可以被认为是框架性的组件,只是将应用程序的状态传递给其中的功能组件。

DOM操作

DOM操作是像React这样的框架真正闪耀的领域。因此,虽然我们通过在vanilla JavaScript中自己处理标记获得了一点灵活性,但我们失去了与这些框架更新方式相关的很多便利。

让我们在我们的待办事项应用的例子中试一下,看看我在说什么。下面的代码来自src/index.jssrc/components/TodoItemList.js

import onChange from 'on-change';
import TodoItemList from 'components/TodoItemList';

class App = {
  constructor() {
    const state = {
      currentTodoItemID: null,
      todoItems: [
        { title: 'Buy Milk', due: '3/11/23' },
        { title: 'Wash Car', due: '4/13/23' },
        { title: 'Pay Rent', due: '5/15/23' },
      ]
    }
    this.state = onChange(state, this.update);

    this.el = document.createElement('div');
    this.el.className = 'todo';

    this.todoItemList = new TodoItemList(this.state); 
    this.el.appendChild(this.todoItemList.el);
  }

  update(path, current, previous) {
    if(path === 'todoItems') {
      this.todoItemList.render();
    }
  }
}

const app = new App();
document.body.appendChild(app.el);

app.state.todoItems.splice(1, 1); // remove the second todoListItem
app.state.todoItems.push({ title: 'Eat Pizza', due: '6/17/23'); // add a new one
import TodoItem from 'components/TodoItem';

export default class TodoItemList {
  constructor(state) {
    this.state = state;
    this.el = document.createElement('div');
    this.el.className = 'todo-list';
    this.render();
  }

  // render the list of todoItems to the DOM 
  render() {
    // empty the list
    this.el.innerHTML = '';
    // fill the list with todoItems
    for (let i = 0; i < this.state.todoItems.length; i += 1) {
      const todoItem = new TodoItem(state, i);
      this.el.appendChild(todoItem);
    }
  }
}

在上面的例子中,我们创建了一个TodoItemList ,在我们的状态中预装了三个todoListItems 。然后,我们删除中间的TodoItem ,并添加一个新的。

虽然这个策略可以正常工作并正常显示,但它的效率很低,因为它涉及到删除所有现有的DOM节点并在每次渲染时创建新节点。

React在这方面比JavaScript更聪明;它将每个DOM节点的引用保留在内存中。你可能已经注意到React标记中奇怪的标识符,比如下面显示的那些:

Generic React Markup

我们也可以通过存储每个节点的引用来进行类似的DOM操作。对于todoListItems ,它可能看起来像这样:

 for(let i = 0; i < this.state.todoItems.length; i += 1) {
   // instead of making anonymous elements, attach them to state
   this.state.todoItems[i].el = new TodoItem(this.state, i);
   this.el.appendChild(this.state.todoItems[i].el);
 }

虽然这些操作会起作用,但在向你的状态添加DOM元素时,你应该小心。它们不仅仅是对它们在DOM树中的位置的引用;它们包含自己的属性和方法,在你的应用程序的生命周期中可能会发生变化。

如果你走这条路,最好使用ignoreKeys 参数来告诉on-change模块忽略添加的DOM元素。

生命周期钩子

React有一套一致的生命周期Hooks,使得开发者可以很容易地开始在一个新项目上工作,并迅速了解应用程序运行时将发生什么。两个最引人注目的Hooks是ComponentDidMount()ComponentWillUnmount()

让我们举一个非常基本的例子,在src/index.js 文件中,简单地称它们为show()hide()

import onChange from 'on-change';
import Menu from 'components/Menu';

class App = {
  constructor() {
    const state = {
      showMenu: false
    }
    this.state = onChange(state, this.update);
   
    this.el = document.createElement('div');
    this.el.className = 'todo';
    // create an instance of the Menu
    this.menu = new Menu(this.state);

    // create a button to show or hide the menu
    this.toggle = document.createElement('button');
    this.toggle.innerText = 'show or hide the menu';

    this.el.appendChild(this.menu.el);
    this.el.appendChild(this.toggle);

    // change the showMenu property of our state object when clicked
    this.toggle.addEventListener('click', () => { this.state.showMenu = !this.state.showMenu; })
  }

  update(path, current, previous) {
    if(path === 'showMenu') {
      // show or hide menu depending on state
      this.menu[current ? 'show' : 'hide']();
    }
  }
}

const app = new App();
document.body.appendChild(app.el);

现在,这里有一个例子(来自src/components/menu.js ),说明我们如何在JavaScript中编写自定义Hooks:

export default class Menu = {
  constructor(state) {
    this.el = document.createElement('div');
    this.title = document.createElement('h1');
    this.text = document.createElement('p');
    
    this.title.innerText = 'Menu';
    this.text.innerText = 'menu content here';

    this.el.appendChild(this.title);
    this.el.appendChild(this.text);

    this.el.className = `menu ${!state.showMenu ? 'hidden' : ''}`;
  }

  show() {
    this.el.classList.remove('hidden');
  }

  hide() {
    this.el.classList.add('hidden');
  }
}

这种策略允许我们编写任何我们喜欢的内部方法。例如,你可能想根据菜单是由用户关闭的,还是因为应用程序中发生了其他事情而关闭的,来改变菜单的动画方式。

React通过使用一套标准的Hooks来实现一致性,但我们可以在vanilla JavaScript中为我们的组件编写自定义的Hooks,从而拥有更大的灵活性。

路由

现代网络应用的一个重要方面是能够跟踪当前的位置,并通过使用应用的用户界面或浏览器的后退和前进按钮,在历史上向前和向后移动。当你的应用程序尊重 "深层链接"(如todoapp.com/currentTodo…)时,这也很好

React Router在这方面做得很好,我们可以用一些技术做类似的事情。一个是JavaScript的本地历史API。通过向其数组推送和弹出,我们可以跟踪我们想要保存在页面历史中的状态变化。我们还可以监听它的变化,并将这些变化应用到我们的状态对象中(以下代码来自index.js

import onChange from 'on-change';

class App = {
  constructor() {
    // create the initial state object
    const state = {
      currentTodoItemID: null
    }
    // listen for changes to the state object
    this.state = onChange(state, this.update);
  
    // listen for changes to the page location
    window.addEventListener('popstate', () => {
      this.state.currentTodoItemID = window.location.pathname.split('/')[2];
    });

    // on first load, check for a deep link
    if(window.location.pathname.split('/')[2]) {
      this.state.currentTodoItemID = window.location.pathname.split('/')[2];
    }
  }
  // react to state changes
  update(path, current, previous) {
    console.log(`${path} changed from ${previous} to ${current}`);
    if(path === 'currentTodoItemID') {
      history.pushState({ currentTodoItemID: current }, null, `/currentTodoItemID/${current}`);
    }
  }
}

// create a new instance of the App
const app = new App();

你可以随心所欲地扩展它;对于复杂的应用程序,你可能有10个或更多不同的属性来影响它应该显示的内容。这种技术比React Router需要更多的设置,但使用vanilla JavaScript也能达到同样的效果。

文件组织

React的另一个很好的副产品是它鼓励你组织你的目录和文件,从一个入口点开始,通常命名为 index.jsapp.js ,靠近项目文件夹的根。

接下来,你通常会在同一位置找到/views/components 文件夹,里面装满了应用程序要利用的各种视图和组件,以及一些/subviews/subcomponents

这种清晰的划分使原作者或加入项目的新开发者更容易进行更新。

下面是一个待办事项列表应用程序的样本文件夹结构:

src
├── assets
│   ├── images
│   ├── videos
│   └── fonts
├── components
│   ├── TodoItem.js
│   ├── TodoItem.scss
│   ├── TodoItemList.js
│   └── TodoItemList.scss
├── views
│   ├── nav.js
│   ├── header.js
│   ├── main.js
│   └── footer.js
├── index.js
└── index.scss

在我的应用程序中,我通常通过JavaScript来创建标记,这样我就有一个参考,但你也可以使用你最喜欢的模板引擎,甚至包括.html 文件来构建每个组件。

调试

React有一套调试工具,可以在Chrome的开发者控制台中运行。

通过这种香草式的JavaScript方法,你可以在onChange's listener里面创建一些中间件,你可以设置为做很多类似的事情。就我个人而言,我喜欢在应用程序看到它在本地运行时,只在控制台对状态进行所有的改变(window.location.hostname === 'localhost')。

有时,你想只关注特定的变化或组件,这也很容易。

结束语

显然,学习和使用大的框架有巨大的优势,但请记住,它们都是用JavaScript编写的。重要的是,我们不要对它们产生依赖。

有一大批React、Angular或Vue的开发者设法放弃学习JavaScript的基础,如果他们只想在React、Angular或Vue项目上工作,那也没关系。对于我们其他人来说,了解底层语言、它的能力和它的不足之处是很好的。

我希望这篇文章能让你对这些大型框架的工作方式有一点了解,并给你一些想法,在它们不工作时如何调试它们。

请使用下面的评论,对如何改进这个系统提出建议,或者指出我所犯的任何错误。我发现这个设置是一个直观而薄的脚手架层,支持各种规模和功能的应用程序,但我在每个项目中都会继续发展它。

经常有其他开发者看到我的应用程序,认为我在使用某个大框架。当他们问起 "这是用什么构建的?"时,能够回答 "JavaScript "就很好了。🙂