锻造场 - JavaScript、表单和 Ajax

作者:Reuven M. Lerner

上个月,我们开始涉足 Ajax 的领域,Ajax 是异步 JavaScript 和 XML 的简称,它已席卷 Web 开发领域。Ajax 应用程序在各方面都是 Web 应用程序,它们依赖于 HTML、HTTP、URL、JavaScript 和 CSS 的底层组合,这些组合提供了现代 Web 基础设施。但是,它们也依赖于现代 JavaScript 的几个特性,包括其重写网页以及异步发出 HTTP 请求的能力。

的确,这种异步行为正是 JavaScript(以及 Ajax,就此而言)让 Web 开发人员如此兴奋的原因。我们不再像过去那样受限于旧式的 3270 终端,执行仅在页面之间切换时才在服务器上进行。现在,无需重新加载页面即可更新网页。

Ajax 不是一场技术革命,而是一场概念革命,它为用户带来了新的期望,为开发人员带来了新的范例。Ajax 背后的所有技术已经存在多年,但直到现在我们才开始在 Web 应用程序中利用它。

本月,我们开始研究 Ajax 应用程序的一个简单示例,其背景对于大多数 Web 开发人员来说可能很熟悉:要求用户注册网站。在本月专栏结束时,您将了解我们如何结合服务器端程序、HTML 表单和 JavaScript 来在表单提交之前检查其有效性。下个月,我们将了解如何使用 Ajax 来克服与此实现相关的致命缺陷,从而一次性提高应用程序的效率、稳健性和安全性。

注册用户

如果您从事 Web 网站开发已经有一段时间了,那么您可能需要创建一个登录系统。此类系统有各种形状和大小,并且需要不同的安全级别。对于我们的示例应用程序,我们假设每个用户都有一个用户名和密码。实际上,正如您将看到的,我们不太关心密码;这里的关键问题是用户名,它必须是唯一的。

首先要做的是在我们的数据库中创建一个简单的表来跟踪用户

CREATE TABLE Users (
id              SERIAL    NOT NULL,
username  TEXT       NOT NULL    CHECK (username <> ''),
password   TEXT      NOT NULL    CHECK (password <> ''),
    email_address  TEXT   NOT NULL  CHECK (email_address <> ''),

PRIMARY KEY(id),
UNIQUE(username)
);

上面使用 PostgreSQL 语法定义的表为我们跟踪用户。每个用户都有一个唯一的数字 ID,存储在 id 列中。(PostgreSQL 中的特殊 SERIAL 数据类型确保我们 INSERT 到数据库中的每一行都将具有唯一的 id 值。)就本专栏而言,我们忽略了与以纯文本形式存储密码相关的安全问题。

接下来,我们定义三个 TEXT 类型的列,PostgreSQL 使用 TEXT 类型来定义无限长度的文本字段。这些字段中的每一个也都经过完整性检查,以确保该值既不能为空白也不能为 NULL。我们还将 username 列定义为 UNIQUE。

现在,从数据完整性的角度来看,我们已经完成了我们的工作。我们可以确定,没有两个用户会具有相同的用户名,每个电子邮件地址(即每个人)都可以在系统中拥有多个用户名,并且用户名、电子邮件地址和密码不能为空。其他一切都只是锦上添花,对吧?

嗯,是的——但前提是我们愿意向用户提供数据库错误。我们大多数人更愿意为用户提供更温和的着陆体验,不仅告诉他们(例如)他们选择的用户名已被其他人占用,而且还要让他们免受 PostgreSQL 显示的错误的影响。

这意味着我们的应用程序将需要获取用户请求的用户名,在数据库中检查它,然后显示错误消息(提示用户重试)或在数据库中 INSERT 新行。

这是一个关于我们如何在网页中执行此操作的简单示例。本月的服务器端示例我使用了用 Perl 编写的简单 CGI 程序,这主要是因为它们往往易于理解并在任何主机上尝试。清单 1 (register.html) 显示了用户想要注册网站时将看到的 HTML 表单。清单 2 (register.pl) 显示了将接受表单内容、在数据库中检查用户名,然后生成响应消息的 CGI 程序。我的假设是表单将位于主 Web 文档根目录中,而 CGI 程序将位于 cgi-bin 目录中。显然,如果您使用服务器端语言(例如 PHP),则两者可以并排存在。

清单 1. register.html

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
  <head><title>Register</title>
  </head>
  <body>
    <h2>Register</h2>
    <form action="/cgi-bin/register.pl" method="post">
        <p>Username: <input type="text" name="username" /></p>
        <p>Password: <input type="password" name="password" /></p>
        <p>E-mail address: <input type="text" name="email_address" /></p>
        <p><input type="submit" value="Register" /></p>
    </form>
  </body>
</html>

清单 2. register.pl

#!/usr/bin/perl
use strict;
use diagnostics;
use warnings;
use CGI;
use DBI;
# ------------------------------------------------------------
# # Connect to the database
# ------------------------------------------------------------
# my $dbname = 'atf';
my $dbuser = 'reuven';
my $dbpassword = '';
my $dbh = DBI->connect("DBI:Pg:dbname=$dbname",
                              $dbuser, $dbpassword,
{
                        AutoCommit => 1, RaiseError => 1,
PrintError => 1, ChopBlanks => 1}) ||
    print "<p>Error connecting: '$DBI::errstr' </p>";

# ------------------------------------------------------------
# CGI startup
# ------------------------------------------------------------
my $query = new CGI;
print $query->header("text/html");
print $query->start_html(-title => "Site registration");

my $username = $query->param("username");
my $password = $query->param("password");
my $email_address = $query->param("email_address");

# ------------------------------------------------------------
# Check the parameters
# ------------------------------------------------------------
my @missing_data = ();

push @missing_data, "The username"
    unless $username;

push @missing_data, "A password"
    unless $password;

push @missing_data, "The e-mail address"
    unless $email_address;

if (@missing_data)
{
    foreach my $missing_field (@missing_data)
    {
        print "<p>Sorry, but you are missing:</p>\n";
        print "<ul>\n";
        print "<li> $missing_field</li>\n";
        print "</ul>\n";
        print "<p>Please back up and try again.</p>\n";
        exit;
    }
}


# ------------------------------------------------------------
# Try to register the user
# ------------------------------------------------------------
my $select_sql = "SELECT COUNT(*) FROM Users WHERE username = ?";
my $select_sth = $dbh->prepare($select_sql);
$select_sth->execute($username);

my ($username_is_taken) = $select_sth->fetchrow_array();

# Is this username taken?  If so, give an error
if ($username_is_taken)
{
    print "<p>Sorry, but the username '$username' was already taken.
Please back up and try again.</p>\n";
}

# Otherwise, insert the new trio into the
database
else
{
    my $insert_sql = "INSERT INTO Users (username, password,
email_address)
                                    VALUES (?, ?, ?)";
    $dbh->do($insert_sql, {}, $username, $password, $email_address);

    print "<p>Added the username '$username' to the system!</p>\n";
}

print $query->end_html;

HTML 表单有三个文本字段,用户需要在其中输入用户名、密码和电子邮件地址。(我个人更喜欢使用电子邮件地址作为用户名,但我意识到很多人不喜欢,所以我本月会添加它。)我们从数据库定义中知道,用户名在系统中必须是唯一的,但是姓名和电子邮件地址可以各自存在多次。

注册程序(在清单 2 中)是一个相对简单的 CGI 程序。它使用 Perl 的 DBI 接口连接到数据库,然后使用 CGI 程序的标准开头,获取参数并进行常规准备。然后,该程序检查数据库以查看用户名是否已存在,返回数据库中与其匹配的行数。如果没有行匹配,我们可以假设该用户名可用。(这里存在某种竞争条件,但我们不会为这个小示例使用事务来使事情复杂化。)

使表单动态化

这是我们大多数人熟悉的注册表单类型。此外,这也是我们许多人继续在各种网站上实施的注册表单类型。程序员很容易构建它,它易于理解和调试,并且与所有浏览器兼容。

问题不在于程序的技术基础,而在于用户界面。从非技术用户的角度来看,他们输入用户名、密码和电子邮件地址,然后提交,然后才发现用户名不可接受,这是没有道理的。肯定有办法解决这个问题!

在表单提交到服务器之前对其进行检查的唯一方法是使用客户端语言——即嵌入在 Web 浏览器中的语言,它可以附加到浏览器窗口事件。此类语言的通用标准是 ECMAScript,因为正是 ECMA International(以前称为欧洲计算机制造商协会)批准并发布了该标准。但是,大多数人将 ECMAScript 称为启发该标准的语言,即 JavaScript。

JavaScript 几乎总是出现在 HTML 文档的页面中。我们可以在文档内部定义和调用函数,并使用事件处理程序触发调用。因此,我们可以在某人单击提交按钮时,在内容发送到服务器之前检查表单的内容。当鼠标在特定文本和图形上移动(或移开)时,我们可以更改样式。并且,当某人进入或退出 HTML 表单元素时,我们可以执行函数。

清单 3 包含 js-register.html,它是 register.html 的修改版本。此文件中的基本思想是,一旦用户修改了 username 文本字段,浏览器就会执行 checkUsername 函数。

清单 3. js-register.html

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
  <head><title>Register</title>

    <script type="text/javascript">
<!--
        var usernames = ['abc', 'def'];

        function removeText(node) {
            if (node != null)
            {
        if (node.childNodes)
        {
            for (var i=0 ; i < node.childNodes.length ; i++)
            {
            var oldTextNode = node.childNodes[i];
            if (oldTextNode.nodeValue != null)
            {
                node.removeChild(oldTextNode);
            }
            }
        }
            }
        }

        function appendText(node, text) {
            var newTextNode = document.createTextNode(text);
            node.appendChild(newTextNode);
        }


        function setText(node, text) {
            removeText(node);
            appendText(node, text);
        }

        function checkUsername() {

            var new_username = document.forms[0].username.value;
            var found = false;
            var warning = document.getElementById("warning");
            var submit_button = document.getElementById("submit-button");

        // Is this new username already taken?  Iterate over
        // the list of usernames to be sure.
            for (i=0 ; i<usernames.length; i++)
            {
                if (usernames[i] == new_username)
                {
                    found = true;
                }
            }

        // If we find the username, issue a warning and stop
        // the user from submitting the form.
            if (found)
            {
                setText(warning, "Warning: username '" + new_username
+"' was taken!");
                submit_button.disabled = true;
            }

            else
            {
                removeText(warning);
                submit_button.disabled = false;
            }
        }
-->    </script>
  </head>
  <body>
    <h2>Register</h2>
    <p id="warning"></p>
    <form action="/cgi-bin/register.pl" method="post">
        <p>Username: <input type="text" name="username"
onchange="checkUsername()" /></p>
        <p>Password: <input type="password" name="password" /></p>
        <p>E-mail address: <input type="text" name="email_address" /></p>
        <p><input type="submit" value="Register" id="submit-button"
/></p>
    </form>
  </body>
</html>

这是大多数客户端 Web 程序的结构方式。JavaScript 函数执行实际工作,但它们由 HTML 中定义的事件处理程序调用。因此,在清单 3 中,我们看到

<p>Username: <input type="text" name="username"
    onchange="checkUsername()" /></p>

这告诉浏览器,当 username 文本字段更改时,它应该调用 checkUsername() 函数。当此函数执行时,它从以下内容开始

var new_username = document.forms[0].username.value;

new_username 变量获取 username 文本字段的值。我们通过从 document 对象(表示我们的 HTML 文档)开始,然后获取其 forms 数组的第一个元素(表示文档中的第一个也是唯一的表单)来做到这一点。表单的 username 属性为我们提供了 username 文本字段的节点,然后我们可以使用 value 属性检索其值(作为字符串)。

以这种方式遍历树是在使用 JavaScript 时的典型做法。但是,也可以立即跳转到特定的表单元素,前提是该元素已分配了 id 属性。ID 在文档中必须是唯一的,这意味着我们可以使用适当的方法找到节点

var warning = document.getElementById("warning");
var submit_button = document.getElementById("submit-button");

上面的两行代码都使用 document.getElementById 从文档树中检索节点,并使用 id 属性进行标识。(如果没有任何匹配项,则变量将设置为 null 值。)

用户名列表已在清单 3 中硬编码,这在实际应用程序中是永远不会尝试的。我在下面进一步讨论这一点,并且我们将在下个月找到生产质量的 Ajax 风格的解决方案。

动态修改页面

现在我们有了 JavaScript 中的用户名列表,我们希望强制用户选择一个与已使用的用户名不冲突的用户名。我们将通过将建议的用户名与我们已经收集的列表进行比较来做到这一点。如果用户名已被占用,我们将通过修改当前页面的 HTML,然后禁用提交按钮来警告用户。只有当选择的用户名是新的且唯一的时,才允许用户将其提交到服务器。这并不意味着我们将删除服务器或数据库上的唯一性检查,但它将一些检查工作转移到客户端,并使应用程序对用户的需求做出更即时的响应。

我们通过迭代 usernames(包含用户名的数组)来做到这一点

for (i=0 ; i<usernames.length; i++)
{
    if (usernames[i] == new_username)
    {
        found = true;
    }
}

如果我们在用户请求的新用户名和数组中的用户名之间找到匹配项,则我们将 found 变量设置为 true。否则,它将继续设置为 false。然后,这将告诉我们是否需要警告用户存在冲突并禁用提交按钮,反之亦然。

警告用户包括两个步骤。第一步涉及将警告文本(即表单上方 <p> 标签内的文本)设置为适当的消息。我们已经在 checkUsername 函数的开头将变量 warning 设置为指向该节点,这意味着我们现在必须消除 warning 节点的所有子节点。实际上,我们不想消除所有子节点,而只是消除具有 nodeValue 属性的子节点,因为文本存储在那里。removeText 函数通过迭代节点的每个子节点,检查它是否包含文本并在包含文本时将其删除来实现这一点

if (node.childNodes)
{
for (var i=0 ; i < node.childNodes.length ; i++)
{
    var oldTextNode = node.childNodes[i];
    if (oldTextNode.nodeValue != null)
    {
    node.removeChild(oldTextNode);
    }
}
}

从 warning 节点中删除文本子节点后,我们可以向 warning 节点添加一个新的文本子节点,其中包含我们要显示的消息。这是在 appendText 函数中完成的

function appendText(node, text) {
  var newTextNode = document.createTextNode(text);
  node.appendChild(newTextNode);
}

至此,用户已收到有关所选用户名的警告,表明该用户名将不被接受,因为该用户名已被占用。但是,我们不能依赖用户阅读并遵循警告消息中的说明。相反,我们应该禁用表单的提交按钮,使用户难以甚至将错误的用户名发送到我们的服务器端程序。我们可以通过设置提交按钮的 disabled 属性来做到这一点

submit_button.disabled = true;

概括一下——当用户在 usernames 数组中的 username 文本字段中输入值时,我们删除 warning 节点中任何现有的文本子节点。然后,我们向 warning 添加一个新的文本子节点,指示所选的用户名已被占用。最后,我们禁用 HTML 表单中的提交按钮。

当然,我们希望用户最终提交表单,但只能在输入 usernames 数组中没有的用户名之后。这意味着我们必须从 warning 节点中删除文本子节点,然后重新启用提交按钮

removeText(warning);
submit_button.disabled = false;

果然,这种 JavaScript 函数的组合似乎奏效了。不在 usernames 数组中的用户名会删除任何错误消息并重新激活表单,从而允许我们将其提交到服务器端 CGI 程序并注册该站点。但是,在数组中的用户名会生成警告并阻止我们提交表单。它还不是 Ajax,但它比我们纯粹的服务器端解决方案对用户的响应更快。

考虑事项

当然,清单 3 中的程序在几个方面都存在致命缺陷。最大的缺陷是 usernames 数组是在 JavaScript 中硬编码的。毋庸置疑,以这种方式硬编码用户名列表肯定会失败,因为用户列表存储在数据库表中,而我们尚未将数据库与程序连接起来。

我们可以通过从数据库生成 usernames 数组来克服这个问题。换句话说,我们的服务器端程序将动态创建客户端 JavaScript 程序的一部分。因此,而不是我们在清单 3 中看到的

var usernames = ['abc', 'def'];

我们将使用服务器端程序来执行以下操作

my $output = "[";
my $sql = "SELECT username FROM Users";
my $sth = $dbh($sql);
$sth->execute();
while (my ($username) = $sth->fetchrow_array())
{
    $output .= "'$username', ";
}

$output .= "]";

然后我们将 $output 插入到生成的 HTML 文件中,确保用户名的值将具有系统中最新且最完整的用户名列表。

但即使这样也可能在生产应用程序中引起严重的安全问题,因为这意味着您系统中的每个用户名(包括那些密码选择不当的用户名)都将对访问您注册页面的每个人可用,只需查看 HTML 源代码即可。尽管确实每个用户名都有一个密码,并且有人必须猜测与用户名关联的密码才能侵入您的系统,但您真的可以保证每个密码的质量吗?此外,用户名本身可能暗示您系统上用户的数量或类型。简而言之,您真的不希望生产系统为潜在的攻击者列出用户名,即使您可能认为您的系统是安全的。

这里也存在效率问题。随着您的用户列表的增长,usernames 数组的长度也会增长。你能想象为一个拥有 10,000 个用户的站点生成和下载 JavaScript 需要多长时间吗?

解决所有这些问题的方法当然是 Ajax。与其根据 JavaScript 应用程序中的数组检查建议的新用户名,不如让 JavaScript 将建议的用户名提交给服务器,找出它是否已被占用并采取相应的措施——所有这些都无需强制用户切换到不同的 HTML 页面!这就是使 Ajax 应用程序如此引人注目的底层魔力;它们使您停留在同一页面上的时间比传统的 Web 应用程序更长,从而提供更流畅的用户体验。

结论

我们在通往 Ajax 天堂的道路上取得了一些进展。我们现在有一个应用程序——用户注册——旧式的 Web 开发为此提供了一个答案,但对于用户来说感觉很笨拙。我们本月专栏中看到的解决方案效果很好,但要求 JavaScript 包含一个包含系统上所有用户名的 usernames 数组。出于性能和安全原因,这是一个坏主意,我们应该寻找不同的解决方案。下个月,我们将开始研究针对此问题的真正的 Ajax 解决方案,使我们的应用程序看起来和感觉更流畅,同时提高其安全性。

关于 Ajax 和 JavaScript 的书籍

在编写这些专栏时,我发现了一些关于 HTML、JavaScript、Ajax 和相关技术的优秀书籍。

关于该主题的两本最全面的书是 O'Reilly 的 JavaScript: The Definitive Guide(David Flanagan 著)和 Dynamic HTML: The Definitive Reference(Danny Goodman 著)。这两本书之间有相当多的重叠之处,而且它们绝对是参考书而不是教程。也就是说,有兴趣学习客户端编程的经验丰富的 Web 开发人员可能会从这些书中学习到很多东西。一旦您有了经验,您无疑会经常使用这两本书,检查从各种 JavaScript 对象的跨平台兼容性到 JavaScript 如何与 DOM 交互的所有内容。

对于较新的和经验不足的 Web 开发人员来说,最好从对这些技术的更温和的介绍开始。我见过的最好笑的书之一是 O'Reilly 的 Head Rush Ajax(Brett McLaughlin 著)。我对这本书的一个批评之处被吹捧为本书的优点之一——即它以许多不同的方式呈现相同的信息,以确保您会记住它。对于想要更快地掌握要点并且可能会因重复而感到沮丧的经验丰富的 Web 开发人员来说,这本书可能会有点烦人。尽管如此,我认为对于任何刚开始进入 Ajax 世界的人来说,这都是一本值得一读的书。

一本折衷的书,可能会吸引更有经验的 Web 开发人员,同时提供教程和对本文描述的许多 JavaScript 概念的介绍,是 Nicholas Zakas 编写并由 Wrox 出版的 Professional JavaScript for Web Developers。我不喜欢 Zakas 在整本书中使用他自己的(免费提供的)JavaScript 库的方式,但我确实认为这些示例和解释经过精心挑选且有趣,并且它们还有助于阐明 JavaScript 的一些阴暗面。Zakas 为另一本 Wrox 书籍 Professional Ajax(Zakas、Jeremy McPeak 和 Joe Fawcett 合著)做出了贡献,我发现这本书不如 JavaScript 书籍或 O'Reilly 的 Head Rush Ajax 书籍那样集成且令人愉快。

Reuven M. Lerner,一位长期的 Web/数据库顾问,是伊利诺伊州埃文斯顿西北大学学习科学专业的博士候选人。他目前与妻子和三个孩子住在伊利诺伊州斯科基。您可以在 altneuland.lerner.co.il 阅读他的网络日志。

加载 Disqus 评论