Lambda 是啥玩意

简单来说,Lambda 就是一个匿名的方法,就这样,没啥特别的。它采用一种非常简洁的方式来定义方法。当你想传递可复用的方法片段时,匿名方法非常有用。例如,将一个方法传递给另外一个方法。

Tips

其实很多主流语言早已支持 lambda 表达式,例如,Scala,C#,Objective-C,Ruby,C++(11), Python等等。所以也不是啥新玩意儿。

匿名方法 VS 匿名类

需要谨记一点,在 Java 里,匿名方法和匿名类并不是相同的。匿名类仍然需要实例化对象,匿名类虽然没有明确的名字,但它只有是一个对象时才能够使用。

而匿名方法并不需要给它分配实例,方法与作用的数据分离,而对象与它所作用的数据密切相关。

Java 中的 Lambda 表达式

在 Java 8之前,一个实现了只有一个抽象方法的接口的匿名类看起来更像Lambda 表达式。下面的代码中,anonymousClass方法调用waitFor方法,参数是一个实现接口的Condition类,实现的功能为,当满足某些条件,Server 就会关闭。

下面的代码是典型的匿名类的使用。

void anonymousClass() {
final Server server = new HttpServer();
waitFor(new Condition() {
@Override
public Boolean isSatisfied() {
return !server.isRunning();
}
}

下面的代码用 Lambda 表达式实现相同的功能:

void closure() {
Server server = new HttpServer();
waitFor(() -> !server.isRunning());
}

其实,上面的waitFor方法,更接近于下面的代码的描述:

class WaitFor {
static void waitFor(Condition condition) throws
InterruptedException {
while (!condition.isSatisfied())
Thread.sleep(250);
}
}

一些理论上的区别

实际上,上面的两种方法的实现都是闭包,后者的实现就是Lambda 表示式。这就意味着两者都需要持有运行时的环境。在 Java 8 之前,这就需要把匿名类所需要的一切复制给它。在上面的例子中,就需要把 server 属性复制给匿名类。

因为是复制,变量必须声明为 final 类型,以保证在获取和使用时不会被改变。Java 使用了优雅的方式保证了变量不会被更新,所以我们不用显式地把变量加上 final 修饰。

Lambda 表达式则不需要拷贝变量到它的运行环境中,从而 Lambda 表达式被当做是一个真正的方法来对待,而不是一个类的实例。

Lambda 表达式不需要每次都要被实例化,对于 Java 来说,带来巨大的好处。不像实例化匿名类,对内存的影响可以降到最小。

总体来说,匿名方法和匿名类存在以下区别:

  • 类必须实例化,而方法不必;
  • 当一个类被新建时,需要给对象分配内存;
  • 方法只需要分配一次内存,它被存储在堆的永久区内;
  • 对象作用于它自己的数据,而方法不会;
  • 静态类里的方法类似于匿名方法的功能。

一些具体的区别

匿名方法和匿名类有一些具体的区别,主要包括获取语义和覆盖变量。

获取语义

this 关键字是其中的一个语义上的区别。在匿名类中,this 指的是匿名类的实例,例如有了内部类为 Foo$InnerClass,当你引用内部类闭包的作用域时,像Foo.this.x的代码看起来就有些奇怪。

在 Lambda 表达式中,this 指的就是闭包作用域,事实上,Lambda 表达式就是一个作用域,这就意味着你不需要从超类那里继承任何名字,或是引入作用域的层级。你可以在作用域里直接访问属性,方法和局部变量。

例如,下面的代码中,Lambda 表达式可以直接访问firstName变量。

public class Example {
private String firstName = "Tom"; public void example() {
Function<String, String> addSurname = surname -> {
// equivalent to this.firstName
return firstName + " " + surname; // or even,
};
}
}

这里的firstName就是this.firstName的简写。

但是在匿名类中,你必须显式地调用firstName

public class Example {
private String firstName = "Jerry"; public void anotherExample() {
Function<String, String> addSurname = new Function<String,
String>() {
@Override
public String apply(String surname) {
return Example.this.firstName + " " + surname;
}
};
}
}

覆盖变量

在 Lambda 表达式中,

public class ShadowingExample {

    private String firstName = " Tim";

    public void shadowingExample(String firstName) {
Function<String, String> addSurname = surname -> {
return this.firstName + " " + surname;
};
}
}

因为 this 在Lambda 表达式中,它指向的是一个封闭的作用域,所以this.firstName对应的值是“Tim”,而不是跟它同名的参数的值。如果去掉this,那么引用的则是方法的参数。

在上面的例子中,如果用匿名类来实现的话,firstName指的就是方法的参数;如果想访问最外面的firstName,则使用Example.this.firstName

public class ShadowingExample {

    private String firstName = "King";

    public void anotherShadowingExample(String firstName) {
Function<String, String> addSurname = new Function<String,
String>() {
@Override
public String apply(String surname) {
return firstName + " " + surname;
}
};
}
}

Lambda 表达式基本语法

Lambda 表达式基本上就是匿名函数块。它更像是内部类的实例。例如,我们想对一个数组进行排序,我们可以使用Arrays.sort方法,它的参数是Comparator接口,类似于下面的代码。

Arrays.sort(numbers, new Comparator<Integer>() {
@Override
public int compare(Integer first, Integer second) {
return first.compareTo(second);
}
});

参数里的Comparator实例就是一个抽象片段,本身没有别的。在这里只有在 sort 方法中被使用。

如果我们用新的语法来替换,用 Lambda 表达式的方式来实现:

Arrays.sort(numbers, (first, second) -> first.compareTo(second));

这种方式更加简洁,实际上,Java 把它当做Comparator类的实例来对待。如果我们把 sort 的第二个参数从 Lambda 表达式中抽取出来,它的类型为Comparator<Integer>

Comparator<Integer> ascending = (first, second) -> first.compareTo(second);
Arrays.sort(numbers, ascending);

语法分解

你可以把单一的抽象方法转换成 Lambda 表达式。

举例,如果我们有一个接口名为Example,里面只有一个抽象方法apply,该抽象方法返回某一类型。

interface Example {
R apply(A args);
}

我们可以匿名实现此接口里的方法:

new Example() {
@Override
public R apply(A args) {
body
}
};

转换成 Lambda 表达式的话,我们去掉实例和声明,去掉方法的细节,只保留方法的参数列表和方法体。

(args) {
body
}

我们引入新的符号(->)来表示 Lambda 表达式。

(args) -> {
body
}

拿之前排序的方法为例,首先我们用匿名类来实现:

Arrays.sort(numbers, new Comparator<Integer>() {
@Override
public int compare(Integer first, Integer second) {
return first.compareTo(second);
}
});

下一步,去掉实例和方法签名:

Arrays.sort(numbers, (Integer first, Integer second) {
return first.compareTo(second);
});

引用 Lambda 表达式:

Arrays.sort(numbers, (Integer first, Integer second) -> {
return first.compareTo(second);
});

完成!但有些地方可以进一步优化。你可以去掉参数的类型,编译器已经足够聪明知道参数的类型。

Arrays.sort(numbers, (first, second) -> {
return first.compareTo(second);
});

如果是一个简单的表达式的话,例如只有一行代码,你可以去掉方法体的大括号,如果有返回值的话,return 关键字也可以去掉。

Arrays.sort(numbers, (first, second) -> first.compareTo(second));

如果Lambda 只有一个参数的话,参数外面的小括号也可以去掉。

(x) -> x + 1

去掉小括号后,

x -> x + 1

下一步我们做下总结,

(int x, int y) -> { return x + y; }
(x, y) -> { return x + y; }
(x, y) -> x + y; x -> x * 2
() -> System.out.println("Hello");
System.out::println;

第一个方式是完整的 Lambda 的声明和使用的方式,不过有些冗余,其实,参数的类型可以省略;

第二个方式是去掉参数类型的 Lambda 表达式;

第三个方式是,如果你的方法体只有一行语句,你可以直接省略掉大括号和 return 关键字;

第四个方式是没有参数的 Lambda 表达式;

第五个方式是Lambda 表达式的变种:是Lambda 表达式的一种简写,称为方法引用。例如:

 System.out::println;

实际上它是下面Lambda 表达式的一种简写:

(value -> System.out.prinltn(value)

深入 Lambda表达式

函数式接口

Java 把 Lambda表达式当作是一个接口类型的实例。它把这种形式被称之为函数式接口。一个函数式接口就是一个只有单一方法的接口,Java把这种方法称之为“函数式方法”,但更常用的名字为单一抽象方法(single abstract method" 或 SAM)。例如JDK中存在的接口例如RunnableCallable

@FunctionalInterface

Oracle 引入了一个新的注解为@FunctionalInterface, 用来标识一个接口为函数式接口。它基本上是用来传达这一用途,除此而外,编辑器还会做一些额外的检查。

比如,下面的接口:

public interface FunctionalInterfaceExample {
// compiles ok
}

如果加上@FunctionalInterface注解,则会编译错误:

@FunctionalInterface // <- error here
public interface FunctionalInterfaceExample {
// doesn't compile
}

编译器就会报错,错误的详细信息为“Invalid '@FunctionalInterface' annotation; FunctionalInterfaceExample is not a functional interface”。意思是没有定义一个单一的抽象方法。

而如果我们定义了两个抽象方法会如何?

@FunctionalInterface
public interface FunctionalInterfaceExample {
void apply();
void illegal(); // <- error here
}

编译器再次报错,提示为"multiple, non-overriding abstract methods were found"。所以,一旦使用了此注解,则在接口里只能定义一个抽象方法。

而现在有这样一种情况,如歌一个接口继承了另一个接口,会怎么办?我们创建一个新的函数式接口为A,定义了另一个接口B,B继承A,则B仍然是一个函数式接口,它继承了A的apply方法。

@FunctionalInterface
interface A {
abstract void apply();
} interface B extends A {

如果你想看起来更加清晰,可以复写父类的方法:

@FunctionalInterface
interface A {
abstract void apply();
} interface B extends A {
@Override
abstract void apply();
}

我们可以用下面的代码来测试一下上面的两个接口是否为函数式接口:

@FunctionalInterface
public interface A {
void apply();
} public interface B extends A {
@Override
void apply();
} public static void main(String... args) {
A a = () -> System.out.println("A");
B b = () -> System.out.println("B");
a.apply(); // 打印:A
b.apply(); // 打印:B
}

如果B接口继承了A接口,那么在B接口中就不能定义新的方法了,否则编译器会报错。

除了这些,在Java 8 中接口有了一些新的改进:

  • 可以添加默认方法;
  • 可以包含静态接口方法;
  • java.util.function包中增加了一些新的接口,例如,Function Predicate

方法引用

简单来说,方法引用就是 Lambda 表达式的一种简写。当你创建一个 Lambda 表达式时,你创建了一个匿名方法并提供方法体,但你使用方法引用时,你只需要提供已经存在的方法的名字,它本身已经包含方法体。

它的基本语法如下;

Class::method

或一个更加简洁明了的例子:

String::valueOf

"::"符号前面表示的是目标引用,后面表示方法的名字。所以,在上面的例子,String 类作为目标类,用来寻找它的方法valueOf,我们指的就是 String 类上的静态方法。

public static String valueOf(Object obj) { ... }

"::"称之为定界符,当我们使用它的时候,只是用来引用要使用的方法,而不是调用方法,所以不能在方法后面加()。

String::valueOf(); // error

你不能直接调用方法引用,只是用来替代 Lambda 表达式,所以,哪里使用 Lambda 表达式了,哪里就可以使用方法引用了。

所以,下面的代码并不能运行:

public static void main(String... args) {
String::valueOf;
}

这是因为该方法引用不能转化为Lambda 表达式,因为编译器没有上下文来推断要创建哪种类型的Lambda。

我们知道这个引用其实是等同于下面的代码:

(x) -> String.valueOf(x)

但编译器还不知道。虽然它可以知道一些事情。它知道,作为一个Lambda,返回值应该是字符串类型,因为valueOf方法的返回值为字符串类型。但它不知道作为论据需要提供什么信息。我们需要给它一点帮助,给它更多的上下文信息。

下面我们创建一个函数式接口Conversion

@FunctionalInterface
interface Conversion {
String convert(Integer number);
}

接下来我们需要创建一个场景去使用这个接口作为一个 Lambda,我们定义了下面的方法:

public static String convert(Integer number, Conversion function) {
return function.convert(number);
}

其实,我们已经给编译器提供了足够多的信息,可以把一个方法引用转换成一个等同的 Lambda。当我们调用convert方法时,我们可以把如下代码传递给 Lambda。

convert(100, (number) -> String.valueOf(number));

我们可以用把上面的 Lambda 替换为方法引用,

convert(100, String::valueOf);

另一种方式是我们告诉编译器,把引用分配给一个类型:

Conversion b = (number) -> String.valueOf(number);

用方法引用来表示:

Conversion b = String::valueOf

方法引用的种类

在 Java 中,有四种方法引用的类型:

  • 构造方法引用;
  • 静态方法引用:
  • 两种实例方法引用。

最后两个有点混乱。第一种是特定对象的方法引用,第二个是任意对象的方法引用,而是特定类型的方法引用。区别在于你想如何使用该方法,如果你事先并不知道有没有实例。

构造方法引用

构造方法的基本引用如下:

String::new

它会创建一个 Lambda 表达式,然后调用String 无参的构造方法。

它实际上等同于:

() -> new String()

需要注意的是构造方法引用没有括号,它只是引用,并不是调用,上面的例子只是引用了 String类的构造方法,并没有真正去实例化一个字符串对象。

接下来我们看一个实际应用构造方法引用的例子。

看先的例子,循环十遍为 list 增加对象。

public void usage() {
List<Object> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add(new Object());
}
}

如果我们想复用实例化的功能,我们可以抽取出一个新的方法initialisefactory创建对象。

public void usage() {
List<Object> list = new ArrayList<>();
initialise(list, ...);
} private void initialise(List<Object> list, Factory<Object> factory){
for (int i = 0; i < 10; i++) {
list.add(factory.create());
}
}

Factory是一个函数式接口,包含一个create方法,此方法返回 Object 对象,我们可以用 Lambda 的方式向 list 中添加对象。

public void usage() {
List<Object> list = new ArrayList<>();
initialise(list, () -> new Object());
}

或者我们用构造方法引用的方式来替换:

public void usage() {
List<Object> list = new ArrayList<>();
initialise(list, Object::new);
}

上面的方法其实还有待改进,上面只是创建 Object 类型的对象,我们可以增加泛型,实现可以创建更多类型的方法。

public void usage() {
List<String> list = new ArrayList<>();
initialise(list, String::new);
} private <T> void initialise(List<T> list, Factory<T> factory) {
for (int i = 0; i < 10; i++) {
list.add(factory.create());
}
}

到现在为知,我们演示的都是无参的构造方法的引用,如果是带有参数的构造方法的引用该如何处理呢?

当有多个构造函数时,使用相同的语法,但编译器计算出哪个构造函数是最佳匹配。它基于目标类型和推断功能接口,它可以用来创建该类型。

例如,我们有个 Person 类,它有一个多个参数的构造方法。

class Person {
public Person(String forename, String surname, LocalDate
birthday, Sex gender, String emailAddress, int age) {
// ...
}

回到上面的例子,我们可以如下使用:

initialise(people, () -> new Person(forename, surname, birthday,
gender, email, age));

但是如果想使用这个构造方法引用,则需要 Lambda 表达式提供如下参数:

initialise(people, () -> new Person(forename, surname, birthday,
gender, email, age));

特定对象的方法引用

下面是特定对象的方法引用的例子:

x::toString

x就是我们想要得到的对象。它等同于下面的Lambda 表达式。

() -> x.toString()

这种方法引用可以为我们提供便利的方式在不同的函数式接口类型中进行切换。看例子:

Callable<String> c = () -> "Hello";

Callable的方法为call,当被调用时返回“Hello”。

如果我们有另外一个函数式接口Factory,我们可以使用方法引用的方式来转变Callable这个函数式接口。

Factory<String> f = c::call;

我们可以重新创建一个 Lambda表达式,但是这个技巧是重用预定义的Lambda的一个有用的方式。 将它们分配给变量并重用它们以避免重复。

我们有下面一个例子:

public void example() {
String x = "hello";
function(x::toString);
}

这个例子中方法引用使用了闭包。他创建了一个 Lambda用来调用x对象上的toString方法。

上面function方法的签名和实现如下所示:

public static String function(Supplier<String> supplier) {
return supplier.get();
}

函数式接口Supplier的定义如下:

@FunctionalInterface
public interface Supplier<T> {
T get();
}

当使用此方法时,它通过get方法返回一个字符串,而且这是唯一的在我们的结构中获取字符串的方式。它等同于:

public void example() {
String x = "";
function(() -> x.toString());
}

需要注意的是,这里的 Lambda 表达式没有参数。这表明x变量在Lambda的局部作用域里是不可用的,如果可用必须要放在它的作用域之外。我们必须要掩盖变量x

如果用匿名类来实现的话,应该是下面的样子,这些需要主意,x变量是如何传递的。

public void example() {
String x = "";
function(new Supplier<String>() {
@Override
public String get() {
return x.toString(); // <- closes over 'x'
}
});
}

任意对象的实例方法引用(实例随后提供)

最后一种类型的实例方法引用的格式是这样的:

Object::toString

尽管在“::”左边指向的是一个类(有点类似于静态方法引用),实际上它是指向一个对象,toString方法是Object类上的实例方法,不是静态方法。您可能不使用常规实例方法语法的原因是,还没有引用的实例。

在以前,当我们调用x::toString时,我们是知道x的类型,但是有些情况我们是不知道的,但你仍然可以传递一个方法引用,但是在后面使用此语法时需要提供对应的类型。

例如,下面的表达式等同于x没有限制的类型。

(x) -> x.toString()

有两种不同的实例方法的引用基本是学术上的。有时候,你需要传递一些东西,其他时候,Lambda 的用法会为你提供。

这个例子类似于一个常规的方法引用;它这次调用String 对象的toString方法,该字符串提供给使用 Lambda 的函数,而不是从外部作用域传递的函数。

public void lambdaExample() {
function("value", String::toString);
}

这个String看起来像是引用一个类,其实是一个实例。是不是有些迷惑,为了能清晰一些,我们需要看一个使用 Lambda 表达式的方法,如下:

public static String function(String value, Function<String, String> function) {
return function.apply(value);
}

所以,这个 String 实例直接传递给了方法,它看起来像一个完全合格的Lambda。

public void lambdaExample() {
function("value", x -> x.toString());
}

上面的代码可以简写成String::toString, 它是在说在运行时给我提供对象实例。

如果你想用匿名类展开加以理解,它是这个样子的。参数x是可用的并没有被遮蔽,所以它更像是Lambda 表达式而不是闭包。

public void lambdaExample() {
function("value", new Function<String, String>() {
@Override
// takes the argument as a parameter, doesn't need to close
over it
public String apply(String x) {
return x.toString();
}
});
}

方法引用的总结

Oracle描述了四种类型的方法引用,如下所示。

种类 举例
静态方法引用 ContainingClass::staticMethodName
特定对象的实例方法引用 ContainingObject::instanceMethodName
特定类型的任意对象的实例方法引用 ContainingType::methodName
构造方法引用 ClassName::new

下面是方法引用的语法和具体的例子。

种类 语法 举例
静态方法引用 Class::staticMethodName String::valueOf
特定对象的实例方法引用 object::instanceMethodName x::toString
特定类型的任意对象的实例方法引用 Class::instanceMethodName String::toString
构造方法引用 ClassName::new String::new

最后,上面的方法引用等同于下面对应的 Lambda 表达式。

种类 语法 Lambda
静态方法引用 Class::staticMethodName (s) -> String.valueOf(s)
特定对象的实例方法引用 object::instanceMethodName () -> "hello".toString()
特定类型的任意对象的实例方法引用 Class::instanceMethodName (s) -> s.toString()
构造方法引用 ClassName::new () -> new String()

目前为止,Labmbda 的主要内容已经介绍完毕。

Java 8 Lambda 表达式的更多相关文章

  1. Java 8 Lambda表达式

    Java 8 Lambda表达式探险 http://www.cnblogs.com/feichexia/archive/2012/11/15/Java8_LambdaExpression.html 为 ...

  2. 深入浅出 Java 8 Lambda 表达式

    摘要:此篇文章主要介绍 Java8 Lambda 表达式产生的背景和用法,以及 Lambda 表达式与匿名类的不同等.本文系 OneAPM 工程师编译整理. Java 是一流的面向对象语言,除了部分简 ...

  3. Java 8 Lambda表达式10个示例【存】

    PS:不能完全参考文章的代码,请参考这个文件http://files.cnblogs.com/files/AIThink/Test01.zip 在Java 8之前,如果想将行为传入函数,仅有的选择就是 ...

  4. Java 8 lambda表达式示例

    例1.用lambda表达式实现Runnable 我开始使用Java 8时,首先做的就是使用lambda表达式替换匿名类,而实现Runnable接口是匿名类的最好示例.看一下Java 8之前的runna ...

  5. Java 8 Lambda 表达式详解

    一.Java 8 Lambda 表达式了解 参考:Java 8 Lambda 表达式 | 菜鸟教程 1.1 介绍: Lambda 表达式,也可称为闭包,是推动 Java 8 发布的最重要新特性. La ...

  6. 用Java 8 Lambda表达式实现设计模式:命令模式

    在这篇博客里,我将说明如何在使用 Java 8 Lambda表达式 的函数式编程方式 时实现 命令 设计模式 .命令模式的目标是将请求封装成一个对象,从对客户端的不同类型请求,例如队列或日志请求参数化 ...

  7. Java基础学习总结(44)——10个Java 8 Lambda表达式经典示例

    Java 8 刚于几周前发布,日期是2014年3月18日,这次开创性的发布在Java社区引发了不少讨论,并让大家感到激动.特性之一便是随同发布的lambda表达式,它将允许我们将行为传到函数里.在Ja ...

  8. 02、Java的lambda表达式和JavaScript的箭头函数

    前言 在JDK8和ES6的语言发展中,在Java的lambda表达式和JavaScript的箭头函数这两者有着千丝万缕的联系:本次试图通过这篇文章弄懂上面的两个"语法糖". 简介 ...

  9. Java 8 Lambda表达式学习和理解

    Java 8 Lambda表达式和理解 说明:部分资料来源于网络 时间:20190704 Lambda 表达式,也可称为闭包,它是推动 Java 8 发布的最重要新特性.Lambda 允许把函数作为一 ...

随机推荐

  1. h5标签基础 table表格标签

    一.表格的定义:用于有规范的显示数据. 二.基本组成: 行<tr>/列<td>/表头<caption>/表标题<th> eg: <table> ...

  2. Android中的WebView实战详解(一)

    一.为什么要用WebView? 1.兼容已有的项目2.可动态更新 二.WebView怎样使用? WebView是一个控件,如在布局中设置: <WebView android:id="@ ...

  3. ANdrod Studio查看Sha1的方法

    在用Studio做开发中,有时候根据业务需求,需要集成一些SDk,举个例子,百度的鹰眼定位,当然还有很多,在创建项目的时候需要输入sha1值,这个sha1值的获取有多种方式,我记得百度有个检测,就可以 ...

  4. T——SQL基础语句(定义变量,赋值,取值,分支,循环,存储过程)

    T--SQL基础语句 1.定义变量: declare @变量名 数据类型 ; declare @a int ; declare @b  nvarchar(10) ; 2.赋值: 法1:set @变量名 ...

  5. 分享一些自己写的前端库,并骗骗 star(库都是在实际项目中大量运用过的)

    最近一两年在一些项目上,通过实际需求出发,编写了一些库在项目中使用,现在将这些项目都稍微整理了一下开源了出来,也许也有刚好能够你也用得上的,顺便也骗一下star.均在项目的README中加了相关的说明 ...

  6. iOS回顾笔记(07) -- UITableView的使用和性能优化

    iOS回顾笔记(07) -- UITableView的使用和性能优化 如果问iOS中最重要的最常用的UI控件是什么,我觉得UITableView当之无愧!似乎所有常规APP都使用到了UITableVi ...

  7. 关于数组和集合的冒泡排序中容易出现的IndexOutOfBoundsException

    数组只能存错一种相同的数据类型,集合只能存储引用数据类型(用泛型),集合的底层就是一个可变的数组. 数组的冒泡排序: public static void arrayMaxPaiXu(int[] ar ...

  8. php多进程编程详解

    php多进程编程 前言 php单进程存在的问题: 多核处理器未充分利用,而单处理器通常需要等待其他操作完成之后才能再继续工作. 任何现代操作系统都可在幕后执行多任务,这意味着在很短时间内,计算机可以调 ...

  9. CSS.02 -- 样式表 及标签分类(块、行、行内块元素)、CSS三大特性、背景属性

    样式表书写位置  内嵌式写法 <head> <style type="text/css"> 样式表写法 </style> </head&g ...

  10. Web移动端的常用组件库

    normalize http://necolas.github.io/normalize.css/ 最受欢迎的css reset 保留有用的默认值,这个区别于其他的CSS resets 标准化大范围的 ...