JavaScript基础 - 语法与DOM

JavaScript 基础 - 语法与 DOM

HTML 搭骨架, CSS 穿衣服, 到这里为止页面还是死的: 点按钮没反应, 数据也不会变。JavaScript (下面都简称 JS) 就是让页面活过来的编程语言。这篇是整条前端路线里最重要的一篇, 因为后面的 Vue 说到底就是换了种方式写 JS, 这里的每个语法点 (尤其 ES6 那批) 到时候都会高频出现。

我是带着 Java 基础来学的, 所以这篇笔记的写法是: 和 Java 一样的东西一笔带过, 只记不一样的和 Java 里没有的。

JS 是什么, 在哪里跑

JS 代码由浏览器里的引擎直接解释执行, 不用编译, 没有 main 方法, 从上到下顺着跑。把它和页面连起来有三种写法:

代码块HTML · 10 行收起展开
<!-- 1. 外部文件, 推荐: 结构和逻辑分离 -->
<script src="script.js" defer></script>

<!-- 2. 直接写在 HTML 里, 小例子用 -->
<script>
  console.log('Hello');   // console.log 就是前端的 System.out.println
</script>

<!-- 3. 写在标签属性里, 别这么干, 难维护 -->
<button onclick="alert('点击了')">点击</button>

defer 值得解释一下: script 标签默认是”加载到就立刻执行”, 如果它写在 <head> 里, 执行时 body 还没解析, 找页面元素会找不到。
defer 让脚本等 HTML 全部解析完再跑, 所以加了它就能放心把 script 放头部。
不加 defer 的老写法是把 script 放 </body> 前面, 效果类似。

变量: let 和 const

代码块JAVASCRIPT · 3 行收起展开
let count = 0;        // 会变的量
const name = '张三';   // 不会变的量, 类似 Java 的 final
var old = 1;          // 老写法, 有作用域坑, 新代码别用

我的习惯 (也是主流习惯): 默认 const, 确实要重新赋值才用 let。注意 const 锁的是”变量指向谁”, 不是”内容不能改”: const arr = [] 之后照样可以 arr.push(1), 这一点和 Java 的 final 引用一模一样。

数据类型: 动态类型的世界

JS 和 Java 最大的观感差异: 变量不声明类型, 一个变量一会儿装字符串一会儿装数字都合法, 类型错误要到运行时才炸。写惯静态类型的人前期会很不适应, 这也是后面 TypeScript 存在的理由 (工具链篇再说)。

代码块JAVASCRIPT · 7 行收起展开
let str = 'hello';           // 字符串, 单双引号都行
let num = 42;                // 数字, 不分 int/double, 就一种 number
let ok = true;               // 布尔
let nothing = null;          // 主动表示"空"
let notDefined;              // 声明了没赋值, 就是 undefined

typeof num;                  // 'number', 运行时查类型

nullundefined 的区别我这么记: undefined 是”根本没给过值”, null 是”我特意放了个空进去”。

比较运算必须记一个坑: <mark> 会偷偷做类型转换, 1 </mark> '1' 竟然是 true。所以永远用 <mark>= (值和类型都相等), 就像 Java 里比字符串永远用 equals 不用 一样, 属于肌肉记忆级别的规矩。

另外两个 Java 没有但很好用的运算符:

代码块JAVASCRIPT · 2 行收起展开
let value = input ?? '默认值';   // 空值合并: input 是 null/undefined 时才用默认值
let email = user?.email;        // 可选链: user 是 null 时不炸, 整体返回 undefined

可选链约等于把 Java 里 if (user != null) user.getEmail() 那串判空压缩成一个问号, 香。

模板字符串

反引号包裹, 里面 ${} 直接插值, 还能换行:

代码块JAVASCRIPT · 1 行收起展开
let msg = `你好, ${name}, 明年${age + 1}岁`;

有了它就再也不用 '你好, ' + name + ... 这种拼接了。Vue 模板里的 {{ }} 插值思路同源, 先在这里习惯。

数组: 前端的 Stream API

代码块JAVASCRIPT · 5 行收起展开
let arr = [1, 2, 3, 4, 5];

arr.push(6);       // 尾部加
arr.pop();         // 尾部删
arr.length;        // 长度是属性不是方法, 没有括号

真正的重头戏是这批函数式方法, 用起来和 Java 的 Stream 几乎一个手感, 只是不用先 .stream() 也不用最后 .collect():

代码块JAVASCRIPT · 7 行收起展开
arr.map(x => x * 2);              // 映射: [2, 4, 6, 8, 10]
arr.filter(x => x > 3);           // 过滤: [4, 5]
arr.find(x => x > 3);             // 找第一个: 4
arr.some(x => x > 3);             // 有没有满足的: true
arr.reduce((sum, x) => sum + x, 0); // 归约: 15, 第二个参数是初始值
arr.includes(3);                  // 包含: true
arr.join(',');                    // 拼成字符串: '1,2,3,4,5'

这批方法必须练熟, 熟到条件反射。Vue 里渲染列表、筛选数据、算合计, 全是它们。

对象: 长得像 Map 的东西

JS 的对象不用先写类, 直接字面量造:

代码块JAVASCRIPT · 15 行收起展开
let user = {
  name: '张三',
  age: 25,
  greet() {
    console.log(`你好, 我是${this.name}`);
  }
};

user.name;          // 点号访问
user['age'];        // 也可以像 Map 一样用键访问
user.email = 'x@y.com';  // 随时加属性, 没有类定义拦着你
delete user.age;         // 随时删

Object.keys(user);       // 所有键
Object.entries(user);    // 键值对数组, 类似 Java 的 entrySet

对 Java 人的理解捷径: 它像一个键固定写法更宽松的 HashMap, 又能挂方法。没有类也能干活这件事, 前期不习惯, 后来真香。

解构和展开: Vue 代码里的常客

代码块JAVASCRIPT · 9 行收起展开
// 解构: 把对象/数组按名字拆出来
let { name, age } = user;            // 等价于两行赋值
let { name: userName } = user;       // 拆出来顺便改名
let [first, second] = [1, 2, 3];     // 数组按位置拆

// 展开: 把对象/数组摊开
let copy = { ...user };              // 浅拷贝一个对象
let merged = { ...user, age: 26 };   // 拷贝并覆盖某字段
let all = [...arr1, ...arr2];        // 合并数组

这两个语法在 Vue 项目里出现频率极高 (组件接参数、改状态、传 props 全用), 现在就要看熟。

函数: 从 function 到箭头函数

代码块JAVASCRIPT · 14 行收起展开
// 经典写法
function greet(name) {
  return `你好, ${name}`;
}

// 箭头函数, 等价于 Java 的 lambda
const greet2 = (name) => `你好, ${name}`;    // 单行时连 return 都省了
const add = (a, b) => a + b;

// 默认参数和可变参数
function log(msg = '空') { }
function sum(...nums) {              // 相当于 Java 的可变参数 int... nums
  return nums.reduce((t, n) => t + n, 0);
}

箭头函数基本就是 lambda, 上面数组方法里已经在大量用了。它和 function 有个深层区别: 箭头函数没有自己的 this。现在不用深究, 记一个结论: 回调、数组方法里无脑用箭头函数, 就不会掉 this 的坑。

控制流 (if/else、switch、for、while、三元) 和 Java 几乎完全一样, 不抄了, 只记两个 Java 没有的:

代码块JAVASCRIPT · 2 行收起展开
for (let item of arr) { }    // for...of 遍历数组的值, 相当于 Java 的 for-each
for (let key in obj) { }     // for...in 遍历对象的键, 注意是键不是值

DOM: JS 操作页面的手

浏览器把 HTML 解析成一棵对象树, 叫 DOM (Document Object Model)。HTML 里每个标签都对应树上一个节点对象, JS 拿到节点就能改它, 页面立刻跟着变。这就是”JS 让页面动起来”的全部原理。

选元素

代码块JAVASCRIPT · 2 行收起展开
let box = document.querySelector('.box');       // 选第一个匹配的, 参数就是 CSS 选择器
let items = document.querySelectorAll('li');    // 选所有匹配的

querySelector 系列是现代标准答案, 参数直接复用 CSS 篇学的选择器语法 (#id.classdiv > p 都能用), 老 API (getElementById 那批) 见到认识就行。

改内容、样式、属性

代码块JAVASCRIPT · 9 行收起展开
box.textContent = '新文本';           // 改文本 (安全, 内容只当文字)
box.innerHTML = '<b>加粗</b>';        // 改 HTML (会解析标签, 别塞用户输入, 有注入风险)

box.style.color = 'red';             // 单改一条内联样式
box.classList.add('active');         // 更推荐: 加减 class, 样式写在 CSS 里
box.classList.toggle('open');        // 有则删无则加

img.src = 'new.jpg';                 // 常用属性直接点出来改
input.value;                         // 表单输入框的当前值, 读写都靠它

增删节点

代码块JAVASCRIPT · 4 行收起展开
let li = document.createElement('li');   // 造节点
li.textContent = '新的一项';
list.append(li);                         // 挂到父节点尾部
li.remove();                             // 删掉自己

事件: 给页面装监听器

代码块JAVASCRIPT · 5 行收起展开
let btn = document.querySelector('#saveBtn');

btn.addEventListener('click', (event) => {
  console.log('点击了', event.target);   // event.target 是触发事件的元素
});

模式上就是 Java GUI 那套监听器/回调: 注册一个函数, 事件发生时浏览器来调它。常用事件名: clickinput (每敲一个字符触发)、change (值改完失焦触发)、submit (表单提交)、keydown

表单提交有个必知的坑: 浏览器默认会刷新页面提交表单, 要自己接管就得拦下来:

代码块JAVASCRIPT · 4 行收起展开
form.addEventListener('submit', (e) => {
  e.preventDefault();     // 阻止默认的刷新式提交
  // 这里自己处理数据
});

定时器

代码块JAVASCRIPT · 5 行收起展开
let id = setTimeout(() => console.log('2秒后执行一次'), 2000);
clearTimeout(id);                                  // 取消

let id2 = setInterval(() => console.log('每秒一次'), 1000);
clearInterval(id2);                                // 不清理会一直跑, 记得清

localStorage: 浏览器里的小仓库

代码块JAVASCRIPT · 7 行收起展开
localStorage.setItem('name', '张三');    // 存, 永久有效 (除非手动清)
localStorage.getItem('name');           // 取
localStorage.removeItem('name');        // 删

// 只能存字符串, 对象要先序列化, 和 Redis 存 JSON 一个思路
localStorage.setItem('user', JSON.stringify(user));
let saved = JSON.parse(localStorage.getItem('user'));

还有个 sessionStorage, API 一模一样, 区别是关掉标签页就清空。

异步: JS 最特别的部分

JS 在页面里只有一根线程, 如果发个网络请求要傻等三秒, 这三秒页面就完全卡死, 点啥都没反应。所以 JS 的规矩是: 耗时操作一律异步, 发起之后立刻继续往下走, 结果好了再回来通知你。

“通知你”的写法演进了三代, 三代都会在真实代码里遇到:

第一代, 回调函数: 把”完成后干嘛”作为函数传进去。嵌套两三层就成了金字塔, 人称回调地狱。

第二代, Promise: 把”未来才有的结果”包成对象, 用链式 .then 接:

代码块JAVASCRIPT · 4 行收起展开
fetch('/api/users')
  .then(response => response.json())   // 上一步的结果传给下一步
  .then(data => console.log(data))
  .catch(err => console.log(err));     // 链上任何一环出错都落到这

第三代, async/await: 让异步代码长得像同步, 可读性最好, 现在的默认写法:

代码块JAVASCRIPT · 9 行收起展开
async function getUsers() {           // async 标记这个函数里有异步操作
  try {
    let response = await fetch('/api/users');   // await: 等结果, 但不卡死页面
    let data = await response.json();
    console.log(data);
  } catch (err) {                     // 熟悉的 try/catch 又回来了
    console.log(err);
  }
}

await 只能出现在 async 函数里。理解要点: 它”等”的时候线程没有被阻塞, 浏览器照常响应用户, 结果到了再回到这行继续。

fetch: 前端的 HTTP 客户端

fetch 是浏览器内置的请求 API, 角色相当于 Java 里的 HttpClient/RestTemplate。GET 见上面, POST 长这样:

代码块JAVASCRIPT · 5 行收起展开
let response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },  // 告诉后端我发的是 JSON
  body: JSON.stringify({ name: '张三', age: 25 })    // 请求体必须手动序列化
});

写 Spring 接口时接的 @RequestBody, 就是从这儿发出去的。有个隐蔽坑先记着: fetch 对 404/500 这类 HTTP 错误码并抛异常, 要自己查 response.ok, 这个坑到 Vue 请求层封装时会正式处理。

串起来: 数据驱动的萌芽

三个小例子把语法、DOM、事件连起来。注意它们共同的形状: 数据变了, 手动去更新页面。

// 计数器: 状态是 count, 每次点击后手动同步到页面
let count = 0;
btn.addEventListener('click', () => {
  count++;
  display.textContent = count;    // 这行"手动同步"就是日后 Vue 替我们干的活
});
// 列表渲染: 数据数组循环造 DOM
let users = [{ name: '张三', age: 25 }, { name: '李四', age: 30 }];
users.forEach(u => {
  let li = document.createElement('li');
  li.textContent = `${u.name}, ${u.age}岁`;
  list.append(li);
});
// 表单校验: 拦截提交, 检查通过才放行
form.addEventListener('submit', (e) => {
  e.preventDefault();
  if (!emailInput.value.includes('@')) {
    alert('邮箱格式错误');
    return;
  }
  console.log('提交', emailInput.value);
});

写多了就会烦: 每次改数据都要惦记着改 DOM, 改漏一处页面就和数据对不上。这个痛点先攒着, 完整示例篇会攒到顶点, 然后 Vue 篇一次性解决。

延伸阅读