在Nette和纯JS中创建依赖选择框的方法

92 阅读4分钟

如何创建链式选择框,在一个选择框中选择一个值后,另一个选择框中的选项会动态更新?这在Nette和纯JavaScript中是一项简单的任务。我们将展示一个干净的、可重复使用的、安全的解决方案。

数据模型

作为一个例子,让我们创建一个包含选择框的表单来选择国家和城市。

首先,我们将准备一个数据模型,它将返回两个选择框的条目。它可能会从数据库中检索它们。具体的实现并不重要,所以我们只提示一下界面会是什么样子。

class World
{
	public function getCountries(): array
	{
		return ...
	}

	public function getCities($country): array
	{
		return ...
	}
}

因为城市的总数非常大,我们将使用AJAX来检索它们。为此,我们将创建一个EndpointPresenter ,这个API将以JSON格式返回每个国家的城市。

class EndpointPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private World $world,
	) {}

	public function actionCities($country): void
	{
		$cities = $this->world->getCities($country);
		$this->sendJson($cities);
	}
}

如果城市很少(例如在另一个星球上😉),或者模型代表的数据根本不多,我们可以把它们全部作为数组传递给JavaScript,并保存AJAX请求。在这种情况下,就不需要EndpointPresenter

形式

让我们继续讨论表单本身。我们将创建两个选择框,并将它们连接起来,也就是说,我们将根据父框(country )的选择值来设置子框(city )项目。重要的是,我们要在onAnchor事件处理程序中这样做,也就是说,在表单已经知道用户提交的值的时候。

class DemoPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private World $world,
	) {}

	protected function createComponentForm(): Form
	{
		$form = new Form;
		$country = $form->addSelect('country', 'Country:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'City:');
		// <-- we'll add something else here

		$form->onAnchor[] = fn() =>
			$city->setItems($country->getValue()
				? $this->world->getCities($country->getValue())
				: []);

		// $form->onSuccess[] = ...
		return $form;
	}
}

以这种方式创建的表单不需要JavaScript就能工作。这是通过让用户首先选择一个国家,提交表单,然后会出现一个城市菜单,选择其中一个,然后再次提交表单。

然而,我们对使用JavaScript动态加载城市感兴趣。最干净的方法是使用data- 属性,在其中我们向HTML(以及JS)发送关于哪些选择框被链接以及从哪里检索数据的信息。

对于每个子选择框,我们传递一个带有父元素名称的data-depends 属性,然后传递一个带有使用AJAX检索项目的URL的data-url ,或者传递一个我们直接列出所有选项的data-items属性。

让我们从AJAX的变体开始。我们传递父元素的名称country 和对Endpoint:cities 的引用。我们使用# 字符作为占位符,JavaScript会把用户选择的键代替。

$city = $form->addSelect('city', 'City:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-url', $country->link('Endpoint:cities', '#'));

那么没有AJAX的变体呢?我们准备一个包含所有国家和所有城市的数组,并将其传递给data-items 属性。

$items = [];
foreach ($this->world->getCountries() as $id => $name) {
	$items[$id] = $this->world->getCities($id);
}

$city = $form->addSelect('city', 'City:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-items', $items);

JavaScript处理程序

下面的代码是通用的,它没有绑定到例子中特定的countrycity 选择框,但它将链接页面上的任何选择框,只要设置提到的data- 属性。

这段代码是用纯粹的vanilla JS编写的,所以它不需要jQuery或任何其他库。

// find all child selectboxes on the page
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // parent <select>
	let url = childSelect.dataset.url; // attribute data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // attribute data-items

	// when the user changes the selected item in the parent selection...
	parentSelect.addEventListener('change', () => {
		// if the data-items attribute exists...
		if (items) {
			// load new items directly into the child selectbox
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// if the data-url attribute exists...
		if (url) {
			// we make AJAX request to the endpoint with the selected item instead of placeholder
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// and load new items into the child selectbox
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// replaces <options> in <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // remove all
	for (let id in items) { // insert new
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

更多元素和可重用性

该解决方案并不局限于两个选择框,你可以创建一个由三个或更多依赖元素组成的级联。例如,我们添加一个街道选择,它依赖于所选城市。

$street = $form->addSelect('street', 'Ulice:')
	->setHtmlAttribute('data-depends', $city->getHtmlName())
	->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));

$form->onAnchor[] = fn() =>
	$street->setItems($city->getValue() ? $this->world->getStreets($city->getValue()) : []);

另外,多个选择框可以依赖于一个共同的选择框。只要通过类比来设置data- 属性,然后用setItems() 来填充这些项目。

完全不需要对JavaScript代码做任何修改,它可以普遍地工作。

安全性

即使在这些例子中,Nette表单所具有的所有安全机制仍然被保留下来。特别是,每个选择框都会检查所选的选项是否是所提供的选项之一,因此攻击者无法欺骗不同的值。


该解决方案适用于Nette 2.4及以后的版本,代码样本是为PHP 8编写的。要使它们在旧版本中工作,用function () use (...) { ... } 替换属性推广fn()