盐汽水的海洋

JavaScript / TypeScript

avatar

盐汽水

目标:用 2–3 小时从零到能写一个可运行的小功能,并掌握最常用语法、API 与工程化最小配置。
结构:概念 → 示例 → 练习 → 自测


一、概念(80/20 清单)

1) 语言与运行时(JS 必修)

  • 变量与作用域(let/const/var:默认用 const;用 let 表示会变;避免 var 以防函数作用域与提升带来的坑。

  • 值与类型:基本类型(string/number/boolean/null/undefined/symbol/bigint)与引用类型(object/function);用 typeofArray.isArray 做快速判断。

  • == 与 ===:总用 ===!==,避免隐式类型转换。

  • 对象与原型:对象通过原型链共享方法;Object.create/class 都依赖原型机制。

  • 函数与箭头函数:箭头函数没有自己的 this/arguments;用来保持外层 this

  • 闭包:函数“记住”其定义时的词法环境;常用于工厂函数与私有状态。

  • 模块(ESM):用 import/export 组织代码,浏览器与 Node 均支持 ESM。

  • 异步(Promise/async/await)await 语法糖基于 Promise;微任务(Promise)优先于宏任务(setTimeout)。

  • 错误处理:同步用 try/catch;异步 await 外层也要 try/catch 或在链末尾 .catch

  • 数组常用方法map/filter/reduce/some/every/find 覆盖 80% 处理场景。

  • 对象操作:解构与展开({...obj})、Object.assign 做浅拷贝与合并。

  • JSONJSON.parse/JSON.stringify 是前后端传输与持久化的基石。

  • DOM 与事件querySelector/addEventListener;事件委托用 e.target.closest()

  • 网络请求(Fetch)fetch(url).then(r=>r.json());注意非 2xx 不会抛错需手动判断。

  • 存储localStorage/sessionStorage 保存小量数据;序列化与容量限制要注意。

  • URL 与查询参数URLURLSearchParams 用于拼接与解析链接。

  • 定时器setTimeout/clearTimeoutsetInterval/clearInterval;防抖/节流均基于它们。

  • 集合类型Map/Set/WeakMap/WeakSet 适合键值映射与去重。

  • 国际化(Intl)Intl.DateTimeFormat/NumberFormat 本地化展示数字与日期。

参考:MDN Web Docs(语言、Web API),ECMAScript 规范(语言语义)。

2) 类型系统(TS 必修)

  • 类型注解与类型推断:TS 会自动推断;只在边界(入参/返回值/公共接口)显式标注。

  • 接口与类型别名(interface/type:两者都可描述对象形状;需要交叉/联合/映射类型时更常用 type

  • 联合与交叉(| / &:联合表示“可能是其中之一”,交叉表示“同时满足多个类型”。

  • 字面量与枚举:用字面量联合 + as const 代替 enum,便于 tree-shaking。

  • 泛型(<T>:把“类型参数化”以复用;为返回值与入参建立对应关系。

  • 类型守卫typeofininstanceof、自定义谓词(arg is X)用于在分支中缩窄类型。

  • anyunknownany 放弃检查仅作逃生阀;unknown 需先缩窄后使用更安全。

  • nevervoidnever 表示不可能到达的分支;void 表示无返回值。

  • 可选链与空值合并(?. / ??:安全访问与为 null/undefined 提供默认值。

  • 类型断言as 是“我保证”;应作为最后手段。

参考:TypeScript Handbook、tsconfig Reference。

3) 必备 API / 配置(≤2 句/条)

  • DOM 选择/遍历document.querySelector(All)element.closest

  • 事件addEventListener(type, handler, { capture: false });解绑用 removeEventListener

  • Fetchfetch(url, { method, headers, body });先 if(!res.ok) throw …await res.json()

  • 存储localStorage.getItem/setItem/removeItem/clear;注意都以字符串形式存储。

  • URLnew URL(url, base)new URLSearchParams(obj)

  • 计时performance.now() 高精度计时。

  • 控制台调试console.log/table/group/timedebugger

  • tsconfig 关键项"strict": true"target": "ES2020""module": "ES2020""lib": ["ES2020","DOM"]"sourceMap": true"outDir": "dist"

  • 包管理与脚本npm init -ynpx 临时执行、npm run build 定义编译脚本。

4) 典型应用场景与设计取舍

  • 直接 DOM vs 框架:小功能/嵌入式页面优先原生 DOM;复杂状态/组件化考虑框架。

  • Fetch vs 第三方库:浏览器端优先原生 Fetch;需要拦截器/重试/超时可加轻量封装。

  • 本地存储 vs 服务端:配置/小缓存用 localStorage;可变共享数据走接口并考虑缓存失效。

  • any 速度 vs 类型安全:开发快可以局部放宽,但在模块边界恢复严格类型。

  • 事件直绑 vs 委托:大量子元素动态增删时用事件委托(绑定在父元素上)。

  • ESM 原生加载 vs 打包:学习与演示用原生 ESM;上线常配合打包/压缩以兼容与性能。

5) 内部规范(建议)

  • 默认 strict + 禁止 any(必要时集中隔离);导出 API 必须有类型。

  • 代码风格const 优先、不可变更新({...obj}/arr.map)、统一使用 ===

  • 错误与返回约定:有可能失败的函数返回 Result<T> = {ok:true,data}|{ok:false,error} 或抛出明确错误。

    • *

二、最小可运行示例(从零开始)

技术点覆盖:模块、DOM、事件委托、localStorageasync/await、基本类型与类型缩窄。
效果:一个极简 Todo 应用(可添加/勾选/删除,自动持久化)。

0) 环境准备

  • 安装 Node.js ≥ 18 与一款现代浏览器(Chrome/Edge/Firefox/Safari)。

  • 随机本地静态服务器任选其一:npx http-server / npx serve / python3 -m http.server(后续给出命令)。

1) 初始化项目

mkdir mini-ts-todo && cd mini-ts-todo
npm init -y
npm i -D typescript
npx tsc --init

2) 替换 tsconfig.json(最小可用严格配置)

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ES2020",
    "lib": ["ES2020", "DOM"],
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true,
    "noEmitOnError": true
  },
  "include": ["src"]
}

3) 新建页面 index.html

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>TS 最小示例:Todo</title>
  <style>
    html,body{font-family:system-ui,-apple-system,Segoe UI,Roboto,'Noto Sans SC',sans-serif;margin:0}
    .container{max-width:720px;margin:40px auto;padding:0 16px}
    h1{font-size:1.2rem}
    form{display:flex;gap:8px;margin:12px 0}
    input[type="text"]{flex:1;padding:8px;border:1px solid #ccc;border-radius:6px}
    button{padding:8px 12px;border-radius:6px;border:1px solid #ccc;background:#f7f7f7;cursor:pointer}
    ul{list-style:none;padding:0}
    li{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:8px 0;border-bottom:1px solid #eee}
    label{display:flex;align-items:center;gap:8px;flex:1}
    .delete{background:transparent;border:none;font-size:1rem}
  </style>
</head>
<body>
  <div class="container">
    <h1>Todo(TypeScript + DOM + 本地存储)</h1>
    <form id="todo-form" autocomplete="off">
      <input id="todo-input" type="text" placeholder="输入任务并回车…" />
      <button type="submit">添加</button>
    </form>
    <p id="stats" aria-live="polite"></p>
    <ul id="todo-list"></ul>
  </div>
  <!-- 使用编译后的 ESM 输出 -->
  <script type="module" src="./dist/main.js"></script>
</body>
</html>

4) 新建入口 src/main.ts(含详细注释)

// --- 类型定义:每条 todo 的形状(TypeScript 核心:为边界定义类型) ---
type Todo = {
  id: string;
  title: string;
  done: boolean;
  createdAt: number;
};

// 生成稳定的 id(优先用 Web Crypto;不支持时回退到时间戳+随机)
const newId = () =>
  (globalThis.crypto && "randomUUID" in globalThis.crypto)
    ? (globalThis.crypto as any).randomUUID()
    : Date.now().toString(36) + Math.random().toString(36).slice(2);

// --- DOM 查询助手:失败即抛错,避免后续空指针 ---
const qs = <T extends Element>(sel: string, root: Document | Element = document) => {
  const el = root.querySelector(sel);
  if (!el) throw new Error(`Not found: ${sel}`);
  return el as T;
};
const $list = qs<HTMLUListElement>('#todo-list');
const $form = qs<HTMLFormElement>('#todo-form');
const $input = qs<HTMLInputElement>('#todo-input');
const $stats = qs<HTMLParagraphElement>('#stats');

// --- 本地存储 ---
function load(): Todo[] {
  try {
    const raw = localStorage.getItem('todos');
    if (!raw) return [];
    const data: unknown = JSON.parse(raw);
    if (!Array.isArray(data)) return [];
    // 轻量“校验/清洗”,将未知转为我们需要的形状
    return (data as any[]).map(x => ({
      id: String(x?.id ?? newId()),
      title: String(x?.title ?? ''),
      done: Boolean(x?.done),
      createdAt: Number(x?.createdAt ?? Date.now())
    })).filter(t => t.title !== '');
  } catch (e) {
    console.warn('Failed to read todos:', e);
    return [];
  }
}
function save(todos: Todo[]) {
  localStorage.setItem('todos', JSON.stringify(todos));
}

// --- 应用状态(单一真相源) ---
let todos: Todo[] = load();
render();

// --- 事件:提交表单添加 todo(阻止默认提交刷新) ---
$form.addEventListener('submit', ev => {
  ev.preventDefault();
  const title = $input.value.trim();
  if (!title) return;
  todos = [{ id: newId(), title, done: false, createdAt: Date.now() }, ...todos];
  save(todos);
  $input.value = '';
  render();
});

// --- 事件委托:在 ul 上监听,处理复选与删除 ---
$list.addEventListener('click', ev => {
  const target = ev.target as HTMLElement;
  const li = target.closest('li[data-id]') as HTMLLIElement | null;
  if (!li) return;
  const id = li.dataset.id!;
  if (target.matches('input[type="checkbox"]')) {
    toggle(id);
  } else if (target.matches('button.delete')) {
    remove(id);
  }
});

// --- 操作 ---
function toggle(id: string) {
  todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
  save(todos);
  render();
}
function remove(id: string) {
  todos = todos.filter(t => t.id !== id);
  save(todos);
  render();
}

// --- 渲染:最小化 DOM 操作(一次性 innerHTML 更新) ---
function render() {
  $list.innerHTML = todos.map(t => `
    <li data-id="${t.id}">
      <label>
        <input type="checkbox" ${t.done ? 'checked' : ''}>
        <span ${t.done ? 'style="text-decoration: line-through; opacity:.7"' : ''}>
          ${escapeHtml(t.title)}
        </span>
      </label>
      <button class="delete" aria-label="delete">🗑️</button>
    </li>
  `).join('');
  const total = todos.length;
  const done = todos.filter(t => t.done).length;
  $stats.textContent = `总数:${total},已完成:${done}`;
}

// --- 防 XSS:简单转义(插入 innerHTML 前务必转义外部字符串) ---
function escapeHtml(s: string) {
  const map: Record<string, string> = {
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  };
  return s.replace(/[&<>"']/g, ch => map[ch]);
}

// --- 异步演示(事件循环微任务/宏任务):控制台会看到顺序 ---
async function boot() {
  console.log('应用启动');
  setTimeout(() => console.log('宏任务:setTimeout'), 0);
  queueMicrotask(() => console.log('微任务:queueMicrotask'));
  await delay('TS 已就绪 ✅(查看控制台输出)', 200);
  console.log('异步:', 'TS 已就绪 ✅(查看控制台输出)');
}
function delay<T>(v: T, ms: number) {
  return new Promise<T>(resolve => setTimeout(() => resolve(v), ms));
}
boot();

5) 编译与运行

# 终端 A:持续编译
npx tsc -w

# 终端 B:启本地静态服务器(任选其一)
npx http-server -c-1 .
# 或:npx serve .
# 或:python3 -m http.server

打开控制台观察日志,尝试添加/勾选/删除 Todo,刷新后数据仍在(localStorage 持久化)。

6) 常见坑与调试技巧(与示例相关)

  • await + 循环forEach 中的 await 不生效;改用 for...ofPromise.all

  • 请求错误不抛fetch 对 4xx/5xx 不抛错,需 if (!res.ok) throw

  • 事件目标:事件委托时用 e.target.closest('选择器') 而不是只看 e.target

  • this 绑定:把方法解构出来后 this 丢失会报错;用箭头函数或 fn.bind(obj)

  • 源映射调试"sourceMap": true 后在 DevTools 的 “Sources” 直接断在 .ts

  • 日志可读性console.group/console.table;必要时放 debugger 做断点。

  • 性能小贴士:合并 DOM 更新(如一次性 innerHTML),避免在循环里读写触发布局抖动。

查阅:MDN(Console、DOM、Fetch)、TypeScript Handbook(Narrowing、Generics)、tsconfig Reference。


三、分层练习清单

入门(3 项)

  1. 输入校验:禁止添加空白标题(已经有),并限制标题长度(超出弹 alert)。

  2. 过滤视图:增加“全部 / 未完成 / 已完成”三段切换按钮(基于内存状态过滤)。

  3. 本地化显示:用 Intl.DateTimeFormat 在每条 Todo 后显示创建时间(相对或本地化日期)。

巩固(3 项)

  1. 搜索:加一个输入框,实时过滤显示标题包含关键字的 todo(防抖 300ms)。

  2. 批量操作:增加“全选/全不选/清除已完成”按钮,注意状态与 UI 的一致性。

  3. 模块化:把 storageviewtypes 拆成多个文件并使用 ESM import/export

进阶(3 项)

  1. 网络边界:新建 api.ts,封装 Result<T> 返回风格与错误处理,写一个模拟接口(delay + 随机失败)。

  2. 更强类型:为 filter 的选项使用字面量联合 type Filter = 'all' | 'active' | 'done' + as const,让 UI 与逻辑强绑定。

  3. 构建与部署:尝试用 Vite 初始化同等功能并比较原生 ESM 与打包的差异,开启 TS 严格模式。


四、自测(Checklist + 闭卷题)

A. 自测清单(能做到即达标)

  • 我能用 import/export 组织最小项目并在浏览器运行。

  • 我知道何时用 let/const,避免 var,且默认使用 ===

  • 我能写出一个返回 Promise<T> 的异步函数,并在调用处用 try/catch 处理错误。

  • 我能用 map/filter/reduce 完成列表大多数数据处理。

  • 我能用 addEventListener + 事件委托管理动态元素的交互。

  • 我能解释微任务与宏任务的执行顺序,并预测一个简单片段的输出。

  • 我能在 tsconfig 中开启 strict 并解释 target/module/lib/sourceMap/outDir 的含义。

  • 我能写一个带泛型的工具函数(如 head<T>(arr:T[])),并在边界处标注类型。

  • 我知道 anyunknown 的差别,并尽量使用 unknown + 类型缩窄。

  • 我能在 DevTools 中设置断点与查看网络与存储面板。

B. 闭卷题(3 题,含标准答案)

1) 事件循环顺序
题:下面代码输出顺序是?

console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
queueMicrotask(() => console.log(4));
console.log(5);

答案1, 5, 3, 4, 2
解析:同步先执行(1、5);同一轮事件循环清空微任务队列(按入队顺序 Promise 回调 3queueMicrotask4);然后执行宏任务队列(setTimeout2)。


2) 用泛型修正不安全的函数
题:指出问题并重写:

function head(arr: any[]): any { return arr[0]; }
const a: number = head([1,2,3]);
const b: number = head(['x','y']);

答案(安全写法)

function head<T>(arr: T[]): T | undefined { return arr[0]; }
// 推断:a: number | undefined, b: string | undefined

解析:用 泛型 把入参与返回值“绑定”;避免 any;空数组时返回 undefined 更贴近真实世界。


3) this 绑定
题:严格模式/ESM 下输出/行为?

const obj = { x: 42, getX() { return this.x; } };
const { getX } = obj;
console.log(getX());

答案:抛出 TypeError: Cannot read properties of undefined
解析:解构后独立调用 getX()thisundefined(严格模式);访问 this.x 直接报错。解决:const getX = obj.getX.bind(obj)const getX = () => obj.getX()


附:常见坑一页纸(速记)

  • forEachreturn 只是跳过当前回调,不会中断循环;需要中断用 some/every 或普通 for

  • Number('') === 0parseInt('')NaN;数值转换建议 Number() 并结合 Number.isNaN

  • || 会把 0/''/false 视为假值;只为 null/undefined 兜底请用 ??

  • JSON 反序列化得到的类型是 any,需要手动校验/缩窄(本示例做了轻校验)。

  • localStorage 有配额限制(~5MB),不适合存大型列表与二进制。

  • Date 默认构造本地时区;跨时区显示用 Intl.DateTimeFormat

    • *

官方文档指引(建议收藏):

  • MDN Web Docs:语言核心、DOM、Fetch、URL、Storage、Console。

  • TypeScript Handbook & tsconfig Reference:类型系统、类型缩窄、泛型、编译选项。

  • ECMAScript(ECMA‑262):语言语义权威来源(用于深入理解事件循环、抽象操作等)。

闽ICP备2021014815号-2
powered by emlog sitemap