写在学习文档(五)前面:入门之后,真正拉开差距的地方在哪
如果你已经看到了系列的第五篇,那说明前面几篇里的语法基础、函数作用域、DOM 操作这些内容你已经基本过了一遍。到了这个阶段,很多人会有一种共同的感受:单个知识点看文档都能看懂,但一打开编辑器开始写自己的页面或者小工具,立刻开始卡壳——字符串处理得绕来绕去,数组方法用得不自信,浏览器控制台一报错就懵,更别提遇到"javascript:void(0)"这种看起来像乱码的写法了。
这篇学习文档,我不想再按"数据类型、运算符、流程控制"的顺序把文档复述一遍。那样写出来的东西你翻三天也记不住多少。本文直接把 JavaScript 学习到这个阶段最常遇到的几个实战关卡摆出来:字符串和数组到底怎么用才顺手、void(0) 这类特殊语法背后的求值逻辑是什么、运行时报错的完整排查链路怎么建立、以及 JS 被嵌入到非浏览器环境(比如 OC 与 Axure)时它到底以什么方式运行。每一节都会有可以直接复制的代码和我在实际项目中踩过、填过的坑。这份内容适合那些已经学完基础语法、正在尝试独立写页面或小型应用、想从"看得懂教程"过渡到"自己写得出东西"的学习者。
我自己带过不少新人,也反复改过自己早年写的烂代码,下面这些内容,是真刀真枪用时间换来的。
1. 字符串与数组:从语法点到应用习惯
1.1 字符串为什么总是"改不动":不可变性的实际影响
先来看一个几乎每个初学者都会撞上的问题:
javascript复制let str = 'hello world';
str.replace('world', 'javascript');
console.log(str); // 输出什么?
如果你答"hello javascript",那你需要把这一节看完。正确答案是 hello world。字符串的 replace 方法并没有修改原字符串,而是返回了一个新字符串。几乎所有字符串方法都是这个套路:toUpperCase()、trim()、slice()、split()。原因在于 JavaScript 中的字符串属于原始类型(primitive type),原始类型的特征就是不可变(immutable)。你可以重新给变量赋值,但无法在原有内存地址上"改写"这个字符串。
实操层面的启发是:如果你要连续对字符串做多步处理,必须把中间结果接住,或者用链式调用:
javascript复制let raw = ' Hello, JavaScript World! ';
let cleaned = raw.trim().toLowerCase().replace('world', '前端');
console.log(cleaned); // "hello, javascript 前端!"
这只是热身。真正让字符串从"会背方法"变成"会应用"的,是要建立一种条件反射:当你要在字符串里做查找、截断、替换、拼接、补位时,脑子里能快速选定对应方法。
| 需求 | 推荐方法 | 注意点 |
|---|---|---|
| 按分隔符拆成数组 | split(分隔符) |
空字符串 '' 会按字符拆分 |
| 判断是否包含子串 | includes() |
区分大小写,需要忽略时配合 toLowerCase() |
| 截取中间一段 | slice(开始, 结束) |
支持负数索引,substring 不支持负数 |
| 替换所有匹配 | replaceAll() |
replace() 只替换第一处,除非用正则 /g |
| 去掉首尾空格 | trim() |
还有 trimStart() 和 trimEnd() |
| 按长度补位 | padStart() / padEnd() |
常用于时间格式化、序号对齐 |
我举个例子说明为什么这些细节在实际开发中会咬人。假设你要写一个倒计时组件,期望输出 05:08 这种带前导零的格式。新手常见的做法是:
javascript复制let min = 5;
let sec = 8;
let time = min + ':' + sec; // "5:8"
这显然不符合要求。利用 padStart 可以一行解决:
javascript复制let time = String(min).padStart(2, '0') + ':' + String(sec).padStart(2, '0');
// "05:08"
padStart 的意思是:如果当前字符串长度不够指定长度,就在开头补上指定字符。这个方法是 ES2017 加入的,现在所有现代浏览器都支持。
字符串相关的另一个高频坑是 split 和 join 搞混。split 是字符串调用的,把字符串拆成数组;join 是数组调用的,把数组合并成字符串。方向完全相反,但非常容易记反:
javascript复制let arr = ['2025', '05', '20'];
console.log(arr.join('-')); // "2025-05-20"
let dateStr = '2025-05-20';
console.log(dateStr.split('-')); // ["2025", "05", "20"]
1.2 数组方法那么多,实战里到底怎么选
数组相关的热搜词常年居高不下,说明这是 JavaScript 学习过程中的一个公认难点。学习数组应用,核心不是记住 map、filter、reduce、forEach、find、some 每一个方法的全部细节,而是建立一套"按需求选方法"的判断逻辑:
- 我要把每个元素映射成一个新值 →
map - 我要筛选出符合条件的元素 →
filter - 我在找第一个满足条件的元素 →
find - 我要判断是否至少有一个满足条件 →
some - 我要判断是否全部满足条件 →
every - 我要对每个元素执行副作用操作(比如打印) →
forEach - 我要把整个数组合并计算成一个值 →
reduce
我强烈建议从学数组开始就养成用 map 而不是 for 循环去"改造"数组的习惯。看一个典型的场景:后端返回了一组用户数据,你要把 user.name 抽出来组成一个名字列表:
javascript复制const users = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 3, name: '王五' }
];
// 新手写法
const names = [];
for (let i = 0; i < users.length; i++) {
names.push(users[i].name);
}
// 更符合函数式习惯的写法
const names = users.map(user => user.name);
两种写法结果一样,但第二种可读性明显更好,而且声明式的代码不容易出"索引越界"这类低级错误。你一旦养成了这个习惯,后面写 filter 和 find 简直就是顺水推舟:
javascript复制const adults = users.filter(user => user.age >= 18);
const target = users.find(user => user.id === 2);
find 和 filter 的区别值得单独强调:find 一旦找到第一个满足条件的元素就立即返回该元素本身(找不到返回 undefined),而 filter 会遍历完所有元素,返回一个新的数组(找不到返回空数组 [])。我见过不少人把 find 当 filter 用,结果拿到一个对象后直接 .map,报错 filter is not a function,排查半天才发现选错了方法。
reduce 是很多人觉得难啃的骨头,但它本质上就是把一个数组"折叠"成一个值。举个例子,统计一组订单的总金额:
javascript复制const orders = [
{ product: '键盘', price: 199 },
{ product: '鼠标', price: 89 },
{ product: '显示器', price: 1299 }
];
const total = orders.reduce((sum, order) => sum + order.price, 0);
console.log(total); // 1587
reduce 接受两个参数:第一个是回调函数,回调函数里第一个参数是"累加器"(上一轮的结果),第二个参数是当前元素;第二个参数是累加器的初始值。上面的代码里,0 就是初始值。如果省略初始值,reduce 会把数组的第一个元素当作初始值,然后从第二个元素开始遍历。省略初始值在某些场景会导致意料之外的结果,比如对空数组调用 reduce 会直接抛 TypeError,所以建议始终显式传入初始值。
再提一个数组方法里非常容易踩的坑:splice 和 slice 长得很像,但行为天差地别。slice 不会修改原数组,返回的是原数组的一个切片副本;splice 会直接修改原数组,用于删除、插入、替换元素:
javascript复制let arr1 = [1, 2, 3, 4, 5];
// slice:不改变原数组
let sliced = arr1.slice(1, 3);
console.log(sliced); // [2, 3]
console.log(arr1); // [1, 2, 3, 4, 5]
// splice:从索引1开始删除2个元素
let spliced = arr1.splice(1, 2);
console.log(spliced); // [2, 3]
console.log(arr1); // [1, 4, 5]
1.3 解构赋值和展开运算符:数组用得优雅的秘诀
如果说前面那些方法让你能解决 80% 的需求,那解构赋值和展开运算符能让你剩下的 20% 写得更漂亮。
解构赋值可以从数组里"拆包":
javascript复制const [first, second, ...rest] = [10, 20, 30, 40];
console.log(first); // 10
console.log(second); // 20
console.log(rest); // [30, 40]
可以用来优雅地交换变量:
javascript复制let a = 1;
let b = 2;
[a, b] = [b, a];
console.log(a, b); // 2 1
展开运算符可以快速合并数组、复制数组:
javascript复制const arrA = [1, 2, 3];
const arrB = [4, 5, 6];
const merged = [...arrA, ...arrB];
console.log(merged); // [1, 2, 3, 4, 5, 6]
const copy = [...arrA]; // 浅拷贝
这里必须提醒一个浅拷贝的陷阱:[...arr] 和 arr.slice() 都是浅拷贝,如果数组里放的是对象,复制的是对象的引用,修改副本里的对象属性,原数组里的对应对象也会变。需要深拷贝时可以考虑 structuredClone(现代浏览器支持)或者手动递归。
2. javascript:void(0) 到底在干什么:一个运算符和一个协议的组合
2.1 从浏览器的地址栏说起
很多人在老代码里见过类似 <a href="javascript:void(0)" onclick="doSomething()">点击</a> 这种写法,也会在搜索引擎热搜里看到 javascript:void(0) 相关的报错搜索。要理解它在干什么,得先拆成两部分看:javascript: 是一个伪协议,void(0) 是一个表达式。
正常情况下,在浏览器地址栏输入一个 URL,浏览器会发起网络请求加载页面。但如果你输入 javascript:alert('hello') 然后回车,浏览器不会去请求网络,而是把 javascript: 后面的内容当成 JavaScript 代码执行。这就是"伪协议"的含义——它不是一个真实的网络协议,只是一个使浏览器进入脚本执行模式的入口。同理,<a> 标签的 href 属性里写 javascript: 开头的字符串,点击时也会执行对应的 JS 代码。
void 是 JavaScript 的一个运算符,它的作用非常纯粹:计算后面的表达式,然后永远返回 undefined。所以 void(0) 就是"计算 0,然后返回 undefined"。
把两者组合起来,href="javascript:void(0)" 的意思是:点击这个链接时,执行一段 JS,这段 JS 的结果是 undefined。为什么非要让它返回 undefined?因为浏览器在处理 javascript: 伪协议的返回值时有一个特殊逻辑:如果返回值不是 undefined,浏览器会用这个返回值替换当前页面的内容。也就是说,如果 href="javascript:0",点击后浏览器可能会把当前页面替换成一个大大的 0。而 void(0) 返回 undefined,页面内容就不会被替换,只会执行你写在 onclick 里的逻辑。
2.2 为什么不用 href="#"
新手经常问的一个问题是:那直接把 href 设置为 "#" 不就行了?这里有两个实际差别。
第一,点击 <a href="#"> 后,URL 会追加一个 #,浏览器会尝试滚动到页面中 id 为空的锚点位置(通常滚动到顶部),而且会在浏览历史里留下一条记录,用户点后退时会觉得"怎么没反应"。第二,如果页面很长,用户点击一个位于底部的"空链接",页面会瞬间跳回顶部,体验很糟糕。javascript:void(0) 则不会有这些副作用,URL 不会变,页面位置也不会动。
不过从现代前端实践来看,javascript:void(0) 已经不算是最佳方案了。更好的做法是直接用 <button> 元素来承载交互动作,或者给 <a> 的点击事件里加上 event.preventDefault()。前者语义更正确——如果你只是要一个可以点击的控件,按钮比链接合适得多;后者能避免在 href 里写任何 javascript: 代码,减少可读性负担和潜在安全问题。比如:
html复制<a href="#" id="doAction">点击执行</a>
<script>
document.getElementById('doAction').addEventListener('click', function (event) {
event.preventDefault();
// 真正的业务逻辑
});
</script>
2.3 void 运算符的其它作用:从热搜里的奇怪代码谈起
热搜词里有一条很显眼:javascript:void(document.title=document.cookie)。这类代码在原理上依然是我们上面分析的框架:通过 javascript: 协议执行一个表达式,这里执行的是赋值操作 document.title = document.cookie,然后 void 确保返回 undefined,页面不会被替换。说白了,它就是把当前页面的标题改成了当前页面的 cookie 值。
如果你在真实的网站里看到这种代码,要提高警惕——它通常是攻击者用来探测或利用 XSS 漏洞的手段。攻击者如果能在页面里注入任意脚本,理论上可以做更多事情,而把 cookie 写到标题栏上往往是一种验证手段。这个例子放在这里是帮助你把 void 的求值逻辑理解透,而不是让你去复制它。我在实际项目中遇到类似代码的原则就一条:生产环境绝对不允许出现裸的 javascript: 伪协议代码,涉及用户数据的脚本更是连想都不要想。
另外顺带解释一个热搜词 javascript:void(o) 报错。这种写法里的 o 是一个变量名,如果代码里没有定义过 o,执行 void(o) 会抛出 ReferenceError: o is not defined。这是运行时错误,不是 void 本身的问题。很多时候看到这种报错,是因为代码是从混淆压缩后的脚本里复制过来的,变量名被压缩成了 o,单独拿出来执行自然会报错。排查思路也很简单:回到原始代码里找 o 对应的变量声明。
3. 建立运行时报错的完整排查链路
3.1 控制台报错信息里,哪一行才是关键
学习 JavaScript 到写真实页面阶段,控制台报错会成为你的日常。与其害怕它,不如把它当作调试线索。"JavaScript 运行时报错"是一个宽泛的说法,但基本上所有报错都可以通过一套固定流程来定位。
先看一个典型的报错长什么样:
code复制Uncaught TypeError: Cannot read properties of undefined (reading 'length')
at showList (app.js:12:25)
at HTMLButtonElement.onclick (index.html:18:68)
正确的读法是:第一行告诉你错误类型和简要信息——TypeError 表示你在某个值上执行了它不支持的操作,这里具体是"尝试读取 undefined 的 length 属性";第二行 at 后面告诉你出错的位置——函数名 showList、文件 app.js、行列号 12:25;第三行告诉你这个函数是被谁调用的——HTMLButtonElement.onclick,也就是按钮的点击事件。这就是调用栈(stack trace)。
绝大多数初学者犯的错误是只看第一行,然后就在整个文件里乱翻。正确姿势是先看第二行定位到具体文件、具体行,再看调用栈的前几层理解触发链路。如果报错信息指向的是压缩后的代码或者框架内部代码,那问题几乎肯定出在你自己的业务代码里,往上翻调用栈,找到第一个你熟悉的文件名。
3.2 一个真实案例的排查过程:Cannot read properties of undefined
分享一个我在实际项目里遇到过的典型问题。用户反馈一个列表页面有时候能加载,有时候点了按钮整个页面空白。打开控制台,报错是 Cannot read properties of undefined (reading 'map')。
看到这个报错后,我先按上面说的流程定位到具体代码:
javascript复制function renderNews(data) {
const list = document.getElementById('news-list');
list.innerHTML = data.list.map(item => `<li>${item.title}</li>`).join('');
}
崩溃点很明确:data.list 是 undefined,所以 .map 调用失败。但为什么 data.list 会缺失?继续往调用方查,发现这个 renderNews 的数据来自接口返回:
javascript复制const res = await fetch('/api/news');
const data = await res.json();
renderNews(data);
问题在于接口在某种边界条件下返回的不是预期结构,而是 { code: 500, message: 'error' } 或者直接返回了 null。也就是说,前端的假设——"接口永远返回 { list: [...] }"——不成立。
这类问题的排查思路可以固化成一个步骤链:
- 看报错类型和 message,定位到具体文件行。
- 判断是哪个变量为
undefined(这里通过阅读代码知道是data.list)。 - 顺着数据流往回查,找到这个变量的赋值来源。
- 用
console.log或者断点在赋值处打印实际数据,确认它在什么情况下会缺失。 - 在代码里加上防御处理,比如
if (!data || !Array.isArray(data.list))时给出兜底 UI。
修复代码里有一处细节值得说一下,就是不能只写 if (data.list),因为如果接口正常返回了 { list: [] },空数组也是真值,会通过判断;但如果返回的是 { list: 'error' },data.list 也是真值,还是会走到 .map 上报错。更稳妥的判断是 Array.isArray(data.list)。这就是从"报错信息"到"防御性编程"的完整升级路径。
3.3 异步错误为什么不好抓:try...catch 的作用范围
另一个让初学者很困惑的问题:为什么我加了 try...catch 还是捕获不到报错?看这段代码:
javascript复制try {
setTimeout(() => {
throw new Error('异步报错');
}, 100);
} catch (e) {
console.log('捕获到了', e);
}
这段代码不能捕获异步任务里的错误。原因是 setTimeout 的回调函数是在将来某个时刻由运行时调用的,那时候 try...catch 所在的同步代码块早就执行完了,作用域已经退出,自然无法捕获。要捕获异步操作里的错误,正确的做法是把 try...catch 放进异步回调内部:
javascript复制setTimeout(() => {
try {
throw new Error('异步报错');
} catch (e) {
console.log('捕获到了', e);
}
}, 100);
如果是 Promise 或 async/await 场景,错误处理逻辑又不一样。async 函数里可以用 try...catch 捕获 await 的 rejection:
javascript复制async function loadData() {
try {
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data;
} catch (e) {
console.error('请求失败', e);
return null;
}
}
但如果你没有 await 一个 Promise,而是直接调用一个返回 Promise 的函数且不处理它,那它内部的 rejection 就成了"未处理的 Promise 拒绝"(unhandled promise rejection),这时全局的 unhandledrejection 事件可以兜底:
javascript复制window.addEventListener('unhandledrejection', function (event) {
console.error('未处理的 Promise 拒绝:', event.reason);
});
我一直向新人强调一件事:给自己建立一个"运行时错误的分类意识"。把报错分成语法错误(代码格式不对,编译器直接报)、类型错误(值不是预期的类型,比如对 undefined 调方法)、引用错误(变量未定义)、范围错误(比如 new Array(-1))、以及异步错误(发生在回调或 Promise 中)。分类意识一旦建立,排查的时候就不会像没头苍蝇一样乱试。
3.4 用调试器代替 console.log
console.log 是好东西,但学会用 debugger 语句和浏览器开发者工具里的断点,排查效率还能再上一个台阶。
在代码里写上 debugger;,当浏览器执行到这一行时,会自动暂停,进入调试状态:
javascript复制function calcTotal(price, count) {
const total = price * count;
debugger; // 执行到这里会暂停
return total;
}
暂停之后,你可以在 Sources 面板里看到当前作用域的所有变量值,可以单步执行(Step over / Step into),可以监视某个表达式。相比 console.log 的好处是:不需要提前想好要打印哪些变量,暂停时可以自由查看任意变量的状态,还能往回看调用栈。等调试完,把 debugger; 语句删掉即可。
我个人实际使用的经验是,console.log 更适合快速确认一个值有没有进来,debugger 适合深入理解一段复杂逻辑的执行过程。如果一段代码出了诡异的问题,上 debugger 逐行看一遍,往往比打二十个 console.log 更快定位到问题。
4. 脱离浏览器之后:JS 在别的环境里怎么活
4.1 JavaScript 和它的"宿主环境"
JavaScript 这门语言很有意思,它本身不依赖浏览器。真正决定"JavaScript 能干什么"的,是它运行在哪个宿主环境里。浏览器提供了 window、document、fetch 这些 API;Node.js 提供了文件系统、网络、进程等 API;iOS 的 JavaScriptCore 和 Android 的 V8 则把 JS 嵌入到原生应用里。从这个角度看,"JavaScript 运行时报错"这件事在每个宿主环境里都有不同的表现。
热搜词里有一条是"oc和javascript互相调用",这正好对应 iOS 开发里一个核心问题:原生代码和 Web 页面里的 JS 怎么通信。理解这个桥接逻辑,对前端开发者来说能帮你把"JS 是运行在宿主环境中的脚本"这个认知坐实。
在 iOS 的 WKWebView 场景下,双向通信的机制大致如下。
OC 调用 JS,核心是执行一段 JS 代码字符串:
objectivec复制// 假设 webView 是 WKWebView 实例
[webView evaluateJavaScript:@"document.getElementById('app').innerText" completionHandler:^(id result, NSError *error) {
if (error) {
NSLog(@"执行 JS 出错: %@", error.localizedDescription);
} else {
NSLog(@"拿到 JS 返回值: %@", result);
}
}];
JS 调用 OC,在 WKWebView 时代的推荐方案是通过 WKScriptMessageHandler 拦截特定的消息。前端侧写法类似:
javascript复制window.webkit.messageHandlers.AppHandler.postMessage({ action: 'openCamera', data: {} });
原生侧需要在配置 WKWebView 时注册名为 AppHandler 的消息处理器,然后实现对应的回调方法。前端发来的消息会被封装成一个 WKScriptMessage 对象,原生代码从里面取出 body 里的数据,再分发给对应的逻辑。
另一条路线是使用 JavaScriptCore 框架(主要用于 UIWebView 或者纯 JS 上下文场景)。原生代码通过 JSExport 协议暴露一个对象给 JS 调用:
objectivec复制@protocol NativeBridgeProtocol <JSExport>
- (void)callNativeMethod:(NSString *)methodName params:(NSDictionary *)params;
@end
然后在 JS 里就能直接调用 NativeBridge.callNativeMethod('openCamera', {})。这套机制的门槛比 WKScriptMessage 高一点,但它更接近"运行时注入对象"的模型——原生环境动态地往 JS 全局作用域里塞了一个对象,JS 代码根本感知不到这个对象来自原生还是来自另一个 JS 文件。
对于前端开发者来说,这些细节不需要全部背下来,但有一个建议值得记住:跨语言调用时,一定要约定好数据格式和错误处理方式。因为 JS 异常不会自动变成 OC 异常,OC 的 block 回调也不会自动变成 JS 的 Promise。你在写这类桥接时,总得在某一层显式地把错误结构化成纯数据,比如统一的 { code: 0, data: ... } 格式。这样不管哪一端出了问题,至少能按统一的协议去解析。
4.2 在 Axure 里嵌入 JavaScript 到底是什么原理
热搜词里还有一个很有意思的问题:"如何在Axure嵌入JavaScript脚本"。Axure 是原型设计工具,很多人不知道它也能跑 JS。其实 Axure 生成的 HTML 页面本身就是一套可以运行的网页,所以它当然可以携带自定义的 JavaScript 逻辑。区别只在于:Axure 提供了图形化的交互设计能力,但你依然可以通过某些入口写自己的脚本。
在 Axure RP 里,常规做法是在某个交互事件(比如按钮的"单击"事件)中添加一个"自定义脚本"(Set Custom Script)动作,然后填写 JS 代码。这里有一个关键限制:Axure 脚本运行的上下文是 Axure 生成的那套页面框架,里面已经定义了它自己的全局变量和函数。你在自定义脚本里可以直接操作当前页面的 DOM,也可以通过 window 对象去访问 Axure 内置的一些数据结构。
我在给团队搭原型规范时总结过三个实用要点:
-
如果脚本里要操作 Axure 生成的动态面板,先用浏览器开发者工具查看实际生成的 DOM 结构,不要凭直觉写选择器。Axure 生成的 class 名称通常是类似
ax_default这样的命名,直接选中比猜测稳定。 -
原型页面的跳转、弹窗这类交互,能使用 Axure 自带的交互能力就不要用 JS 自己实现。自研脚本只用来补充一些 Axure 图形化配置做不到的逻辑,比如根据 URL 参数初始化页面状态、调用外部接口填充数据等。
-
在 Axure 里写脚本时,代码一开始最好先判断当前环境是否是 Axure 的预览模式。因为 Axure 不同版本生成的全局对象名可能略有差异,写一个兼容判断能避免原型在不同电脑上打开时报错。
把这些场景串起来看,你会发现 JavaScript 的"应用能力"从来不只是语法本身的宽度,更多取决于你对运行环境的理解深度。同一个 setTimeout 在浏览器里配合 requestAnimationFrame 做动画,在 Node 里配合事件循环处理 IO,在 WKWebView 里被原生代码调用。学文档的最终目的,就是把 JavaScript 这个语言核心打磨好,然后到任何宿主里都能快速搞清楚"它能给我什么 API,我怎么和它通信"。
文章写到这儿,核心的内容都铺开了。我用最后一段做个收尾,聊聊学习阶段的转变:到达《JavaScript 学习文档》第五篇这个节点,你真正的里程碑不是你记住了多少个 API,而是面对一个"为什么报错"的问题时,脑子里能自动浮现出几个排查方向;面对一段别人的代码时,能看出它在哪个环境里运行、用了哪种通信方式、哪些地方是坑。往后的学习路径无非是多写、多调试、多看真实项目的代码,把本文里说的这些判断习惯反复练成直觉。下一篇文章开始,就可以进入具体框架和工程化的世界了。
