热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

深切明白JavaScriptErrors和StackTraces

译者注:本文作者是有名JavaScriptBDD测试框架Chai.js源码贡献者之一,Chai.js中会碰到许多异常处置惩罚的状况。追随作者思绪,从JavaScript基础的Err

《深切明白 Javascript Errors 和 Stack Traces》

译者注:本文作者是有名 Javascript BDD 测试框架 Chai.js 源码贡献者之一,Chai.js 中会碰到许多异常处置惩罚的状况。追随作者思绪,从 Javascript 基础的 Errors 道理,到怎样实际应用 Stack Traces,深切进修和明白 Javascript Errors 和 Stack Traces。文章贴出的源码链接也异常值得进修。

作者:lucasfcosta

编译:胡子大哈

翻译原文:[http://huziketang.com/blog/po…
](http://huziketang.com/blog/po…

英文原文:Javascript Errors and Stack Traces in Depth

转载请说明出处,保存原文链接以及作者信息

良久没给人人更新关于 Javascript 的内容了,这篇文章我们来聊聊 Javascript 。

此次我们聊聊 Errors 和 Stack traces 以及怎样闇练地应用它们。

许多同砚并不注重这些细节,然则这些学问在你写 Testing 和 Error 相干的 lib 的时刻是异常有效的。应用 Stack traces 能够清算无用的数据,让你关注真正重要的题目。同时,你真正明白 Errors 和它们的属性究竟是什么的时刻,你将会更有自信心的应用它们。

这篇文章在最先的时刻看起来比较简朴,但当你闇练应用 Stack trace 今后则会觉得异常庞杂。所以在看难的章节之前,请确保你明白了前面的内容。

Stack是怎样事情的

在我们谈到 Errors 之前,我们必需明白 Stack 是怎样事情的。它实在异常简朴,然则在最先之前相识它也是异常必要的。假如你已晓得了这些,能够略过这一章节。

每当有一个函数挪用,就会将其压入栈顶。在挪用终了的时刻再将其从栈顶移出。

这类风趣的数据结构叫做“末了一个进入的,将会第一个出去”。这就是广为所知的 LIFO(后进先出)。

举个例子,在函数 x 的内部挪用了函数 y,这时候栈中就有个递次先 x 后 y。我再举别的一个例子,看下面代码:

function c() {
console.log('c');
}
function b() {
console.log('b');
c();
}
function a() {
console.log('a');
b();
}
a();

上面的这段代码,当运转 a 的时刻,它会被压到栈顶。然后,当 b 在 a 中被挪用的时刻,它会被继承压入栈顶,当 c 在 b 中被挪用的时刻,也一样。

在运转 c 的时刻,栈中包括了 a,b,c,而且其递次也是 a,b,c。

当 c 挪用终了时,它会被从栈顶移出,随后控制流回到 b。当 b 实行终了后也会从栈顶移出,控制流交还到 a。末了,当 a 实行终了后也会从栈中移出。

为了更好的展现如许一种行动,我们用console.trace()来将 Stack trace 打印到控制台上来。一般我们读 Stack traces 信息的时刻是从上往下读的。

function c() {
console.log('c');
console.trace();
}
function b() {
console.log('b');
c();
}
function a() {
console.log('a');
b();
}
a();

当我们在Node REPL服务端实行的时刻,会返回以下:

Trace
at c (repl:3:9)
at b (repl:3:1)
at a (repl:3:1)
at repl:1:1 // <-- For now feel free to ignore anything below this point, these are Node's internals
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:313:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)

从上面我们能够看到,当栈信息从 c 中打印出来的时刻,我看到了 a,b 和 c。如今,假如在 c 实行终了今后,在 b 中把 Stack trace 打印出来,我们能够看到 c 已从栈中移出了,栈中只要 a 和 b。

function c() {
console.log('c');
}
function b() {
console.log('b');
c();
console.trace();
}
function a() {
console.log('a');
b();
}
a();

下面能够看到,c 已不在栈中了,在其实行完今后,从栈中 pop 出去了。

Trace
at b (repl:4:9)
at a (repl:3:1)
at repl:1:1 // <-- For now feel free to ignore anything below this point, these are Node's internals
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:313:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:513:10)

归纳综合一下:当挪用时,压入栈顶。当它实行终了时,被弹出栈,就是这么简朴。

Error 对象和 Error 处置惩罚

Error发作的时刻,一般会抛出一个Error对象。Error对象也能够被看作一个Error原型,用户能够扩大其寄义,以建立自身的 Error 对象。

Error.prototype对象一般包括下面属性:

  • constructor &#8211; 一个毛病实例原型的组织函数

  • message &#8211; 毛病信息

  • name &#8211; 毛病称号

这几个都是规范属性,偶然差别编译的环境会有其奇特的属性。在一些环境中,比方 Node 和 Firefox,以至另有stack属性,这内里包括了毛病的 Stack trace。一个Error的客栈追踪包括了从其组织函数最先的一切客栈帧

假如你想要进修一个Error对象的特别属性,我强烈建议你看一下在MDN上的这篇文章。

要抛出一个Error,你必需应用throw关键字。为了catch一个抛出的Error,你必需把能够抛出Error的代码用try块包起来。然后紧随着一个catch块,catch块中一般会接收一个包括了毛病信息的参数。

和在 Java 中相似,不管在try中是不是抛出Error, Javascript 中都许可你在try/catch块背面紧随着一个finally块。不管你在try中的操纵是不是见效,在你操纵完今后,都用finally来清算对象,这是个编程的好习惯。

引见到如今的学问,能够关于大部份人来讲,都是已控制了的,那末如今我们就举行更深切一些的吧。

应用try块时,背面能够不随着catch块,然则必需随着finally块。所以我们就有三种差别情势的try语句:

  • try...catch

  • try...finally

  • try...catch...finally

Try语句也能够内嵌在一个try语句中,如:

try {
try {
// 这里抛出的Error,将被下面的catch获取到
throw new Error('Nested error.');
} catch (nestedErr) {
// 这里会打印出来
console.log('Nested catch');
}
} catch (err) {
console.log('This will not run.');
}

你也能够把try语句内嵌在catchfinally块中:

try {
throw new Error('First error');
} catch (err) {
console.log('First catch running');
try {
throw new Error('Second error');
} catch (nestedErr) {
console.log('Second catch running.');
}
}

    • *

    try {

    console.log('The try block is running...');

    } finally {

    try {
    throw new Error('Error inside finally.');
    } catch (err) {
    console.log('Caught an error inside the finally block.');
    }

    }

这里给出别的一个重要的提醒:你能够抛出非Error对象的值。只管这看起来很炫酷,很天真,但实际上这个用法并不好,尤其在一个开发者改另一个开发者写的库的时刻。由于如许代码没有一个规范,你不晓得其他人会抛出什么信息。如许的话,你就不能简朴的置信抛出的Error信息了,由于有能够它并非Error信息,而是一个字符串或许一个数字。别的这也致使了假如你须要处置惩罚 Stack trace 或许其他有意义的元数据,也将变的很难题。

比方给你下面这段代码:

function runWithoutThrowing(func) {
try {
func();
} catch (e) {
console.log('There was an error, but I will not throw it.');
console.log('The error\'s message was: ' + e.message)
}
}
function funcThatThrowsError() {
throw new TypeError('I am a TypeError.');
}
runWithoutThrowing(funcThatThrowsError);

这段代码,假如其他人通报一个带有抛出Error对象的函数给runWithoutThrowing函数的话,将圆满运转。但是,假如他抛出一个String范例的话,则状况就麻烦了。

function runWithoutThrowing(func) {
try {
func();
} catch (e) {
console.log('There was an error, but I will not throw it.');
console.log('The error\'s message was: ' + e.message)
}
}
function funcThatThrowsString() {
throw 'I am a String.';
}
runWithoutThrowing(funcThatThrowsString);

能够看到这段代码中,第二个console.log会通知你这个 Error 信息是undefined。这如今看起来不是很重要,然则假如你须要肯定是不是这个Error中确切包括某个属性,或许用另一种体式格局处置惩罚Error的特别属性,那你就须要多花许多的工夫了。

别的,当抛出一个非Error对象的值时,你没有接见Error对象的一些重要的数据,比方它的客栈,而这在一些编译环境中是一个异常重要的Error对象属性。

Error 还能够当作其他一般对象一样应用,你并不须要抛出它。这就是为何它一般作为回调函数的第一个参数,就像fs.readdir函数如许:


const fs = require('fs');
fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {
if (err instanceof Error) {
// 'readdir'将会抛出一个异常,由于目次不存在
// 我们能够在我们的回调函数中应用 Error 对象
console.log('Error Message: ' + err.message);
console.log('See? We can use Errors without using try statements.');
} else {
console.log(dirs);
}
});

末了,你也能够在 promise 被 reject 的时刻应用Error对象,这使得处置惩罚 promise reject 变得很简朴。

new Promise(function(resolve, reject) {
reject(new Error('The promise was rejected.'));
}).then(function() {
console.log('I am an error.');
}).catch(function(err) {
if (err instanceof Error) {
console.log('The promise was rejected with an error.');
console.log('Error Message: ' + err.message);
}
});

应用 Stack Trace

ok,那末如今,你们所期待的部份来了:怎样应用客栈追踪。

这一章特地议论支撑 Error.captureStackTrace 的环境,如:NodeJS。

Error.captureStackTrace函数的第一个参数是一个object对象,第二个参数是一个可选的function。捕捉客栈跟踪所做的是要捕捉当前客栈的途径(这是不言而喻的),而且在 object 对象上建立一个stack属性来存储它。假如供应了第二个 function 参数,那末这个被通报的函数将会被看成是本次客栈挪用的尽头,本次客栈跟踪只会展现到这个函数被挪用之前。

我们来用几个例子来更清楚的诠释下。我们将捕捉当前客栈途径而且将其存储到一个一般 object 对象中。


const myObj = {};
function c() {
}
function b() {
// 这里存储当前的客栈途径,保存到myObj中
Error.captureStackTrace(myObj);
c();
}
function a() {
b();
}
// 起首挪用这些函数
a();
// 这里,我们看一下客栈途径往 myObj.stack 中存储了什么
console.log(myObj.stack);
// 这里将会打印以下客栈信息到控制台
// at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack
// at a (repl:2:1)
// at repl:1:1 <-- Node internals below this line
// at realRunInThisContextScript (vm.js:22:35)
// at sigintHandlersWrap (vm.js:98:12)
// at ContextifyScript.Script.runInThisContext (vm.js:24:12)
// at REPLServer.defaultEval (repl.js:313:29)
// at bound (domain.js:280:14)
// at REPLServer.runBound [as eval] (domain.js:293:12)
// at REPLServer.onLine (repl.js:513:10)

我们从上面的例子中能够看到,我们起首挪用了a(a被压入栈),然后从a的内部挪用了b(b被压入栈,而且在a的上面)。在b中,我们捕捉到了当前客栈途径而且将其存储在了myObj中。这就是为何打印在控制台上的只要ab,而且是下面a上面b

好的,那末如今,我们通报第二个参数到Error.captureStackTrace看看会发作什么?


const myObj = {};
function d() {
// 这里存储当前的客栈途径,保存到myObj中
// 此次我们隐蔽包括b在内的b今后的一切客栈帧
Error.captureStackTrace(myObj, b);
}
function c() {
d();
}
function b() {
c();
}
function a() {
b();
}
// 起首挪用这些函数
a();
// 这里,我们看一下客栈途径往 myObj.stack 中存储了什么
console.log(myObj.stack);
// 这里将会打印以下客栈信息到控制台
// at a (repl:2:1) <-- As you can see here we only get frames before `b` was called
// at repl:1:1 <-- Node internals below this line
// at realRunInThisContextScript (vm.js:22:35)
// at sigintHandlersWrap (vm.js:98:12)
// at ContextifyScript.Script.runInThisContext (vm.js:24:12)
// at REPLServer.defaultEval (repl.js:313:29)
// at bound (domain.js:280:14)
// at REPLServer.runBound [as eval] (domain.js:293:12)
// at REPLServer.onLine (repl.js:513:10)
// at emitOne (events.js:101:20)

当我们通报bError.captureStackTraceFunction里时,它隐蔽了b和在它以上的一切客栈帧。这就是为何客栈途径里只要a的缘由。

看到这,你能够会问如许一个题目:“为何这是有效的呢?”。它之所以有效,是由于你能够隐蔽一切的内部完成细节,而这些细节其他开发者挪用的时刻并不须要晓得。比方,在 Chai 中,我们用这类要领对我们代码的挪用者屏障了不相干的完成细节。

实在场景中的 Stack Trace 处置惩罚

正如我在上一节中提到的,Chai 用栈处置惩罚手艺使得客栈途径和挪用者越发相干,这里是我们怎样完成它的。

起首,让我们来看一下当一个 Assertion 失利的时刻,AssertionError的组织函数做了什么。


// 'ssfi'代表"肇端客栈函数",它是移除其他不相干客栈帧的肇端标记
function AssertionError (message, _props, ssf) {
var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON')
, props = extend(_props || {});
// 默认值
this.message = message || 'Unspecified AssertionError';
this.showDiff = false;
// 从属性中copy
for (var key in props) {
this[key] = props[key];
}
// 这里是和我们相干的
// 假如供应了肇端客栈函数,那末我们从当前客栈途径中获取到,
// 而且将其通报给'captureStackTrace',以保证移除厥后的一切帧
ssf = ssf || arguments.callee;
if (ssf && Error.captureStackTrace) {
Error.captureStackTrace(this, ssf);
} else {
// 假如没有供应肇端客栈函数,那末应用原始客栈
try {
throw new Error();
} catch(e) {
this.stack = e.stack;
}
}
}

正如你在上面能够看到的,我们应用了Error.captureStackTrace来捕捉客栈途径,而且把它存储在我们所建立的一个AssertionError实例中。然后通报了一个肇端客栈函数进去(用if推断假如存在则通报),如许就从客栈途径中移除掉了不相干的客栈帧,不显现一些内部完成细节,保证了客栈信息的“洁净”。

感兴趣的读者能够继承看一下近来 @meeber 在 这里 的代码。

在我们继承看下面的代码之前,我要先通知你addChainableMethod都做了什么。它增加所通报的能够被链式挪用的要领到 Assertion,而且用包括了 Assertion 的要领标记 Assertion 自身。用ssfi(示意肇端客栈函数指示器)这个名字纪录。这意味着当前 Assertion 就是客栈的末了一帧,就是说不会再多显现任何 Chai 项目中的内部完成细节了。我在这里就不多列出来其全部代码了,内里用了许多 trick 的要领,然则假如你想相识更多,能够从 这个链接 里获取到。

鄙人面的代码中,展现了lengthOf的 Assertion 的逻辑,它是用来搜检一个对象的肯定长度的。我们愿望挪用我们函数的开发者如许来应用:expect(['foo', 'bar']).to.have.lengthOf(2)


function assertLength (n, msg) {
if (msg) flag(this, 'message', msg);
var obj = flag(this, 'object')
, ssfi = flag(this, 'ssfi');
// 亲昵关注这一行
new Assertion(obj, msg, ssfi, true).to.have.property('length');
var len = obj.length;
// 这一行也是相干的
this.assert(
len == n
, 'expected #{this} to have a length of #{exp} but got #{act}'
, 'expected #{this} to not have a length of #{act}'
, n
, len
);
}
Assertion.addChainableMethod('lengthOf', assertLength, assertLengthChain);

在代码中,我偏重对跟我们相干的代码举行了解释,我们从this.assert的挪用最先。

下面是this.assert要领的代码:


Assertion.prototype.assert = function (expr, msg, negateMsg, expected, _actual, showDiff) {
var ok = util.test(this, arguments);
if (false !== showDiff) showDiff = true;
if (undefined === expected && undefined === _actual) showDiff = false;
if (true !== config.showDiff) showDiff = false;
if (!ok) {
msg = util.getMessage(this, arguments);
var actual = util.getActual(this, arguments);
// 这是和我们相干的行
throw new AssertionError(msg, {
actual: actual
, expected: expected
, showDiff: showDiff
}, (config.includeStack) ? this.assert : flag(this, 'ssfi'));
}
};

assert要领重要用来搜检 Assertion 的布尔表达式是真照样假。假如是假,则我们必需实例化一个AssertionError。这里注重,当我们实例化一个AssertionError对象的时刻,我们也通报了一个肇端客栈函数指示器(ssfi)。假如设置标记includeStack是翻开的,我们经由过程通报一个this.assert给挪用者,以向他展现全部客栈途径。但是,假如includeStack设置是封闭的,我们则必需从客栈途径中隐蔽内部完成细节,这就须要用到存储在ssfi中的标记了。

ok,那末我们再来议论一下其他和我们相干的代码:

new Assertion(obj, msg, ssfi, true).to.have.property('length');

能够看到,当建立这个内嵌 Assertion 的时刻,我们通报了ssfi中已获取到的内容。这意味着,当建立一个新的 Assertion 时,将应用这个函数来作为从客栈途径中移除无用客栈帧的肇端点。趁便说一下,下面这段代码是Assertion的组织函数。


function Assertion (obj, msg, ssfi, lockSsfi) {
// 这是和我们相干的行
flag(this, 'ssfi', ssfi || Assertion);
flag(this, 'lockSsfi', lockSsfi);
flag(this, 'object', obj);
flag(this, 'message', msg);
return util.proxify(this);
}

还记得我在报告addChainableMethod时说的,它用包括他自身的要领设置的ssfi标记,这就意味着这是客栈途径中最底层的内部帧,我们能够移除在它之上的一切帧。

追念上面的代码,内嵌 Assertion 用来推断对象是不是是有适宜的长度(Length)。通报ssfi到这个 Assertion 中,要防止重置我们要将其作为肇端指示器的客栈帧,而且使先前的addChainableMethod在客栈中坚持可见状况。

这看起来能够有点庞杂,如今我们从新回忆一下,我们想要移除没有效的客栈帧都做了什么事情:

  1. 当我们运转一个 Assertion 时,我们设置它自身来作为我们移除其背面客栈帧的标记。

  2. 这个 Assertion 最先实行,假如推断失利,那末从适才我们所存储的谁人标记最先,移除其背面一切的内部帧。

  3. 假如有内嵌 Assertion,那末我们必须要应用包括当前 Assertion 的要领作为移除背面客栈帧的标记,即放到ssfi中。因而我们要通报当前ssfi(肇端客栈函数指示器)到我们即将要新建立的内嵌 Assertion 中来存储起来。

末了我照样强烈建议来浏览一下 @meeber的批评 来加深对它的明白。

我近来正在写一本《React.js 小书》,对 React.js 感兴趣的童鞋,迎接指导。


推荐阅读
  • 本文介绍了在wepy中运用小顺序页面受权的计划,包含了用户点击作废后的从新受权计划。 ... [详细]
  • 浏览器中的异常检测算法及其在深度学习中的应用
    本文介绍了在浏览器中进行异常检测的算法,包括统计学方法和机器学习方法,并探讨了异常检测在深度学习中的应用。异常检测在金融领域的信用卡欺诈、企业安全领域的非法入侵、IT运维中的设备维护时间点预测等方面具有广泛的应用。通过使用TensorFlow.js进行异常检测,可以实现对单变量和多变量异常的检测。统计学方法通过估计数据的分布概率来计算数据点的异常概率,而机器学习方法则通过训练数据来建立异常检测模型。 ... [详细]
  • 《数据结构》学习笔记3——串匹配算法性能评估
    本文主要讨论串匹配算法的性能评估,包括模式匹配、字符种类数量、算法复杂度等内容。通过借助C++中的头文件和库,可以实现对串的匹配操作。其中蛮力算法的复杂度为O(m*n),通过随机取出长度为m的子串作为模式P,在文本T中进行匹配,统计平均复杂度。对于成功和失败的匹配分别进行测试,分析其平均复杂度。详情请参考相关学习资源。 ... [详细]
  • 本文分享了一个关于在C#中使用异步代码的问题,作者在控制台中运行时代码正常工作,但在Windows窗体中却无法正常工作。作者尝试搜索局域网上的主机,但在窗体中计数器没有减少。文章提供了相关的代码和解决思路。 ... [详细]
  • android listview OnItemClickListener失效原因
    最近在做listview时发现OnItemClickListener失效的问题,经过查找发现是因为button的原因。不仅listitem中存在button会影响OnItemClickListener事件的失效,还会导致单击后listview每个item的背景改变,使得item中的所有有关焦点的事件都失效。本文给出了一个范例来说明这种情况,并提供了解决方法。 ... [详细]
  • 本文介绍了C#中生成随机数的三种方法,并分析了其中存在的问题。首先介绍了使用Random类生成随机数的默认方法,但在高并发情况下可能会出现重复的情况。接着通过循环生成了一系列随机数,进一步突显了这个问题。文章指出,随机数生成在任何编程语言中都是必备的功能,但Random类生成的随机数并不可靠。最后,提出了需要寻找其他可靠的随机数生成方法的建议。 ... [详细]
  • 本文介绍了C#中数据集DataSet对象的使用及相关方法详解,包括DataSet对象的概述、与数据关系对象的互联、Rows集合和Columns集合的组成,以及DataSet对象常用的方法之一——Merge方法的使用。通过本文的阅读,读者可以了解到DataSet对象在C#中的重要性和使用方法。 ... [详细]
  • 本文讨论了一个关于cuowu类的问题,作者在使用cuowu类时遇到了错误提示和使用AdjustmentListener的问题。文章提供了16个解决方案,并给出了两个可能导致错误的原因。 ... [详细]
  • Voicewo在线语音识别转换jQuery插件的特点和示例
    本文介绍了一款名为Voicewo的在线语音识别转换jQuery插件,该插件具有快速、架构、风格、扩展和兼容等特点,适合在互联网应用中使用。同时还提供了一个快速示例供开发人员参考。 ... [详细]
  • 本文介绍了一个在线急等问题解决方法,即如何统计数据库中某个字段下的所有数据,并将结果显示在文本框里。作者提到了自己是一个菜鸟,希望能够得到帮助。作者使用的是ACCESS数据库,并且给出了一个例子,希望得到的结果是560。作者还提到自己已经尝试了使用"select sum(字段2) from 表名"的语句,得到的结果是650,但不知道如何得到560。希望能够得到解决方案。 ... [详细]
  • 海马s5近光灯能否直接更换为H7?
    本文主要介绍了海马s5车型的近光灯是否可以直接更换为H7灯泡,并提供了完整的教程下载地址。此外,还详细讲解了DSP功能函数中的数据拷贝、数据填充和浮点数转换为定点数的相关内容。 ... [详细]
  • 模板引擎StringTemplate的使用方法和特点
    本文介绍了模板引擎StringTemplate的使用方法和特点,包括强制Model和View的分离、Lazy-Evaluation、Recursive enable等。同时,还介绍了StringTemplate语法中的属性和普通字符的使用方法,并提供了向模板填充属性的示例代码。 ... [详细]
  • 本文记录了在vue cli 3.x中移除console的一些采坑经验,通过使用uglifyjs-webpack-plugin插件,在vue.config.js中进行相关配置,包括设置minimizer、UglifyJsPlugin和compress等参数,最终成功移除了console。同时,还包括了一些可能出现的报错情况和解决方法。 ... [详细]
  • 本文讨论了编写可保护的代码的重要性,包括提高代码的可读性、可调试性和直观性。同时介绍了优化代码的方法,如代码格式化、解释函数和提炼函数等。还提到了一些常见的坏代码味道,如不规范的命名、重复代码、过长的函数和参数列表等。最后,介绍了如何处理数据泥团和进行函数重构,以提高代码质量和可维护性。 ... [详细]
  • 本文介绍了H5游戏性能优化和调试技巧,包括从问题表象出发进行优化、排除外部问题导致的卡顿、帧率设定、减少drawcall的方法、UI优化和图集渲染等八个理念。对于游戏程序员来说,解决游戏性能问题是一个关键的任务,本文提供了一些有用的参考价值。摘要长度为183字。 ... [详细]
author-avatar
lily-SweetDream_828
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有