原生 JS 灵魂之问(中),检验自己是否真的熟悉 JavaScript?
Category(分类): JavaScript Status: 已整理
本文保留原文从数组、高阶函数、手写数组方法到
new、bind、call/apply、this、浅拷贝和深拷贝的练习路线。手写实现用于学习规范思路,不应覆盖原生方法,也不应宣称等同引擎实现。
真实业务中不一定需要手写 splice 或深拷贝,但边界问题能帮助我们理解 JavaScript 的对象模型、属性描述符、稀疏数组和异常传播。涉及性能的结论应以目标引擎和实际基准测试为准。
第七篇:arguments 为什么不是数组?如何转化成数组?
arguments 是一个类数组对象:有从 0 开始的索引和 length,但它不是 Array,不能直接调用 map、filter 等数组方法。浏览器中的 HTMLCollection、NodeList 也可能是类数组或可迭代对象。
function sum(a, b) {
console.log(Array.isArray(arguments)) // false
const args = Array.prototype.slice.call(arguments)
return args.reduce((total, value) => total + value, 0)
}
console.log(sum(1, 2)) // 3
1. Array.prototype.slice.call
这是历史上常见的 ES5 写法:
function toArray(value) {
return Array.prototype.slice.call(value)
}
它依赖类数组的 length 和索引属性,某些宿主对象或旧浏览器可能存在兼容性差异。
2. Array.from
现代代码通常优先使用 Array.from:
function sum(...args) {
return Array.from(args).reduce((total, value) => total + value, 0)
}
console.log(Array.from(new Set([1, 2, 2]))) // [1, 2]
Array.from 可以处理可迭代对象和具有 length 的类数组对象。
3. 展开语法
展开语法要求对象可迭代:
function collect(...args) {
return [...args]
}
console.log(collect(1, 2, 3)) // [1, 2, 3]
一个只有索引和 length、但没有 Symbol.iterator 的类数组对象不能直接使用 [...value],此时使用 Array.from(value)。
4. concat.apply
旧代码还会这样写:
function concatArguments() {
return Array.prototype.concat.apply([], arguments)
}
console.log(concatArguments(1, 2, 3)) // [1, 2, 3]
这种写法可读性和边界行为都不如 Array.from,新代码不必优先使用。
第八篇:forEach 中 return 有效果吗?如何中断循环?
forEach 会调用每个符合条件的元素的回调,但回调的返回值不会成为 forEach 的返回值,也不能用 break 终止外层遍历:
const nums = [1, 2, 3]
const result = nums.forEach((item) => {
if (item === 2) return
console.log(item)
})
console.log(result) // undefined
如果需要提前结束:
some:回调返回真值时停止并返回true。every:回调返回假值时停止并返回false。find/findIndex:找到结果时停止。- 普通
for、for...of:可以使用break和continue。 - 抛出异常可以强行中断,但通常不是正常控制流方案。
const nums = [1, 2, 3, 4]
const found = nums.some((item) => {
console.log(item)
return item === 2
})
console.log(found) // true
另外,forEach 不会等待异步回调:
async function wrong() {
[1, 2, 3].forEach(async (item) => {
await save(item)
})
console.log('这里不会等待 save')
}
需要串行等待时使用 for...of:
async function serial(items) {
for (const item of items) {
await save(item)
}
}
需要并行等待时使用 Promise.all:
async function parallel(items) {
return Promise.all(items.map((item) => save(item)))
}
第九篇:JS 判断数组中是否包含某个值
1. indexOf
const arr = [1, 2, 3, 4]
console.log(arr.indexOf(3)) // 2
console.log(arr.indexOf(5)) // -1
indexOf 使用严格相等风格的比较,因此找不到 NaN:
console.log([NaN].indexOf(NaN)) // -1
2. includes
console.log([1, 2, 3].includes(3)) // true
console.log([NaN].includes(NaN)) // true
console.log([1, 2, 3].includes(2, 1)) // true
includes 使用 SameValueZero 比较,适合表达“是否包含”。
3. find
find 返回第一个满足条件的元素,而不是索引:
const result = [1, 2, 3, 4].find((item) => item > 3)
console.log(result) // 4
4. findIndex
原文中的索引查找方法名称有拼写错误,正确名称是 findIndex:
const index = [1, 2, 3, 4].findIndex((item) => item > 3)
console.log(index) // 3
如果只需要判断,可以优先使用 includes 或 some,不要为了布尔结果保存不必要的索引。
第十篇:数组扁平化 flat
需求是把多层数组展开:
const nested = [1, [2, [3, [4, 5]]], 6]
console.log(nested.flat(Infinity)) // [1, 2, 3, 4, 5, 6]
1. 原生 flat
flat(depth) 默认只展开一层,Infinity 表示尽可能展开所有嵌套数组。它会创建新数组,不会递归修改原数组。
2. 递归实现
function flatten(value, depth = Infinity) {
const result = []
for (const item of value) {
if (Array.isArray(item) && depth > 0) {
result.push(...flatten(item, depth - 1))
} else {
result.push(item)
}
}
return result
}
console.log(flatten([1, [2, [3]]], 1)) // [1, 2, [3]]
3. reduce 实现
function flattenWithReduce(value) {
return value.reduce((result, item) => {
return result.concat(Array.isArray(item) ? flattenWithReduce(item) : item)
}, [])
}
4. while + concat
function flattenWithConcat(value) {
let result = value.slice()
while (result.some(Array.isArray)) {
result = [].concat(...result)
}
return result
}
不建议的字符串方案
原文的 JSON.stringify + replace + split 只对非常受限的数字数组勉强有效,会破坏字符串中的逗号、null、对象、undefined、稀疏项和特殊值。它不是通用数组扁平化实现,新代码应使用 flat 或明确的递归实现。上面的递归和循环实现也主要面向普通、无环数组;要完整复刻原生 flat,还要考虑稀疏槽位、Symbol.isConcatSpreadable、数组 species 和代理。它们适合说明递归/迭代思路,不是 polyfill。
第十一篇:JS 数组的高阶函数——基础篇
接收函数作为参数,或返回函数的函数,通常称为高阶函数。数组的 map、filter、reduce、sort 等方法都接受回调,但它们的返回值和是否修改原数组不同。
1. map
map 创建新数组,回调参数依次是当前值、索引和原数组;它会跳过稀疏数组中的空槽,但结果数组保留对应空槽:
const nums = [1, 2, 3]
const obj = { value: 5 }
const newNums = nums.map(function (item, index, array) {
return item + index + array[index] + this.value
}, obj)
console.log(newNums) // [7, 10, 13]
2. reduce
reduce 的回调参数依次是累积值、当前值、索引和原数组。没有初始值时,数组第一个存在的元素成为初始累积值;空数组没有初始值会抛出 TypeError:
const total = [1, 2, 3].reduce((sum, item) => sum + item, 0)
console.log(total) // 6
3. filter
filter 返回一个新数组,只保留回调结果为真值的元素:
const oddNums = [1, 2, 3].filter((item) => item % 2 === 1)
console.log(oddNums) // [1, 3]
4. sort
不传比较器时,数组元素会先转为字符串,按 UTF-16 code unit 顺序排序:
console.log([10, 2, 1].sort()) // [1, 10, 2]
console.log([10, 2, 1].sort((a, b) => a - b)) // [1, 2, 10]
从 ECMAScript 2019 起,标准要求 Array.prototype.sort 稳定:比较结果相等的元素保持原有相对顺序。但排序算法和性能仍由引擎决定,不能把某个旧版 V8 的实现细节当作规范。
第十二篇:能不能实现数组 map 方法?
下面的实现展示 map 的主要步骤:把 this 转为对象、读取固定的长度、跳过不存在的索引、传入三个回调参数。它没有完整实现 species、代理异常和所有属性描述符语义,因此只作为教学版本。

function mapLike(arrayLike, callback, thisArg) {
if (arrayLike == null) {
throw new TypeError('map called on null or undefined')
}
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function')
}
const object = Object(arrayLike)
const numberLength = Number(object.length)
const length = Number.isFinite(numberLength) && numberLength > 0
? Math.min(Math.floor(numberLength), Number.MAX_SAFE_INTEGER)
: 0
const result = new Array(length)
for (let index = 0; index < length; index++) {
if (index in object) {
result[index] = callback.call(
thisArg,
object[index],
index,
object,
)
}
}
return result
}
console.log(mapLike([1, 2, 3], (value) => value * 2)) // [2, 4, 6]
原文使用 length >>> 0,这会把长度强制成 32 位无符号整数,不等同于现代规范的 LengthOfArrayLike,也会错误处理超过 2^32 - 1 的类数组长度。原文代码中的 callbackfn/KValue 大小写不一致、newArray 等抓取错误也已修正。
第十三篇:能不能实现数组 reduce 方法?
实现 reduce 时最容易出错的是:不能用 initialValue === undefined 判断调用者是否传了初始值,因为调用者可以显式传入 undefined。应使用参数个数判断。

function reduceLike(arrayLike, callback, initialValue) {
if (arrayLike == null) {
throw new TypeError('reduce called on null or undefined')
}
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function')
}
const object = Object(arrayLike)
const numberLength = Number(object.length)
const length = Number.isFinite(numberLength) && numberLength > 0
? Math.min(Math.floor(numberLength), Number.MAX_SAFE_INTEGER)
: 0
let index = 0
let accumulator
if (arguments.length >= 3) {
accumulator = initialValue
} else {
while (index < length && !(index in object)) index++
if (index >= length) {
throw new TypeError('Reduce of empty array with no initial value')
}
accumulator = object[index++]
}
for (; index < length; index++) {
if (index in object) {
accumulator = callback(
accumulator,
object[index],
index,
object,
)
}
}
return accumulator
}
console.log(reduceLike([1, 2, 3], (sum, value) => sum + value, 0)) // 6
in 会检查原型链,这是数组内置方法处理稀疏数组时需要考虑的语义之一。上面的实现仍然没有实现 ArraySpeciesCreate 等完整规范细节。
第十四篇:能不能实现数组 push、pop 方法?
push 和 pop 会修改数组本身,并返回新长度或移除的元素。它们也是泛型方法,可以对合适的类数组对象调用,但真正的 Array length 有自己的范围和属性约束。


function pushLike(arrayLike, ...items) {
const object = Object(arrayLike)
const length = Number(object.length) || 0
const max = Number.MAX_SAFE_INTEGER
if (length + items.length > max) {
throw new TypeError('Invalid array length')
}
for (let index = 0; index < items.length; index++) {
object[length + index] = items[index]
}
object.length = length + items.length
return object.length
}
function popLike(arrayLike) {
const object = Object(arrayLike)
const length = Number(object.length) || 0
if (length === 0) {
object.length = 0
return undefined
}
const index = length - 1
const value = object[index]
delete object[index]
object.length = index
return value
}
这两个示例没有处理不可写 length、不可配置属性、代理和所有异常回滚语义;生产代码直接使用 Array.prototype.push/pop。
第十五篇:能不能实现数组 filter 方法?
function filterLike(arrayLike, callback, thisArg) {
if (arrayLike == null) {
throw new TypeError('filter called on null or undefined')
}
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function')
}
const object = Object(arrayLike)
const numberLength = Number(object.length)
const length = Number.isFinite(numberLength) && numberLength > 0
? Math.min(Math.floor(numberLength), Number.MAX_SAFE_INTEGER)
: 0
const result = []
for (let index = 0; index < length; index++) {
if (index in object) {
const value = object[index]
if (callback.call(thisArg, value, index, object)) {
result.push(value)
}
}
}
return result
}
过滤结果会重新从索引 0 开始排列,不会保留原数组中的空槽位置。和 map 一样,回调中修改数组会影响后续可见值,但遍历长度是开始时读取的长度;具体代理和属性异常属于规范边界。

第十六篇:能不能实现数组 splice 方法?
splice(start, deleteCount, ...items) 会在原数组上删除和插入元素,并返回被删除元素组成的新数组:
const array = [1, 2, 3, 4]
const removed = array.splice(1, 2, 'a', 'b', 'c')
console.log(removed) // [2, 3]
console.log(array) // [1, 'a', 'b', 'c', 4]
参数规则包括:负索引从末尾计算;start 超出范围会被夹到边界;省略 deleteCount 与显式传入 0 不同;删除数量会被限制在剩余长度内。

受限的教学实现
下面的版本假定 start 和 deleteCount 已经是有限整数,只针对密集普通数组,用来展示移动尾部元素的思路,不处理稀疏数组、代理、species、不可扩展对象、访问器或精确的异常回滚:
function spliceDense(array, start, deleteCount, ...items) {
const length = array.length
const actualStart = start < 0
? Math.max(length + start, 0)
: Math.min(start, length)
const actualDeleteCount = Math.min(
Math.max(deleteCount, 0),
length - actualStart,
)
const removed = array.slice(
actualStart,
actualStart + actualDeleteCount,
)
const tail = array.slice(actualStart + actualDeleteCount)
array.length = actualStart
array.push(...items, ...tail)
return removed
}
const array = [1, 2, 3, 4]
console.log(spliceDense(array, 1, 2, 'a')) // [2, 3]
console.log(array) // [1, 'a', 4]
原文声称手写实现“通过 MDN 所有测试用例”并不可靠:完整 splice 需要处理大量边界,包括 deleteCount 省略、长度上限、稀疏槽位、Proxy 访问顺序、冻结/密封对象和属性定义失败。不要把上述示例当作 polyfill。
密封和冻结对象
- 密封对象不能新增或删除自有属性,但可修改仍可写的值。
- 冻结对象不能新增、删除或修改数据属性值。
splice可能需要移动和删除属性,因此在密封/冻结数组上可能抛出异常或只完成部分前置操作,不能用一个简单的isSealed条件覆盖所有情况。


第十七篇:能不能实现数组 sort 方法?
sort 会原地排序并返回同一个数组。默认比较会把元素转换为字符串;自定义比较器应返回负数、正数或 0:
const array = [10, 2, 1]
array.sort((a, b) => a - b)
console.log(array) // [1, 2, 10]
当前规范要求稳定排序,但没有规定必须使用哪一种排序算法。V8 的旧版本曾使用快速排序等策略,现代 V8 采用过 TimSort 等实现细节;不同引擎和版本会变化。原文基于 Node v10 的基准和 V8 源码得出的“自己实现的排序性能一样”不能保留为结论。

一个用于学习的插入排序
function insertionSort(array, compare = defaultCompare) {
for (let index = 1; index < array.length; index++) {
const value = array[index]
let position = index - 1
while (position >= 0 && compare(array[position], value) > 0) {
array[position + 1] = array[position]
position--
}
array[position + 1] = value
}
return array
}
function defaultCompare(left, right) {
const a = String(left)
const b = String(right)
return a < b ? -1 : a > b ? 1 : 0
}
console.log(insertionSort([10, 2, 1])) // [1, 10, 2]
console.log(insertionSort([10, 2, 1], (a, b) => a - b)) // [1, 2, 10]
插入排序适合小数据或接近有序的数据;它不是现代引擎 sort 的通用替代品。
原文中关于“n <= 10 使用插入排序、n > 1000 取样选择哨兵”的描述属于旧版 V8 的实现观察,不能作为当前 JavaScript 或 V8 的规范保证。




第十八篇:能不能模拟实现一个 new 的效果?
new Constructor(...args) 的教学模型包括:创建对象、把实例的原型设置为 Constructor.prototype、以新对象为 this 执行构造器、处理构造器返回值。若构造器显式返回对象或函数,返回该对象;返回原始值则忽略。
function newLike(Constructor, ...args) {
if (typeof Constructor !== 'function') {
throw new TypeError('Constructor must be callable')
}
const prototype = Constructor.prototype
const object = Object.create(
prototype !== null && (typeof prototype === 'object' || typeof prototype === 'function')
? prototype
: Object.prototype,
)
const result = Reflect.apply(Constructor, object, args)
if (result !== null && (typeof result === 'object' || typeof result === 'function')) {
return result
}
return object
}
这个实现不能完整模拟原生 new 的 new.target、代理构造器、内置构造器内部槽和所有异常行为。需要保留 new.target 语义时,优先使用 Reflect.construct:
function constructLike(Constructor, args, NewTarget = Constructor) {
return Reflect.construct(Constructor, args, NewTarget)
}
第十九篇:能不能模拟实现一个 bind?
原生 bind 至少需要处理:普通调用时的 this、预置参数、绑定函数再次被 new 调用,以及原函数原型链的关系。下面是一个教学实现:
function bindLike(fn, thisArg, ...boundArgs) {
if (typeof fn !== 'function') {
throw new TypeError('bind target must be callable')
}
function bound(...callArgs) {
const args = [...boundArgs, ...callArgs]
if (new.target) {
return Reflect.construct(fn, args, new.target)
}
return Reflect.apply(fn, thisArg, args)
}
if (fn.prototype) {
bound.prototype = Object.create(fn.prototype, {
constructor: {
configurable: true,
value: bound,
writable: true,
},
})
}
return bound
}
仍然不能把它称为完整 polyfill:原生 bound function 的 name、length、构造器细节、代理和跨 realm 行为都有更多规范要求。生产代码直接使用 Function.prototype.bind。
function Person(name) {
this.name = name
}
const BoundPerson = bindLike(Person, { ignored: true }, 'Ada')
const person = new BoundPerson()
console.log(person.name) // Ada
console.log(person instanceof Person) // true
箭头函数没有自己的 this,也不能作为构造器;给箭头函数调用 bind 只能预置参数,不能改变其词法 this。
第二十篇:能不能实现一个 call/apply 函数?
直接实现原生 call/apply 的核心其实就是 Reflect.apply:
function callLike(fn, thisArg, ...args) {
return Reflect.apply(fn, thisArg, args)
}
function applyLike(fn, thisArg, args) {
const list = args == null ? [] : Array.from(args)
return Reflect.apply(fn, thisArg, list)
}
如果为了理解早期 ES5 技术而临时把函数挂到对象上,需要注意 context || window 是错误的:它会错误处理 0、false、空字符串和 null,也无法精确模拟严格函数的 this 语义。可以写出一个有限的教学版本:
function callByTemporaryProperty(fn, thisArg, ...args) {
const receiver = thisArg == null ? globalThis : Object(thisArg)
const key = Symbol('temporary-call')
Object.defineProperty(receiver, key, {
configurable: true,
value: fn,
})
try {
return receiver[key](...args)
} finally {
delete receiver[key]
}
}
不要修改 Function.prototype.call 或 Function.prototype.apply 来做练习;这会破坏运行时和其他代码。
第二十一篇:谈谈你对 JS 中 this 的理解
判断普通函数的 this,可以按下面顺序检查:
- 是否通过
new调用? - 是否使用
call、apply或bind显式指定? - 是否是
obj.method()这种成员调用? - 是否是独立调用?区分严格模式、ES module、class 和 classic script。
- 是否是箭头函数?如果是,查定义位置的外层
this。 - DOM 事件中还要区分
event.target和event.currentTarget。
const object = {
value: 1,
method() {
return this.value
},
}
const method = object.method
console.log(object.method()) // 1
// method():严格模式下 this 为 undefined
“new > call/apply/bind > 对象方法 > 默认绑定”是普通函数的速记,不覆盖箭头函数、super、代理和不同宿主的回调规则。
第二十二篇:JS 中浅拷贝的手段有哪些?
直接赋值不会复制对象:
const arr = [1, 2, 3]
const same = arr
same[0] = 100
console.log(arr) // [100, 2, 3]
浅拷贝只复制一层属性值;嵌套对象仍共享引用:
const arr = [1, 2, { value: 4 }]
const copy = arr.slice()
copy[0] = 100
copy[2].value = 1000
console.log(arr[0]) // 1
console.log(arr[2].value) // 1000
1. 手动实现有限的浅拷贝
function shallowClone(value) {
if (value === null || typeof value !== 'object') return value
const clone = Array.isArray(value) ? [] : {}
for (const key of Reflect.ownKeys(value)) {
if (Object.hasOwn(value, key)) {
clone[key] = value[key]
}
}
return clone
}
这个版本只复制可直接赋值的自有键,不保留原型、属性描述符和访问器语义。若需要保留原型和描述符,应使用 Object.getOwnPropertyDescriptors 配合 Object.create。
2. Object.assign
const object = { name: 'sy', age: 18 }
const copy = Object.assign({}, object, { name: 'sss' })
console.log(copy) // { name: 'sss', age: 18 }
Object.assign 只复制源对象的自有可枚举字符串和 Symbol 键;读取源 getter,写入目标时可能触发目标 setter。它是浅复制。
3. 数组 concat、slice 和展开
const array = [1, 2, 3]
const a = array.concat()
const b = array.slice()
const c = [...array]
console.log(a, b, c)
对象展开和 Object.assign 也都是浅复制:
const source = { nested: { value: 1 } }
const copy = { ...source }
copy.nested.value = 2
console.log(source.nested.value) // 2
第二十三篇:能不能写一个完整的深拷贝?
“完整深拷贝”不是一个没有边界的通用概念。函数闭包、DOM 节点、WeakMap、带私有字段的实例、宿主对象和外部资源都不能简单复制。
1. JSON.parse(JSON.stringify(value)) 的问题
const source = {
date: new Date(),
value: undefined,
nan: NaN,
nested: { value: 1 },
}
const copy = JSON.parse(JSON.stringify(source))
JSON 方法会丢失或改变 undefined、函数、Symbol、NaN、Infinity、Date、Map、Set、循环引用和某些属性信息;它不适合被宣传成通用深拷贝。
2. 现代首选:structuredClone
对可结构化克隆的数据,优先考虑标准 API:
const source = {
date: new Date(),
map: new Map([['key', { value: 1 }]]),
}
source.self = source
const copy = structuredClone(source)
console.log(copy !== source) // true
console.log(copy.self === copy) // true
structuredClone 能处理循环引用、Map、Set、Date、RegExp、TypedArray 等一组标准类型,但不能克隆函数、WeakMap、DOM 节点等所有值;具体可克隆边界应查看目标运行时文档。
3. 教学版:支持常见对象和循环引用
下面的实现支持原始值、数组、普通对象、Date、RegExp、Map、Set、循环引用、Symbol 键和属性描述符。它仍然不是通用 clone:函数按原引用返回,TypedArray、Error 内部槽、DOM、WeakMap 和带私有字段的实例需要额外策略。
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value
}
if (seen.has(value)) {
return seen.get(value)
}
if (value instanceof Date) {
return new Date(value.getTime())
}
if (value instanceof RegExp) {
return new RegExp(value.source, value.flags)
}
if (value instanceof Map) {
const result = new Map()
seen.set(value, result)
for (const [key, item] of value) {
result.set(deepClone(key, seen), deepClone(item, seen))
}
return result
}
if (value instanceof Set) {
const result = new Set()
seen.set(value, result)
for (const item of value) {
result.add(deepClone(item, seen))
}
return result
}
const result = Array.isArray(value)
? []
: Object.create(Object.getPrototypeOf(value))
seen.set(value, result)
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key)
if ('value' in descriptor) {
descriptor.value = deepClone(descriptor.value, seen)
}
Object.defineProperty(result, key, descriptor)
}
return result
}
const source = { value: 1 }
source.self = source
const copy = deepClone(source)
console.log(copy !== source) // true
console.log(copy.self === copy) // true
原文中把函数转成字符串,再用正则提取参数和函数体并 new Function,会丢失闭包、作用域、私有状态、名称和原型,还存在代码注入风险,因此删除这种“复制函数”的实现。函数通常应保持原引用,或由业务明确设计可序列化的行为描述。
4. 为什么需要 WeakMap?
WeakMap 在这里主要用于记录“原对象 → 克隆对象”的映射,解决循环引用和重复引用关系:
const shared = { value: 1 }
const source = { first: shared, second: shared }
const copy = deepClone(source)
console.log(copy.first === copy.second) // true
WeakMap 的弱键特性还避免了“记录表本身长期强引用源对象”的额外保留,但在 deepClone 调用结束后普通局部 Map 通常也会随调用结束而可回收。不能把使用 Map 绝对描述成必然内存泄漏,关键是 Map 是否被长期保存。
小结
- 数组方法常常是泛型的,但完整规范包含长度、稀疏槽、属性描述符、代理和异常边界。
sort的算法由引擎决定,当前标准要求稳定但不规定 TimSort 或快速排序。- 手写
new、bind、call/apply适合学习,不能替代原生方法。 - 浅拷贝只复制一层;深拷贝要先明确数据类型和业务边界。
- 可结构化数据优先使用
structuredClone;函数、DOM 和资源对象需要单独设计复制策略。
配图说明:图示按原文顺序从 article.rivers.pub 的历史文章图片地址下载到本地 images/87-image-*,图片并非规范或引擎官方图,公开发布前仍需核实原始授权。
参考资料: