跳至主要內容

DOM交互

xmut-lby大约 21 分钟

DOM交互

上一节课我们已经可以对页面的基本属性进行操作了,接下来我们要对页面内容进行进一步操作。

写在前面

本节中你将编写:

  1. 页面版的Hello World。
  2. 用户手动收入的计算器。
  3. 一个抽奖小程序。

页面版Hello World!

任何交互程序的套路都是一致的:接受输入->处理->输出。JS也不例外,我们来看一个简单的例子:

<body>
    <input id="input" type="text" name="username" class="input">
    <button onclick="clickbutton()">提交</button>
    <div id="slogan"></div>
</body>
function clickbutton(){
    let tag = document.getElementsByTagName("input")[0];
    let name = tag.value;
    console.log(tag);
    tag = document.getElementById("slogan");
    console.log(tag);
    tag.innerHTML = "<h1>Hello "+ name+"</h1>";
}

再次强调,请打开vscode,用手敲代码。哪怕原文照抄也要比光看着对你有帮助得多。

当你在输入框里输入的内容并点击“提交”按钮,页面下方会输出对应的内容:

页面版Hello World!
页面版Hello World!

我们分析下这段代码。

事件触发

首先是如何让代码跑起来,别小看这点,这点是很多人无法从C语言转出来的一个重要因素。

如果你以C语言的视角看这段代码,你会有一个疑问:main函数在哪里?我的代码从哪里开始?

对这种问题的回答是:脚本语言通常没有main函数,代码会从你写下的第一条代码开始执行,也就是说代码从这里开始执行:

function clickbutton(){ // 没错,从这行开始执行
    //....
}

这里必须改变你的想法——定义是定义、执行是执行。请注意解释型语言没有编译期,都是读一行(段)执行一行(段)。

解释型语言的含金量

你考虑过为什么(很多现代)解释型语言都能提供一个一问一答式的shell?

很简单,因为解释型语言的执行方式跟一问一答是完全一样的。

所以某种程度上说,执行某个脚本和把要执行的脚本直接一行行复制到解释shell里去并没有什么区别。

所以但从代码看,我们实际上只写了一个函数(注意!我们并没有这个函数,只是写了一个函数)。

那么这个函数是如何被执行的呢?在这里:

<body>
    <input id="input" type="text" name="username" class="input">
    <button onclick="clickbutton()">提交</button>
    <div id="slogan"></div>
</body>


 


如果从html的角度,这行给button设置了一个onclick属性,内容就是调用我们写好的函数。这称为事件响应。

我用MDN上的一个定义来给出事件的定义open in new window

html事件

事件是发生在你正在编程的系统中的事情——当事件发生时,系统产生(或“触发”)某种信号,并提供一种机制,当事件发生时,可以自动采取某种行动(即运行一些代码)。

事件是在浏览器窗口内触发的,并倾向于附加到驻留在其中的特定项目。这可能是一个单一的元素,一组元素,当前标签中加载的 HTML 文档,或整个浏览器窗口。有许多不同类型的事件可以发生。

这种定义很严谨,但不够人话,用通俗的话来说,就是:

html事件人话版

要和页面交互,你需要知道页面发生了什么。这需要页面告诉你,我这里发生了什么?这就叫事件

上面的例子里,这个事件就是说:这个按钮被人点击了。

你可能觉得这里的逻辑很离谱:明明是点击了按钮,又为什么需要页面告诉我按钮被点击了呢? 那是因为这里的我实际上有两个:第一个我是用户。而第二个,需要页面告知按钮被点击的是程序员

程序员需要捕捉用户的操作(输入),并对这种操作做出响应,这才叫交互。那么要如何获取这个输入?

回想一下你们在C语言里是如何操作的:你调用scanf函数,等待用户输入,然后根据用户的输入再做处理。 这种方式很直观,有先有后,符合逻辑和直觉,你们很多人应该已经非常习惯于这种作法了。

这种方式可以称为同步操作。

然而大多数实际的UI环境里,不可能让你等待用户的输入,因为等待就意味着你整个代码都会停在这里。 对于UI界面来说,就是除了这个输入框可以响应你的输入,其他所有控件都无法响应。

为什么我写C语言代码的时候没感觉?

你写C语言代码时考虑过用户体验吗?当用户需要输入用户名和密码时,能想先输入哪一个,就先输入哪一个吗?写错了还能回去改吗?

在实际的UI环境里,你无法要求用户按特定的顺序输入,也就是说你根本没法知道(要求)用户什么时候做什么操作。你是被动的。 你只知道用户会在某个时间做某个操作,你需要对这个操作进行响应。而且你不知道用户会在什么时候做这个操作,所以你没法提前去那等他。

这种交互的方式可以称为异步操作。

现实环境里处理交互都用的异步模式,不会使用同步模式,原因上面已经说了。

那么应该如何处理?这就是上面说的事件响应:你写一个处理响应的逻辑,然后告诉浏览器,当这个用户进行某个操作时,请做这些处理

记得吗?写出来不是自己用的,而是交给别人(浏览器),让别人在合适的时候(用户操作时)调用的东西是什么?回调函数啊。

理解为什么回调函数是整个JS的核心吗?

你疑似有点太啰嗦了

回调模式的重要性再如何强调也不为过,这不仅仅是技术(细节)上的,更是设计,或者更高大上一些,哲学上的。 我们甚至可以说回调模式才是软件工程设计上的基本模式,而不仅仅是仅在UI界面里使用:

例如:

  1. 如果自动驾驶系统用同步模式,急刹车的响应可能就要等车机AI跟你对话完才能执行。
  2. 如果安全监测设备使用同步模式,有害气体泄露的警报可能要等用户操作完才能告警。
  3. 如果WebApp使用同步模式,你就必须等前面的用户访问完才能访问。

更大的问题在于软件工程上,使用回调模式才能很好地进行解耦。

而对于作为初学者的你们,这也是你们从一个只会从main函数开始写代码的菜鸟,升级到能够胜任在复杂软件项目和软件框架虾开发的开发工程师的必经之路。 早日适应这种思维才能让你快点更上一层楼。

那么有没有可以锻炼这种思维的方法呢?

有,就是在开发框架下多写代码,比如用Windows API重写EasyX的课设。

获取输入

现在我们知道,点击这个按钮之后,函数clickbutton就会被调用。我们再看这个函数的内容:

    let tag = document.getElementsByTagName("input")[0];
    let name = tag.value;
    console.log(tag);
    tag = document.getElementById("slogan");
    console.log(tag);
    tag.innerHTML = "<h1>Hello "+ name+"</h1>";
 
 




前两行用来获取输入框,也就是<input id="input" type="text" name="username" class="input">这个控件输入的值。

第一行我们用document.getElementsByTagName得到这个对应的控件,我们在第三行打印了这个对象的值。你可以在终端里看到对应的输出。

console.log函数

这是JS的printf,不过是输出到终端里。

由于JS主要输出目标在页面,这个函数一般只用来进行调试,而且是最常用的调试手段。

你可以看到它的输出:

console.log输出的标记
console.log输出的标记

此时这个tag就指向了页面上的这个input标记。

页面标记在JS里是什么?

好问题!这说明你开始从JS的角度去思考了。你可以自己试试!请注意,上节课有提到,通过typeof函数可以获得某个变量的类型。

点击查看答案 实际上是一个对象(Object)。

实际上如果你用的Firefox的浏览器,会给出更多的内容。

这个tag对应的value属性,就存放着input这个输入框里输入的文本。你一样可以用console.log打印出来看看。

实际上你还有另一种方式来观察这个tag:在终端里运行一下(记得吗,解释型语言):

let tag = document.getElementsByTagName("input")[0];

此时你就可以获得tag了,然后试试修改输入框里的值,之后再在终端里直接打印tag.value看看。

为什么是value属性?

试试,在html里的input代码上加上属性value='xxxxx',你发现了什么?

再在终端里看看tag.nametag.typetag.name,你发现了什么?

实际上对于JS来说,每个标记就是一个对象,标记的属性,也是这个对象的属性。你可以试试。

所以,如果我要实现点击之后切换图片内容,要怎么实现?

输出内容

再看下面输出部分的代码:

function clickbutton(){
    let tag = document.getElementsByTagName("input")[0];
    let name = tag.value;
    console.log(tag);
    tag = document.getElementById("slogan");
    console.log(tag);
    tag.innerHTML = "<h1>Hello "+ name+"</h1>";




 
 
 

我们一样通过函数获取获取要输出内容的标记,也就是

<div id="slogan"></div>

这个块的innerHTML就是块内部的HTML代码,我们直接修改它,就可以把代码插入到对应的块的内部了。

执行前:

<div id="slogan"></div>

执行后:

<div id="slogan"><h1>Hello xxxxx</h1></div>

可以看到,<h1>Hello xxxxx</h1>已经被插入到对应的div中了。

获取标记的方法

在上面的代码里我们可以看到两个函数都可以获取某个标记,这样的函数,JS比较常用的有以下4个:

函数说明用途返回内容
getElementsByTagName用tag名去搜索元素例如input、div对象数组
getElementsByClassName通过class去搜索class="xxx"对象数组
getElementsByName通过input这类控件的name属性搜索name="xxx"对象数组
getElementById通过id搜索id="xx"对象

注意这里的函数名称,前三者都是getElements,带一个s,说明是很多个。 和id不同,拥有相同标记名、class和name的元素可以有不止一个,所以必须返回一个数组。

而id在页面上是唯一的,因此只需要把对象返回即可。从这里就可以看出id这个属性对页面逻辑的作用——可以很大程度上简化逻辑,使编码者无需在一堆相似的标记上搜索。

计算器

这一节我们实现一个可以根据计算四则运算的计算器,我们先给出下面的界面:

html界面

计算器
    <input id="lhs" type="number" class="input">
    <select id="op">
        <option value="add">+</option>
        <option value="sub">-</option>
        <option value="mul">*</option>
        <option value="div">/</option>
    </select>
    <input id="rhs" type="number" class="input">
    =
    <input id="res" class="input" disabled>
    <button onclick="clickbutton()">计算</button>
 








 

这里有两点可以稍微注意下,第一是我们设置typenumber,限制只允许输入数字。

第二是结果控件我们设置了disable,这个属性会让输入框变得不可用(输入),这样用户就无法修改结果了。

美化一下

你可以尝试用css美化一下,使界面更好看。

逻辑代码

现在我们来考虑代码要如何写,对于初学者来说,最忌讳一次就要把所有代码写完。 一旦完不成就会从一个极端掉到另一个极端,直接躺平。

一点点往上加逻辑

不要一口吃个胖子,一点点把你的逻辑堆上去。只要你写的代码有反馈,就是胜利。

第一步:获得输入

本例里,我们可以先做到把所有需要用到的参数都get到就可以了。

function clickbutton(){
    let lhs = document.getElementById("lhs").value;
    let rhs = document.getElementById("rhs").value;
    let op = document.getElementById("op").value;
    console.log(lhs);
    console.log(rhs);
    console.log(op);
}

然后,测试!测试!测试!然后通过终端的输出来判断你写的代码是否正确。

保证你写的代码逻辑正确,再开始下一步。

注意,简单测试一下,比如测一个1+1,不能算全面的测试,至少要把不同的操作符都测试过一遍才算比较完整的测试。

这么小心的吗?

一步都没跑过的人没资格嘲笑10分钟才跑1km的人。

只要迈出步伐你就有收获。

实际上这段代码依然有很多地方可能出现问题,包括但不限于:

  1. 拼写错误,比如把document写成documnet,把getElementById写成getElementByID
  2. 重复命名,写两个let lhs
  3. 逻辑错误,比如直接复制代码时,没有修改里面的参数,三个变量都用了:getElementById("lhs")

在解决这些问题的过程中,你就会收获成长——能力的提高。

而如果你抱着一开始就写完这个功能的想法,你很可能在一开始就放弃了。保证新增代码始终处于自己能处理的范围以内,是良好的编码习惯。

第二步:计算结果

保证获取输入的代码能正确运行后,我们可以继续下一步,我们可以先得到结果,在终端里输出一下,看看结果是否正确。

function clickbutton(){
    let lhs = document.getElementById("lhs").value;
    let rhs = document.getElementById("rhs").value;
    let op = document.getElementById("op").value;
    console.log(lhs);
    console.log(rhs);
    console.log(op);
    let res = 0;
    switch(op){
        case "add": res = lhs+rhs;
        case "sub": res = lhs-rhs;
        case "mul": res = lhs*rhs;
        case "div": res = lhs/rhs;
    }
    console.log(res);
}








 
 
 
 
 
 
 

先别急下一步

上面的代码是有bug的,你能看出是什么bug吗?

所以说瞪眼debug不可取,你随便选几个数字试一试就知道,这个代码只能执行除法。

那么要如何定位这个bug,这里可以用浏览器的调试手段(是的!浏览器内置了代码调试器!),我们会在以后详细讲定位和调试。

不过有经验的童鞋一眼就能看出问题在哪里了,我们漏写了break,这是swtich的常见错误。

实践和经验的重要性

定位错误是非常非常依赖于经验的,而经验又来自大量的代码和错误定位训练。

什么叫经验——你犯过的错误并总结出来,记在你脑子里的,才是你的经验。

所以如果你多写代码,就是一个正反馈:写代码--遇到问题--解决问题--获得经验--更快地写代码和解决问题--更多的经验。

反之如果你不写代码,就是一个负反馈:不写代码--无法获得经验--越来越多无法解决的问题。

我们修改下代码:

function clickbutton(){
    let lhs = document.getElementById("lhs").value;
    let rhs = document.getElementById("rhs").value;
    let op = document.getElementById("op").value;
    console.log(lhs);
    console.log(rhs);
    console.log(op);
    let res = 0;
    switch(op){
        case "add": res = lhs+rhs;break
        case "sub": res = lhs-rhs;break
        case "mul": res = lhs*rhs;break
        case "div": res = lhs/rhs;break
    }
    console.log(res);
}









 
 
 
 



你可以测试一下,逻辑应该是OK了——除了加法。如果你用123加上456,你会得到123456而不是379

同样的,如果你很有经验,就很容易看出这里的问题:这里的加法是字符串加法(拼接),而不是数字的相加。

这意味着华生你发现了一个盲点:即便是numberinput,它的value也是字符串的

思考一下

如何证实你的想法?提示一下,还记得有什么方式可以看变量的类型吗?

请注意所谓定位错误,是包括验证的,否则就不叫定位,而应该叫猜想了。

因此我们还需要将输入转为数字:

function clickbutton(){
    let lhs = parseInt(document.getElementById("lhs").value);
    let rhs = parseInt(document.getElementById("rhs").value);
    let op = document.getElementById("op").value;
    console.log(lhs);
    console.log(rhs);
    console.log(op);
    let res = 0;
    switch(op){
        case "add": res = lhs+rhs;break
        case "sub": res = lhs-rhs;break
        case "mul": res = lhs*rhs;break
        case "div": res = lhs/rhs;break
    }
    console.log(res);
}

 
 













再测试一下,就对了。

你想过吗?

有没有想过,为什么只有加法出问题?

好问题!原因在于JS的隐式类型转换:它是真的很努力地让算式能给出结果!

这是个非常复杂的问题(反正我搞不懂),放一张图:

这就是JS!
这就是JS!

第三步:输出结果

最后我们就可以将结果输出了,顺便移除用来调试的代码:

function clickbutton(){
    let lhs = document.getElementById("lhs").value;
    let rhs = document.getElementById("rhs").value;
    let op = document.getElementById("op").value;
    let res = 0;
    switch(op){
        case "add": res = lhs+rhs;break
        case "sub": res = lhs-rhs;break
        case "mul": res = lhs*rhs;break
        case "div": res = lhs/rhs;break
    }
    let res_input = document.getElementById("res");
    res_input.value = res;
}











 
 

第四部:优化一下

这个页面的每次操作都需要我们点击按钮,我们可以优化一下操作,当用户输入发生变化时,就自动计算结果并输出。

对此我们需要处理两个事件:当输入框的内容发生改变时,会触发oninput,当选择框内容更改时,会触发onchange

我这里给出代码,但我强烈建议你先不要看,写完之后再过来对比下:

先不要看,自己试试
    <input id="lhs" type="number" class="input" oninput="dataChanged()">
    <select id="op" onchange="dataChanged()">
        <option value="add">+</option>
        <option value="sub">-</option>
        <option value="mul">*</option>
        <option value="div">/</option>
    </select>
    <input id="rhs" type="number" class="input" oninput="dataChanged()">
    =
    <input id="res" name="number" class="input" disabled>
function dataChanged(){
    let lhs = document.getElementById("lhs").value;
    let rhs = document.getElementById("rhs").value;
    if( lhs == "" || rhs == "" ){
        document.getElementById("res").value = "";
        return;
    }
    lhs = parseInt(lhs);
    rhs = parseInt(rhs);
    let op = document.getElementById("op").value;
    let res = "";
    if( lhs != "" && rhs != "" ){
        res = 0;
        switch(op){
            case "add": res = lhs+rhs;break
            case "sub": res = lhs-rhs;break
            case "mul": res = lhs*rhs;break
            case "div": res = lhs/rhs;break
        }
    }
    document.getElementById("res").value = res;
}

抽签

最后我们实现一个简单的抽签程序。先给出对应的界面:

界面
人数:<input id="total" type="number"> <br><br> 
<button onclick="nextOne(this)">下一个</button>
<table>
    <thead>
        <tr><th>轮次</th><th>签位</th></tr>
    </thead>
    <tbody id="results">
    </tbody>
</table>
table{
    border-collapse:collapse;
    border-top:        2px black solid;
    border-bottom:     2px black solid;
}
th{
    background-color: rgb(81,130,187);
    color: #fff;
    padding:    0 15px 0 15px;
    border-bottom:     thin black solid;
    text-transform:     capitalize;
    text-align: center;
    width: 100px;
}
tr:nth-child(2n){
    background-color:rgb(211,223,237);
}
td{
    text-align: center;
    padding:    0 15px 0 15px;
}
function nextOne(e){
}

我们先思考下具体的业务逻辑:显然我们每轮都要随机生成一个数字,这个数字需要被存起来,所以我们需要一个数组来存放这些数据。

每一轮,我们都随机生成一个不在数组中的数字,并放入数组中。当数组的元素数目达到设定的总数则停止。

为了防止用户在生成数据的过程中修改总数,我们可以在首轮就将输入框置为disabled。而当抽签结束,也可以将按钮置为disable

第一步:业务逻辑

我们先实现每轮随机抽签的业务逻辑:

let results = [];
let total = 0;
function nextOne(e){
    total = parseInt(document.getElementById("total").value);
    if( total == results.length){    // 如果已抽完则直接退出
        return;
    }
    let n = Math.floor(Math.random()*total)+1;
    while( -1 != results.indexOf(n) ){  // 如果已存在,则需要继续生成数字
        n = Math.floor(Math.random()*total)+1;
    }
    results.push(n); // 将数字放入数组中
    console.log(results);
}




 
 
 







我们第一步的目标是实现具体的业务逻辑,暂时先不要对页面内容进行交互。 这是非常有效的作法,可以先聚焦于核心的业务逻辑,避免处理琐碎的逻辑。

注意上面标出的5-7行的处理,这个处理并不属于核心逻辑(而是错误处理),但这里是必须的。 因为如果不做这个处理,当抽满之后,下面的循环会陷入死循环,页面会失去响应影响我们测试。

失去响应

页面失去响应一个比较大的可能就是死循环。

此时你可能需要关闭这个页面并重新打开。

测试一下,在框里输入一个数字,然后点击按钮进行抽签,每一轮都应该输出数组的内容。

第二步:显示出来

这步就比较简单了,我们只需要根据results的内容将其逐条显示在页面上。

我们已经在页面上写好了一个tbody,此时每一条记录,就是表格的一行,其内容是类似于:

<tr><td>{轮次}</td><td>{签位}</td></tr>

的内容。

我们只要将这么多行的数据依次放入<tbody id="results"> </tbody>innerHTML里就可以了。

显然可以用for循环来生成每个条目,但这里有一点要注意:不能多次向innerHTML写入内容,必须一次赋值!

// 错误的作法!
e.innerHTML += "<tr>";
e.innerHTML += "<td>";
e.innerHTML += "</td>";
e.innerHTML += "</tr>";

正确的作法是用变量记录这个内容,然后一次性写入:

let s = "";
s += "<tr>";
s += "<td>";
s += "</td>";
s += "</tr>";
e.innerHTML = s;

至于为什么,你可以自己试试看就明白了。

因此这个代码我们可以这样写:

function showResults(){
    let s = "";
    for( let i=0; i<results.length; ++i ){
        s += "<tr>";
        s += "<td>"+(i+1).toString()+"</td>";
        s += "<td>"+results[i].toString()+"</td>";
        s += "</tr>"
    }
    document.getElementById("results").innerHTML = s;
}

这里results是全局变量,所以可以直接访问。我们再为事件函数加上显示的代码:

function nextOne(e){
    total = parseInt(document.getElementById("total").value);
    if( total == results.length){
        return;
    }
    let n = Math.floor(Math.random()*total)+1;
    while( -1 != results.indexOf(n) ){
        n = Math.floor(Math.random()*total)+1;
    }
    results.push(n);
    showResults();
    console.log(results);
}










 


逻辑和展示分离

你应该意识到这个代码不够“高效”,例如每一次我们都需要跑循环把每条记录贴上去。 有没有什么办法我们可以直接在已有的逻辑上直接写上去呢?直接写在nextOne函数里不好吗?

回答是:不好

如果你仔细看这个代码,你会发现逻辑的代码(nextOne这个函数)和显示的代码(showResults)是独立的两个函数。 两者之间唯一的关联是通过全局变量results。对于这种情况我们说两个函数之间只有最低的数据耦合

如果你无法理解这种处理的优点,说明你很少协同工作:两个函数可以被分给不同的人写,他们的开发是基本独立的,这就是低耦合的好处。

拿你们比较熟悉的游戏的例子来举例,有些程序员可能更熟悉要怎么做出打击感,而有些程序员可能更熟悉如何把魔法效果做得酷炫。

合理的作法是让这两类程序员分别负责他们熟悉的部分,而不是要求所有程序员都既要熟悉打击感的制作,也要熟悉如何把魔法效果做好。

这种把业务逻辑(模型)和数据展示(视图)分离的作法,是大多数框架试图达到的设计目的。

第三步:加上防呆设计

最后我们可以把其他琐碎的UI逻辑加上,例如点击之后把输入框变成无效的,防止用户没有输入数字或者数字太小,以及抽满之后停止按钮响应:

这里就不一一解释了,请大家自行分析:

function nextOne(e){
    total = parseInt(document.getElementById("total").value);
    if( isNaN(total) || total <= 1 ){
        alert("请输入数字,且数字必须大于1");
        return;
    }
    document.getElementById("total").disabled = true;
    let n = Math.floor(Math.random()*total)+1;
    while( -1 != results.indexOf(n) ){
        n = Math.floor(Math.random()*total)+1;
    }
    results.push(n);
    showResults();
    if( total == results.length){
        e.disabled = true;
        return;
    }
}

this是什么?

你会注意到函数nextOne有一个参数,而这个参数是在回调时填入的,值是this

<button onclick="nextOne(this)">下一个</button>

html元素里,编写事件响应时,都可以使用this这个值,这个值指代的就是触发事件的元素,在本例中就是button这个元素。

所以我们在js代码里,可以直接使用这个元素:

function nextOne(e){  // 此时e就指代触发这个消息的按钮
    //....
    //....
    if( total == results.length){
        e.disabled = true; // 直接使用`e`而不需要用getElement...来获取元素。
        return;
    }
}