JavaScript面试的完美指南(开发者视角)
摘要: 面试季手册。
Fundebug经授权转载,版权归原作者所有。
为了说明 JS 面试的复杂性,首先,请尝试给出以下结果:
onsole.log(2.0 == “2” == new Boolean(true) == “1”)
十有八九的会给出false, 其实运行结果是true,原因请看 这里。
1) 理解 JS 函数
函数是 JavaScript 的精华,是 JS 一等公民。JS 函数不仅仅是一个普通的函数,与其他语言不同,JS 函数可以赋值给变量,作为参数传递给另一个函数,也可以从另一个函数返回。
console.log(square(5));
/* ... */
function square(n) { return n * n; }
以为代码很简单,大家应该都知道会打印:25
。接着看一个:
console.log(square(5));
var square = function(n) {
return n * n;
}
乍一看,你可能会忍不住说也打印了 25
。但很不幸,会报错:
TypeError: square is not a function
在 JavaScript 中,如果将函数定义为变量,变量名将被提升,是 JS 执行到它的定义才能被访问。
你可能在一些代码中频繁的见到如下代码。
var simpleLibrary = function() {
var simpleLibrary = {
a,
b,
add: function(a, b) {
return a + b;
},
subtract: function(a, b) {
return a - b;
}
}
return simpleLibrary;
}();
为什么会做这种奇怪的事情? 这是因为一个函数变量中变量和函数被分装,可以避免全局变量污染。 JQuery 到Lodash 的库采用这种技术提供 $
、_
等
2) 理解 bind、apply 和 call
你可能在所有常用库中看到过这三个函数。它们允许局部套用, 我们可以把功能组合到不同的函数。一个优秀的js开发者可以随时告诉你关于这三个函数。
基本上,这些是改变行为以实现某些功能的原型方法,根据 JS 开发人员 Chad 的说法,用法如下:
希望使用某个上下文调用该函数,请使用 .bind()
,这在事件中很有用。 如果要立即调用函数,请使用.call()
或 .apply()
,并修改上下文。
举例说明
让我们看看上面的陈述是什么意思! 假设你的数学老师要求你创建一个库并提交。你写了一个抽象的库,它可以求出圆的面积和周长:
var mathLib = {
pi: 3.14,
area: function(r) {
return this.pi * r * r;
},
circumference: function(r) {
return 2 * this.pi * r;
}
};
提交后,老师调用了它:
mathLib.area(2);
12.56
老师发现他给你要求是 pi
精确到小数点后 5
位数而你只精确到 2
位, 现在由于最后期限已过你没有机会提交库。 这里 JS的 call
函数可以帮你, 只需要调用你的代码如下:
mathLib.area.call({pi: 3.1.159}, 2)
它会动态地获取新的 pi
值,结果如下:
12.56636
这时,注意到 call
函数具有两个参数:
- Context
- 函数参数
在 area
函数中, 上下文是对象被关键词 this
代替,后面的参数作为函数参数被传递。 如下:
var cylinder = {
pi: 3.14,
volume: function(r, h) {
return this.pi * r * r * h;
}
};
调用方式如下:
cylinder.volume.call({pi: 3.14159}, 2, 6);
75.39815999999999
Apply 类似,只是函数参数作为数组传递。
cylinder.volume.apply({pi: 3.14159}, [2, 6]);
75.39815999999999
如果你会使用 call 你基本就会用 apply 了,反之亦然, 那 bind 的用法又是如何呢 ?
bind 将一个全新的 this 注入到指定的函数上,改变 this 的指向, 使用 bind 时,函数不会像 call
或 apply
立即执行。
var newVolume = cylinder.volume.bind({pi: 3.14159});
newVolume(2,6); // Now pi is 3.14159
bind 用途是什么?它允许我们将上下文注入一个函数,该函数返回一个具有更新上下文的新函数。这意味着这个变量将是用户提供的变量,这在处理 JavaScript 事件时非常有用。
3) 理解 js 作用域(闭包)
JavaScript 的作用域是一个潘多拉盒子。从这一个简单的概念中,就可以构造出数百个难回答的面试问题。有三种作用域:
- 全局作用域
- 本地/函数作用域
- 块级作用域(ES6引进)
全局作用域事例如下:
x = 10;
function Foo() {
console.log(x); // Prints 10
}
Foo()
函数作用域生效当你定义一个局部变量时:
pi = 3.14;
function circumference(radius) {
pi = 3.14159;
console.log(2 * pi * radius); // 打印 "12.56636" 不是 "12.56"
}
circumference(2);
ES16 标准引入了新的块作用域,它将变量的作用域限制为给定的括号块。
var a = 10;
function Foo() {
if (true) {
let a = 4;
}
alert(a); // alerts '10' because the 'let' keyword
}
Foo();
函数和条件都被视为块。以上例子应该弹出 4
,因为 if
已执行。但 是ES6 销毁了块级变量的作用域,作用域进入全局。
现在来到神奇的作用域,可以使用闭包来实现,JavaScript 闭包是一个返回另一个函数的函数。
如果有人问你这个问题,编写一个输入一个字符串并逐次返回字符。 如果给出了新字符串,则应该替换旧字符串,类似简单的一个生成器。
function generator(input) {
var index = 0;
return {
next: function() {
if (index < input.lenght) {
return input[index -1];
}
return "";
}
}
}
执行如下:
var mygenerator = generator("boomerang");
mygenerator.next(); // returns "b"
mygenerator.next() // returns "o"
mygenerator = generator("toon");
mygenerator.next(); // returns "t"
在这里,作用域扮演着重要的角色。闭包是返回另一个函数并携带数据的函数。上面的字符串生成器适用于闭包。index 在多个函数调用之间保留,定义的内部函数可以访问在父函数中定义的变量。这是一个不同的作用域。如果在第二级函数中再定义一个函数,它可以访问所有父级变量。
4) this (全局域、函数域、对象域)
在 JavaScript 中,我们总是用函数和对象编写代码, 如果使用浏览器,则在全局上下文中它引用 window 对象。 我的意思是,如果你现在打开浏览器控制台并输入以下代码,输出结果为 true。
this === window;
当程序的上下文和作用域发生变化时,this 也会发生相应的变化。现在观察 this 在一个局部上下文中:
function Foo(){
console.log(this.a);
}
var food = {a: "Magical this"};
Foo.call(food); // food is this
思考一下,以下输出的是什么:
function Foo(){
console.log(this); // 打印 {}?
}
因为这是一个全局对象,记住,无论父作用域是什么,它都将由子作用域继承。打印出来是 window 对象。上面讨论的三个方法实际上用于设置这个对象。
现在,this 的最后一个类型,在对象中的 this, 如下:
var person = {
name: "Stranger",
age: 24,
get identity() {
return {who: this.name, howOld: this.age};
}
}
上述使用了 getter 语法,这是一个可以作为变量调用的函数。
person.identity; // returns {who: "Stranger", howOld: 24}
此时,this 实际上是指对象本身。正如我们前面提到的,它在不同的地方有不同的表现。
5) 理解对象 (Object.freeze, Object.seal)
通常对象的格式如下:
var marks = {physics: 98, maths:95, chemistry: 91};
它是一个存储键、值对的映射。 javascript 对象有一个特殊的属性,可以将任何东西存储为一个值。这意味着我们可以将一个列表、另一个对象、一个函数等存储为一个值。
可以用如下方式来创建对象:
var marks = {};
var marks = new Object();
可以使用 JSON.stringify() 将一个对象转制成字符串,也可以用 JSON.parse 在将其转成对象。
// returns "{"physics":98,"maths":95,"chemistry":91}"
JSON.stringify(marks);
// Get object from string
JSON.parse('{"physics":98,"maths":95,"chemistry":91}');
使用 Object.keys 迭代对象:
var highScere = 0;
for (i of Object.keys(marks)) {
if (marks[i] > highScore)
highScore = marks[i];
}
Object.values 以数组的方式返回对象的值。
对象上的其他重要函数有:
- Object.prototype(object)
- Object.freeze(function)
- Object.seal(function)
Object.prototype 上提供了许多应用上相关的函数,如下:
Object.prototype.hasOwnProperty 用于检查给定的属性/键是否存在于对象中。
marks.hasOwnProperty("physics"); // returns true
marks.hasOwnProperty("greek"); // returns false
Object.prototype.instanceof 判断给定对象是否是特定原型的类型。
function Car(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
}
var newCar = new Car('Honda', 'City', 2007);
console.log(newCar instanceof Car); // returns true
使用 Object.freeze 可以冻结对象,以便不能修改对象现有属性。
var marks = {physics: 98, maths:95, chemistry: 91};
finalizedMarks = Object.freeze(marks);
finalizedMarks["physics"] = 86; // throws error in strict mode
console.log(marks); // {physics: 98, maths: 95, chemistry: 91}
在这里,试图修改冻结后的 physics
的值,但 JavaScript不允许这样做。我们可以使用 Object.isFrozen 来判断,给定对象是否被冻结:
Object.isFrozen(finalizedMarks); // returns true
Object.seal 与 Object.freeze 略有不同。 Object.seal() 方法封闭一个对象,阻止添加新属性并将所有现有属性标记为不可配置。当前属性的值只要可写就可以改变。
var marks = {physics: 98, maths:95, chemistry: 91};
Object.seal(marks);
delete marks.chemistry; // returns false as operation failed
marks.physics = 95; // Works!
marks.greek = 86; // Will not add a new property
同样, 可以使用 Object.isSealed 判断对象是否被密封。
Object.isSealed(marks); // returns true
在全局对象函数上还有许多其他重要的函数/方法,在这里找到他们。
代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug。
6) 理解原型继承
在传统 JavaScript 中,有一种伪装的继承概念,它是通过使用原型技术来实现的。在ES5、ES6中看到使用 new 的语法只是底层原型OOP的语法糖。创建类是使用 JavaScript 中的函数完成的。
var animalGroups = {
MAMMAL: 1,
REPTILE: 2,
AMPHIBIAN: 3,
INVERTEBRATE: 4
};
function Animal(name, type) {
this.name = name;
this.type = type;
}
var dog = new Animal("dog", animalGroups.MAMMAL);
var crocodile = new Animal("crocodile", animalGroups.REPTILE);
这里我们为类创建对象(使用 new 关键字),可以使用如下方式对类追加方法:
Animal.prototype.shout = function() {
console.log(this.name+'is'+this.sound+'ing...');
}
这里你可能会有疑问。类中并没 sound 属性。是的,它打算由继承了上述类的子类传递。
JavaScript中, 如下实现继承:
function Dog(name, type) {
Animal.call(this, name, type);
this.sound = 'bow';
}
我定义了一个更具体的函数,叫做 Dog。在这里,为了继承 Animal 类,我需要call传递this和其他参数。使用如下方式来实例化一只德国牧羊犬
。
var pet = Dog("德国牧羊犬", animalGroups.MAMMAL);
console.log(pet); // returns Dog {name: "德国牧羊犬", type: 1, sound: "bow"}
我们没有在子函数中分配 name
和 type
属性,我们调用的是超级函数 Animal 并设置相应的属性。pet 具有父类的属性(name、type)。但是方法呢。他们也继承的吗? 来看看:
pet.shout(); // Throws error
为什么会这样? 之所以发生这种情况,是因为没有指定让 JavaScript来继承父类方法。 如何解决?
// Link prototype chains
Dog.prototype = Object.create(Animal.prototype);
var pet = new Dog("germanShepard", animalGroups.MAMMAL);
// Now shout method is available
pet.shout(); // 德国牧羊犬 bowing...
现在可以使用 shout
方法。 我们可以使用 object.constructor 函数检查 JavaScript 中给定对象的类 来看看 pet 是什么类:
pet.constructor; // returns Animal
这是模糊的,Animal 是一个父类。但是 pet 到底是什么类型的呢? pet 应该是 Dog
的类型。之所以是 Animal 类型,是因为 Dog 类的构造函数:
Dog.prototype.constructor; // returns Animal
它是 Animal 类型的。我们应该将它设置为 Dog 本身,这样类的所有实例(对象)才能给出正确的类名。
Dog.prototype.constructor = Dog;
关于原型继承, 我们应该记住以下几条:
- 类属性使用
this
绑定 - 类方法使用
prototype
对象来绑定 - 为了继承属性, 使用
call
函数来传递this
- 为了继承方法, 使用 Object.create 连接父和子的原型
- 始终将子类构造函数设置为自身,以获得其对象的正确类型
7)理解 callback 和 promise
回调是在 I/O 操作完成后执行的函数。一个耗时的I/O操作会阻塞代码, 因此在Python/Ruby不被允许。但是在 JavaScript中,由于允许异步执行,我们可以提供对异步函数的回调。这个例子是由浏览器到服务器的AJAX(XMLHettpRequest)调用,由鼠标、键盘事件生成。如下:
function reqListener () {
console.log(this.responseText);
}
var req = new XMLHttpRequest();
req.addEventListener("load", reqListener);
req.open("GET", "http://www.example.org/example.txt");
req.send();
这里的 reqListener 是一个回调函数,当成功响应 GET 请求时将执行该回调函数。
Promise 是回调函数的优雅的封装, 使得我们优雅的实现异步代码。在以下给出的这篇文章中讨论了很多 promise,这也是在 JS 中应该知道的重要部分。
8)理解正则表达
正则表达式有许多应用地方,处理文本、对用户输入执行规则等。JavaScript 开发人员应该知道如何执行基本正则表达式并解决问题。Regex 是一个通用概念,来看看如何从 JS 中做到这一点。
创建正则表达式,有如下两种方式:
var re = /ar/;
var re = new RegExp('ar');
上面的正则表达式是与给定字符串集匹配的表达式。定义正则表达式之后,我们可以尝试匹配并查看匹配的字符串。可以使用 exec 函数匹配字符串:
re.exec("car"); // returns ["ar", index: 1, input: "car"]
re.exec("cab"); // returns null
有一些特殊的字符类允许我们编写复杂的正则表达式。RegEx 中有许多类型的元素,其中一些如下:
- 字符正则:
\w
-字母数字,\d
- 数字,\D
- 没有数字 - 字符类正则:[
x-y]
x-y区间,[^x]
没有x - 数量正则:
+
至少一个、?
没或多个、*
多个 - 边界正则,
^
开始、$
结尾
例子如下:
/* Character class */
var re1 = /[AEIOU]/;
re1.exec("Oval"); // returns ["O", index: 0, input: "Oval"]
re1.exec("2456"); // null
var re2 = /[1-9]/;
re2.exec('mp4'); // returns ["4", index: 2, input: "mp4"]
/* Characters */
var re4 = /\d\D\w/;
re4.exec('1232W2sdf'); // returns ["2W2", index: 3, input: "1232W2sdf"]
re4.exec('W3q'); // returns null
/* Boundaries */
var re5 = /^\d\D\w/;
re5.exec('2W34'); // returns ["2W3", index: 0, input: "2W34"]
re5.exec('W34567'); // returns null
var re6 = /^[0-9]{5}-[0-9]{5}-[0-9]{5}$/;
re6.exec('23451-45242-99078'); // returns ["23451-45242-99078", index: 0, input: "23451-45242-99078"]
re6.exec('23451-abcd-efgh-ijkl'); // returns null
/* Quantifiers */
var re7 = /\d+\D+$/;
re7.exec('2abcd'); // returns ["2abcd", index: 0, input: "2abcd"]
re7.exec('23'); // returns null
re7.exec('2abcd3'); // returns null
var re8 = /<([\w]+).*>(.*?)<\/\1>/;
re8.exec('<p>Hello JS developer</p>'); //returns ["<p>Hello JS developer</p>", "p", "Hello JS developer", index: 0, input: "<p>Hello JS developer</p>"]
有关 regex 的详细信息,可以看 这里。
除了 exec 之外,还有其他函数,即 match、search 和 replace,可以使用正则表达式在另一个字符串中查找字符串,但是这些函数在字符串本身上使用。
"2345-678r9".match(/[a-z A-Z]/); // returns ["r", index: 8, input: "2345-678r9"]
"2345-678r9".replace(/[a-z A-Z]/, ""); // returns 2345-6789
Regex 是一个重要的主题,开发人员应该理解它,以便轻松解决复杂的问题。
9)理解 map、reduce 和 filter
函数式编程是当今的一个热门讨论话题。许多编程语言都在新版本中包含了函数概念,比如 lambdas(例如:Java >7)。在 JavaScrip t中,函数式编程结构的支持已经存在很长时间了。我们需要深入学习三个主要函数。数学函数接受一些输入和返回输出。纯函数都是给定的输入返回相同的输出。我们现在讨论的函数也满足纯度。
map
map 函数在 JavaScript 数组中可用,使用这个函数,我们可以通过对数组中的每个元素应用一个转换函数来获得一个新的数组。map 一般语法是:
arr.map((elem){
process(elem)
return processedValue
}) // returns new array with each element processed
假设,在我们最近使用的串行密钥中输入了一些不需要的字符,需要移除它们。此时可以使用 map 来执行相同的操作并获取结果数组,而不是通过迭代和查找来删除字符。
var data = ["2345-34r", "2e345-211", "543-67i4", "346-598"];
var re = /[a-z A-Z]/;
var cleanedData = data.map((elem) => {return elem.replace(re, "")});
console.log(cleanedData); // ["2345-34", "2345-211", "543-674", "346-598"]
map 接受一个作为参数的函数, 此函数接受一个来自数组的参数。我们需要返回一个处理过的元素, 并应用于数组中的所有元素。
reduce
reduce 函数将一个给定的列表整理成一个最终的结果。通过迭代数组执行相同的操作, 并保存中间结果到一个变量中。这里是一个更简洁的方式进行处理。js 的 reduce 一般使用语法如下:
arr.reduce((accumulator,
currentValue,
currentIndex) => {
process(accumulator, currentValue)
return intermediateValue/finalValue
}, initialAccumulatorValue) // returns reduced value
accumulator 存储中间值和最终值。currentIndex、currentValue分别是数组中元素的 index 和 value。initialAccumulatorValue 是 accumulator 初始值。
reduce 的一个实际应用是将一个数组扁平化, 将内部数组转化为单个数组, 如下:
var arr = [[1, 2], [3, 4], [5, 6]];
var flattenedArray = [1, 2, 3, 4, 5, 6];
我们可以通过正常的迭代来实现这一点,但是使用 reduce,代码会更加简洁。
var flattenedArray = arr.reduce((accumulator, currentValue) => {
return accumulator.concat(currentValue);
}, []); // returns [1, 2, 3, 4, 5, 6]
filter
filter 与 map 更为接近, 对数组的每个元素进行操作并返回另外一个数组(不同于 reduce 返回的值)。过滤后的数组可能比原数组长度更短,因为通过过滤条件,排除了一些我们不需要的。
filter 语法如下:
arr.filter((elem) => {
return true/false
})
elem 是数组中的元素, 通过 true/false
表示过滤元素保存/排除。假设, 我们过滤出以 t
开始以 r
结束的元素:
var words = ["tiger", "toast", "boat", "tumor", "track", "bridge"]
var newData = words.filter((str) => {
return str.startsWith('t') && str.endsWith('r');
})
newData // (2) ["tiger", "tumor"]
当有人问起JavaScript的函数编程方面时,这三个函数应该信手拈来。 如你所见,原始数组在所有三种情况下都没有改变,这证明了这些函数的纯度。
10) 理解错误处理模式
这是许多开发人员最不关心的 JavaScript。 我看到很少有开发人员谈论错误处理, 一个好的开发方法总是谨慎地将 JS 代码封装装在 try/catch
块周围。
在 JavaScript中,只要我们随意编写代码,就可能会失败,如果所示:
$("button").click(function(){
$.ajax({url: "user.json", success: function(result){
updateUI(result["posts"]);
}});
});
这里,我们陷入了一个陷阱,我们说 result 总是 JSON 对象。但有时服务器会崩溃,返回的是 null 而不是 result。在这种情况下,null["posts"]
将抛出一个错误。正确的处理方式可能是这样的:
$("button").click(function(){
$.ajax({url: "user.json", success: function(result){
try {
updateUI(result["posts"]);
}
catch(e) {
// Custom functions
logError();
flashInfoMessage();
}
}});
});
logError 函数用于向服务器报告错误。flashInfoMessage 是显示用户友好的消息,如“当前不可用的服务”等。
Nicholas 说,当你觉得有什么意想不到的事情将要发生时,手动抛出错误。区分致命错误和非致命错误。以上错误与后端服务器宕机有关,这是致命的。在那里,应该通知客户由于某种原因服务中断了。
在某些情况下,这可能不是致命的,但最好通知服务器。为了创建这样的代码,首先抛出一个错误,, 从 window 层级捕捉错误事件,然后调用API将该消息记录到服务器。
reportErrorToServer = function (error) {
$.ajax({type: "POST",
url: "http://api.xyz.com/report",
data: error,
success: function (result) {}
});
}
// Window error event
window.addEventListener('error', function (e) {
reportErrorToServer({message: e.message})
})}
function mainLogic() {
// Somewhere you feel like fishy
throw new Error("user feeds are having fewer fields than expected...");
}
这段代码主要做三件事:
- 监听window层级错误
- 无论何时发生错误,都要调用 API
- 在服务器中记录
你也可以使用新的 Boolean 函数(es5,es6)在程序之前监测变量的有效性并且不为null、undefined
if (Boolean(someVariable)) {
// use variable now
} else {
throw new Error("Custom message")
}
始终考虑错误处理是你自己, 而不是浏览器。
其他(提升机制和事件冒泡)
以上所有概念都是 JavaScript 开发人员的需要知道基本概念。有一些内部细节需要知道,这些对你会有很在帮助。 这些是JavaScript引擎在浏览器中的工作方式,什么是提升机制和事件冒泡?
提升机制
变量提升是 在代码执行过程中将声明的变量的作用域提升到全局作用哉中的一个过程,如:
doSomething(foo); // used before
var foo; // declared later
当在 Python 这样的脚本语言中执行上述操作时,它会抛出一个错误,因为需要先定义然后才能使用它。尽管 JS 是一种脚本语言,但它有一种提升机制,在这种机制中,JavaScript VM 在运行程序时做两件事:
- 首先扫描程序,收集所有的变量和函数声明,并为其分配内存空间
- 通过填充分配的变量来执行程序, 没有分配则填充
undefined
在上面的代码片段中,console.log
打印 “undefined”
。 这是因为在第一次传递变量 foo 被收集。 JS 虚拟机 查找为变量 foo 定义的任何值。 这种提升可能导致许多JavaScript 在某些地方抛出错误,和另外地方使用 undefined
。
学习一些 例子 来搞清楚提升。
事件冒泡
现在事件开始冒泡了! 根据高级软件工程师 Arun P的说法:
“当事件发生在另一个元素内的元素中时,事件冒泡和捕获是 HTML DOM API 中事件传播的两种方式,并且这两个元素都已为该事件注册了处理程序,事件传播模式确定元素接收事件的顺序。“
通过冒泡,事件首先由最内部的元素捕获和处理,然后传播到外部元素。对于捕获,过程是相反的。我们通常使用addEventListener 函数将事件附加到处理程序。
addEventListener("click", handler, useCapture=false)
useCapture 是第三个参数的关键词, 默认为 false
。因此, 冒泡模式是事件由底部向上传递。 反之, 这是捕获模式。
冒泡模式:
<div onClick="divHandler()">
<ul onClick="ulHandler">
<li id="foo"></li>
</ul>
</div>
<script>
function handler() {
// do something here
}
function divHandler(){}
function ulHandler(){}
document.getElementById("foo").addEventListener("click", handler)
</script>
点击li元素, 事件顺序:
handler() => ulHandler() => divHandler()
在图中,处理程序按顺序向外触发。类似地,捕获模型试图将事件从父元素向内触发到单击的元素。现在更改上面代码中的这一行。
document.getElementById("foo").addEventListener("click", handler, true)
事件顺序:
divHandler => ulHandler() => handler()
你应该正确地理解事件冒泡(无论方向是指向父节点还是子节点),以实现用户界面(UI),以避免任何不需要的行为。
这些是 JavaScript中的基本概念。正如我最初提到的,除了工作经验和知识之外,准备有助理于你通过 JavaScript 面试。始终保持学习。留意最新的发展(第六章)。深入了解JavaScript的各个方面,如 V6 引擎、测试等。最后,没有掌握数据结构和算法的面试是不成功的。Oleksii Trekhleb 策划了一个很棒的 git repo,它包含了所有使用 JS 代码的面试准备算法。
关于Fundebug
Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。自从2016年双十一正式上线,Fundebug累计处理了10亿+错误事件,付费客户有Google、360、金山软件、百姓网等众多品牌企业。欢迎大家免费试用!
共同学习,写下你的评论
评论加载中...
作者其他优质文章