js基础总结

GitHub地址源码地址

typeof 判断数据类型

数据分为值类型(undefinedstring,number,boolean)和引用类型(对象、数组、函数、null)两大类;
typeof 只能区分出值类型的数据,引用类型中的对象、数组、null的结果都为object,函数的结果为function

typeof

typeof - MDN

原型的5个规则

  1. 所有引用类型(对象、数组、函数)都有对象的特殊属性,即可自由拓展属性(null除外)
  2. 所有引用类型(对象、数组、函数)都有一个__proto__(隐式原型)属性,该属性是一个普通对象
  3. 所有的函数都有一个prototype(显式原型),该属性是一个普通对象
  4. 所有的引用类型(对象、数组、函数)的__proto__(隐式原型)都指向它的构造函数的 prototype(显式原型)
  5. 当试图得到一个对象的某个属性时,如果该对象本身没有这个属性,那么会去它的__proto__(也就是它的构造函数的prototype)中寻找

原型链

原型链

原型链:f.toString()在本身对象找不到,在f.__proto__也找不到,因为其指向的是Foo.prototype是一个对象,所以也有__proto__属性,所以就去f.__proto__.__proto__

instanceof 就是根据原型链向上查找

原型链
所以判断一个数据是否是数组
var arr = [1,2,3]
arr instanceof Array  // true
typeof arr  // object

this的使用场景

this要在执行时才能确认值,定义时无法确认

使用场景

  1. 作为构造函数执行
function Foo(name){
    this.name = name
}
// 描述new一个对象的过程
// 1.定义一个新对象f 
// 2.将this指向这个新对象f
// 3.执行代码进行this赋值
// 4.返回this
var f = new Foo('viiv')
  1. 作为对象属性执行
var obj = {
    name: 'A',
    printName: function(){
        console.log(this.name)
    }
}
obj.printName() // A
  1. 作为普通对象执行
function fn(){
    console.log(this) 
}
fn() // this === window
  1. call apply bind
function fn1(name,age){
    alert(name + age)
    console.log(this)
}

fn1.call({x:100}, 'zhangsan')   // this === {x:100}
fn1.apply({x:100},['zhangsan',20])

// bind必须是函数表达式方式绑定this指向
var fn2 = function(name, age){
    alert(name)
    console.log(this)
}.bind({x: 1000})

fn2('zhangsan', 20) // {x:1000}

执行上下文

对变量提升的理解

函数声明和变量声明总是会被解释器悄悄地被"提升"到方法体的最顶部。
JavaScript 中,函数及变量的声明都将被提升到函数的最顶部。
JavaScript 中,变量可以在使用后声明,也就是变量可以先使用再声明。
只有声明的变量会提升,初始化的不会

作用域

  1. 无块级作用域
if(true){
  var name = 'zhangsan'
}
console.log(name)  // zhangsan


// 相当于
var name
if(true){
  name = 'zhangsan'
}
console.log(name)
  1. 函数和全局作用域
// 函数的作用域在定义时决定,而不是执行时
var a = 100
function fn(){
  var a = 200
  console.log('fn', a)
}
console.log('global', a) // global 100
fn()  // fn 200

作用域链

当前作用域没有定义的变量,即“自由变量”
函数在哪里定义,自由变量的父级作用域在哪里

var a = 100
function F1(){
  var b = 200
  function F2(){
    var c = 300
    console.log(a)  // a自由变量 
    console.log(b)  // b自由变量 
    console.log(c)
  }
  F2()
}
F1()

闭包

闭包的使用场景

  1. 函数作为返回值
function F1(){
    var a = 100
    // 返回一个函数(函数作为返回值)
    return function (){
        console.log(a) // a是自由变量,父级作用域从#定义#的时候的作用域处寻找
    }
}

var f1 = F1()
var a = 200
f1()  // 100  从定义的地方找父级作用域
  1. 函数作为参数来传递
function F1(){
    var a = 100
    return function(){
        console.log(a) // 自由变量
    }
}

var f1 = F1()
function F2(fn){
    var a = 200
    fn()
}
F2(f1); // 100  执行时候的变量从定义时找作用域
创建10个<a>标签,点击的时候弹出来对应的序号
var i
for(i = 0; i < 10; i++){
    (function(i){
        var a = document.createElement('a');
        a.innerHTML = i + '<br>';
        a.addEventListener('click', function(e){
            e.preventDefault();
            alert(i) // 父级作用域在自执行函数内,保留了i值
        });
        document.body.appendChild(a);
    })(i)
}
如何理解作用域
  1. 自由变量:在当前作用域范围内没有定义的变量
  2. 作用域链,即自由变量的查找: 从定义的地方寻找父级作用域
  3. 闭包的两个场景: 函数作为返回值和函数作为参数
实际开发中闭包的应用

封装变量、收敛权限

function isFirstLoad(){
    var _list = [] // 封装_list,防止外部污染
    return function(id){
        if(_list.indexOf(id) >= 0){
            return false
        }else {
            _list.push(id)
            return true
        }
    }
}

var firstLoad = isFirstLoad()
firstLoad(10) // true
firstLoad(20) // true
firstLoad(10) // false

同步和异步

同步和异步的区别是什么

  1. 同步会阻塞代码执行,而异步不会
  2. alert是同步,setTimeout是异步
console.log(100)
alert(200)
console.log(300)
// 100 弹出200 300


console.log(100)
setTimeout(function(){
    console.log(200)
},1000)
console.log(300)
// 100  300  200

前端使用异步的场景

  1. 定时任务:setTimeout setInterval
  2. 网络请求:ajax请求、<img>图片加载
  3. 事件绑定
// ajax请求示例
 console.log('start')
 $.get('../data.json', function(data1){
     console.log(data1)
 })
console.log('end')

// <img>加载示例
console.log('start')
var img = document.createElement('img')
img.onload = function(){
    console.log('loaded')
}
img.src = 'xxx.png'
console.log('end')

// 事件绑定示例
console.log('start')
document.getElementById('btn1').addEventListener('click', function(){
    console.log('clicked')
})
console.log('end')
关于setTimeout的笔试题
console.log(1)
setTimeout(function(){
    console.log(2)
}, 0)
console.log(3)
setTimeout(function(){
    console.log(4)
},1000)
console.log(5)
// 1 3 5 2 4

Math

获取随机数,要求是长度一致的字符串格式
var random = Math.random()
random = random + '0000000000'
random = random.slice(0, 10)
console.log(random)

日期API

Date.now() === new Date().getTime() // 获取当前时间的毫秒数
var dt = new Date()
dt.getTime() // 返回 1970 年 1 月 1 日至今的毫秒数
dt.getFullYear() // 年
dt.getMonth() // 月 (0 - 11)
dt.getDate() // 日 (0 - 31)
dt.getHours() // 时 (0 - 23)
dt.getMinutes() // 分 (0 - 59)
dt.getSeconds() // 秒 (0 - 59)
获取2017-06-10格式的日期
function formatDate(dt){
    if(!dt){
        dt = new Date()
    }
    var year = dt.getFullYear()
    var month = dt.getMonth() + 1
    month = month < 10 ? '0' + month : month
    var date = dt.getDate()
    date = date < 10 ? '0' + date : date
    return year + '-' + month + '-' + date;
}
formatDate(new Date()) // "2018-03-08"

数组API

forEach 遍历所有元素

var arr = [1, 2, 3]
arr.forEach(function(item, index){
    // 遍历数组的所有元素
    console.log(index, item)
})
// 0 1
// 1 2
// 2 3

every 判断所有元素是否都符合条件

var arr = [1, 2, 3, 4, 5]
var result = arr.every(function(item, index){
    // 用来判断所有的数组元素,都满足一个条件
    return item > 4
})
console.log(result) // false

some 判断是否至少有一个元素符合条件

var arr = [1, 2, 3, 4, 5]
var result = arr.some(function(item, index){
    return item < 2
})
console.log(result) // true

sort 排序

var arr = [1, 4, 2, 4, 5, 3]
var arr2 = arr.sort(function(a, b){
    // 从小到大
    // return a - b
    // 从大到小
    return b - a
})
console.log(arr2)  //  [5, 4, 4, 3, 2, 1]

map 对元素重新组装,生成新数组

var arr = [1, 2, 3, 4]
var arr2 = arr.map(function(item, index){
    return '<b>' + item + '<b>'
})
console.log(arr2) // ["<b>1<b>", "<b>2<b>", "<b>3<b>", "<b>4<b>"]

filter 过滤符合条件的元素

var arr = [1, 2, 3]
var arr2 = arr.filter(function(item, index){
    return item > 2
})
console.log(arr2) // [3]

对象API

var obj = {
    x: 100,
    y: 200,
    z: 300
}
var key
for(key in obj){
    // 注意这里的 hasOwnProperty,在讲原型链的时候讲过了
    // 拿出本身的属性而不是继承来的
    if(obj.hasOwnProperty(key)){
        console.log(key, obj[key])
    }
}
// x 100
// y 200
// z 300
写一个能遍历对象和数组的forEach函数
function forEach(obj, fn){
     var key
     if(obj instanceof Array){
         // 准确判断是否数组
        obj.forEach(function(item, index){
            fn(index, item)
        })
     }else{
         // 对象
        for(key in obj){
            if(obj.hasOwnProperty(key)){
                fn(key, obj[key])
            }
        }
     }
 }

 var arr = [1, 2, 3]
 forEach(arr, function(index, item){
     console.log(index, item)
 })

 var obj = {x: 100, y: 200}
 forEach(obj, function(key, value){
     console.log(key, value)
 })

DOM

DOM是哪种基本的数据结构

DOM操作的常用API有哪些
  1. 获取DOM节点,以及节点的property和attribute
  2. 获取父节点,获取子节点
  3. 新增节点、删除节点
var div1 = document.getElementById('div1')
// 新增节点
var p1 = document.createElement('p')
p1.innerHTML = 'this is p1'
div1.appendChild(p1) // 添加新创建的元素
// 移动已有节点
var p2 = document.getElementById('p2')
div1.appendChild(p2) // 移动 
// 获取父元素
var parent = div1.parentElement
// 获取子元素
var child = div1.childNodes
console.log(div1.childNodes[0].nodeType) // text 3 空字符串是3
console.log(div1.childNodes[1].nodeType) // p    1 标签是1
console.log(div1.childNodes[0].nodeName) // #text
console.log(div1.childNodes[1].nodeName) // P
// 删除元素
div1.removeChild(child[0])
DOM节点的attr和property有何区别
  1. attr修改的是DOM文档标签里的属性
  2. property修改的是js对象的属性
// property修改的是js对象的属性
var div1 = document.getElementById('div1')
console.log(div1.className)
div1.className = 'abc'
console.log(className)

// attribute修改的是DOM文档标签里的属性
var p1 = document.getElemenetsByTagName('p')[0]
console.log(p1.getAttribute('data-name'))
p1.setAttribute('data-name', 'xyz')

BOM

如何检测浏览器的类型
var ua = navigator.userAgent;
var isChromw = ua.indexOf('Chrome')
拆解url的各部分
location.href
location.protocol // 协议 'http:' 'https:'
location.host // 域名
location.pathname // 地址 '/learn/199'
location.search  // 参数 '?removeTooltip=%E4%B8%8'
location.hash // 

history.back() // 返回
history.forward() // 前进

事件

简述事件冒泡流程
  1. DOM树形结构
  2. 事件冒泡,层层向上触发事件
  3. 阻止冒泡: e. stopPropagation()
  4. 冒泡的应用:代理
代理(由于事件冒泡机制)
  1. 使用代理: 对于无线下拉加载的图片,如何给每个图片绑定事件
  2. 代理的两个优点:代码简洁、减少浏览器内存占用
编写一个通用的事件监听函数
function bindEvent(elem, type, selector, fn){
    if(fn == null){
        fn = selector
        selector = null
    }
    elem.addEventListener(type, function(e){
        var target
        if(selector){
            // 代理
            target = e.target
            if(target.matches(selector)){ // 判断element是否匹配给定的选择器。
                fn.call(target, e)
            }
        }else{
            // 不是代理
            fn(e)
        }
    })
}

// 使用代理
var div1 = document.getElementById('div1')
bindEvent(div1, 'click', 'a', function(e){
    e.preventDefault()
    console.log(this.innerHTML)
})
// 不使用代理
var a = document.getElementById('a1')
bindEvent(div1, 'click', function(e){
    console.log(a.innerHTML)
})

ajax

手写一个ajax,不依赖第三方库
// 创建一个xhr对象
var xhr = new XMLHttpRequest() // IE低版本使用   ActiveXObject
// 设置请求方式、请求地址、是否同步
xhr.open('GET', '/api', false)
// 监听状态变化
xhr.onreadystatechange = function(){
    if(xhr.readyState == 4 && xhr.status == 200){
        // 是否完成、状态码是否为200
        alert(xhr.responseText)
    }
}
// 发送请求
xhr.send()
readyState状态值
  • 0 - (未初始化)还没有调用send()
  • 1 - (载入)已调用send(),正在发送请求
  • 2 - (载入完成)send()方法执行完成,已经接收到全部相应内容
  • 3 - (交互)正在解析相应内容
  • 4 - (完成)响应内容解析完成,可以在客户端调用了
status状态值
  • 2xx - 表示成功处理请求。如200
  • 3xx - 需要重定向,浏览器直接跳转
  • 4xx - 客户端请求错误,如404 请求的地址不存在
  • 5xx - 服务器端错误

跨域

浏览器有同源策略,不允许ajax访问其它域接口,跨域条件:协议、域名、端口,有一个不同就算跨域。
http默认端口80 ,https默认端口443

跨域的几种实现方式
  1. 三个标签允许跨域加载资源
    <img> 用于打点统计,统计网站可能是其它域
    <link> <script> 可以使用CDN,CDN的也是其它域
    <script> 可以用于JSONP
  2. JSONP (JSON with Padding):回调函数和数据
  3. CORS(跨域资源共享): 使用自定义的HTTP头部让浏览器与服务器进行沟通,服务器端设置http header Access-Control-Allow-Origin
  4. 通过修改document.domain来跨子域
  5. 使用window.name来进行跨域
  6. 使用HTML5的window.postMessage方法跨域
JSONP原理

<script>标签可以跨域加载资源,提供回调函数来接收数据

// 定义一个fun函数
function fun(fata) {
    console.log(data);
};
// 创建一个脚本,并且告诉后端回调函数名叫fun
var body = document.getElementsByTagName('body')[0];
var script = document.gerElement('script');
script.type = 'text/javasctipt';
script.src = 'demo.js?callback=fun';
body.appendChild(script);

存储

描述一下cookie、sessionStorage和localStorage的区别
  1. cookie容量小,只有4KB;localStoragesessionStorage是HTML5专门为存储而设计,最大容量5M
  2. cookie是在请求中使用,所有http请求都带着,会影响获取资源的效率;localStoragesessionStorage是HTML5专门为存储而设计
  3. localStoragesessionStorage的API简单易用
localStorage.setItem(key,value)
localStorage.getItem(key)

cookie需要封装才能用document.cookie = ...

localStorage和sessionStorage区别

sessionStorage如果浏览器关掉就清理,localStorage一直存在本地

tips: iOS Safari 隐藏模式下,localStorage.getItem()会报错,建议使用try-catch包装

git

  • git add .
  • git checkout xxx
  • git commit -m "xxx" 先提到本地仓库,“xxx”备注
  • git push origin master 推动到远程仓库
  • git pull origin master 拉取远程仓库代码

模块化

不使用模块化的缺点
  1. 函数必须是全局变量,才能暴露给对方使用。全局变量污染
  2. 依赖关系不明确,容易缺失

AMD 异步模块定义

  1. 全局define函数
  2. 全局require函数
  3. 依赖js会自动、异步加载
    代码演示地址
    使用require.js

    使用require.js

CommonJS

nodejs模块化规范,被大量前端使用,原因有:

  1. 前端开发依赖的插件和库,都可以从npm中获取(node-package-manager)
  2. 构建工具的高度自动化,使得使用npm成本非常低
  3. CommonJS不会异步加载js,而是同步一次性加载出来
使用CommonJS
AMD和CommonJS的使用场景
  1. 需要异步加载,使用AMD
  2. 使用了npm之后,建议使用CommonJS
npm 使用
  • npm init 生成package.json文件
  • npm install webpack -g --save-dev 全局(-g)安装(install)webpack,--save-dev表示只是用于开发环境
  • npm i jquery --save 安装(i)jQuery,表示开发上线环境都要用
  • npm uninstall moment 卸载(uninstall)moment

使用webpack打包,配置文件

// webpack.config.js
var path = require('path')
var webpack = require('webpack')

module.exports = {
    context: path.resolve(__dirname, './src'),
    entry: {
        app: './app.js'
    },
    output: {
        path: path.resolve(__dirname, './dist'),
        filename: 'bundle.js'
    },
    plugins: [
        new webpack.optimize.UglifyJsPlugin()
    ]
}

上线回滚流程

上线流程要点
  • 将测试完成的代码提交到Git版本库的master分支
  • 将当前服务器的代码全部打包并记录版本号、备份
  • 将master分支的代码提交覆盖到线上服务器、生成新版本号
回滚流程要点
  • 将当前服务器的代码打包并记录版本号、备份
  • 将备份的上一个版本号解压、覆盖到线上服务器,并生成新的版本号

Linux基本命令

  • 登录
ssh name@server
  • 创建文件夹
mkdir a
mkdir a
  • 查看(只看名字)
ls
ls
  • 查看(列表形式)
ll
ll
  • 进入文件夹
cd a  // 进入a文件夹
pwd  // 查看当前目录
cd a
  • 返回上级目录
cd ../
返回上级目录
  • 新建(编辑)文件
vi a.js

i      // insert 可以输入
esc // 停止输入

// 保存
1. 先点击ESC
2. :w  (写) 就可以保存了
3. :q  (退出)
  • 查看文件
cat a.js

head -n 1 a.js // 查看前一行
tail -n 2 a.js  // 查看后两行
grep '2' a.js  // 从文件中搜索
  • 拷贝文件
cp a.js a1.js
文件拷贝
  • 移动文件
mv a1.js ../src/a1.js   // 将a1.js 移动到src目录下
  • 删除文件
rm a.js
  • 删除文件夹
rm -rf a
删除

页面加载过程、性能优化、安全性

从输入URL到得到HTML的详细过程
  1. 加载资源的形式
  • 输入URL(或跳转页面)加载HTML
  • 加载HTML中的静态资源(css、js、图片、媒体文件)
  1. 加载一个资源的过程
  • 浏览器根据DNS服务器得到域名的IP地址
  • 向这个IP的机器发送http请求
  • 服务器收到、处理、并返回http请求
  • 浏览器得到返回内容
  1. 浏览器渲染页面的过程
  • 根据HTML结构生成DOM Tree
  • 根据CSS生成CSSOM
  • 将DOM和CSSOM整合形成Render Tree
  • 根据RenderTree开始渲染和展示
  • 遇到<script>时,会执行阻塞渲染
window.onloadDOMContentLoaded的区别
window.addEventListener('load', function(){
  // 页面的全部资源加载完才会执行,包括图片、视频等
})

// 推荐这种尽早操作(jQuery、zepto)
window.addEventListener('DOMContentLoaded', function(){
  // DOM 渲染完即可执行,此时图片、视频还可能没有加载完
})

性能优化

原则:多使用内存、缓存或者其他方法; 减少CPU计算、减少网络请求

  1. 加载资源优化
  • 静态资源的压缩合并(资源合并)
  • 静态资源缓存(使用时间戳缓存记录版本号)
  • 使用CDN让资源加载更快
  • 使用SSR后端渲染,数据直接输出到HTML中
  1. 渲染优化:
  • CSS放前面、JS放后面
  • 懒加载(图片懒加载、下拉加载更多)
<img id="img1" src="preview.png" data-realsrc="abc.png">
<script>

var img1 = document.getElementById('img1')
img1.src = img1.getAttribute('data-realsrc')

// 检测浏览器滚动高度
function isVisible($node){
    var winH = $(window).height(),
        scrollTop = $(window).scrollTop(),
        offSetTop = $(window).offSet().top;
    if (offSetTop < winH + scrollTop) {
        return true;
    } else {
        return false;
    }
}

// 第一次被检查到时使用懒加载
var hasShowed = false;
$(window).on("sroll",function{
    if (hasShowed) {
        return;
    } else {
        if (isVisible($node)) {
            hasShowed = !hasShowed;
            console.log(true);
        }
    }
})
</script>
  • 减少DOM查询,对DOM查询做缓存


    缓存DOM查询
  • 减少DOM操作,多个操作尽量合并在一起执行


    减少DOM操作
  • 事件节流


    事件节流

安全性

场景
  1. XSS跨站请求攻击


    XSS跨站请求攻击
  • 前端替换关键字,例如替换<&lt;>&gt;
  • 推荐后端替换
  1. XSRF跨站请求伪造


    XSRF跨站请求伪造
  • 增加验证流程,如输入指纹、密码、短信验证码
  • 推荐后端来做
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容