zh
cn
zh-CN
zh-hans
。同样是中文,却搞出这么多种表示方法。
非常的不讲道理,也没有一丝人性。
如果做过多语言需求,你肯定想过到底用哪种才显得比较专业?
互联网世界是用各种各样的标准和协议作为粘合剂组成的。
Javascript 脚本之所以能运行在不同的浏览器,是因为各大浏览器都遵循 ECMA 标准。
全球互联网之所以能成为现实,是因为所有计算机底层都遵循 OSI 模型,其中使用到的各种协议你肯定耳熟能详,如著名八股文 TCP 协议。
语言代码也不例外,ISO 639 就是为各语言所制定语言代码标准,它定义了 zh
代表中文。这种定义方法叫做 Alpha-2 Code,顾名思义,两个字母表示的代码,它可以表示世界上主要的语言。
比如:中文的拼音是 zhongwen
,那代码就是 zh
。美国的英文是 english
,那代码就是 en
。
然而,世界上的语言有数千种,Alpha-2 表示法只能表示 26 x 26 = 676 种,是远远不够的,因此产生了 Alpha-3 Code,顾名思义,三个字母表示的代码,可以表示 17576 种语言。它的一般表示方法你可能已经猜到了,就是取前 3 个字母:zho
、eng
。
中文可以分为简体中文和繁体中文,ISO 639 明显不够用了。
于是出现了用国家或地区的代码来表示语言的方法,使用的标准是 ISO 3166。
该标准包括了针对国家、地区、和具有特殊科学价值的地点,以及其子行政区名称的国际标准代码。
cn
就来自这个标准,除此之外,hk
表示香港,us
表示美国。
这样就可以用 cn
来表示简体中文偏好,hk
表示繁体中文偏好,以各国家和地区的使用习惯来分。
美国和英国使用的都是英文,怎么样才能既表示国家地区又表示偏好语言呢?
把 ISO 639 的 Alpha-2 和 ISO 3166 用 - 结合起来,IETF 语言标签的最早版本 RFC 1766 应运而生。
zh-CN
表示中国大陆的中文,zh-HK
表示香港地区的中文。
en-US
表示美国的英文,en-GB
表示英国的英文。
RFC 4646 是 RFC 1766 的升级版,它规范了主体,使用 zh-Hans
表示简体中文,zh-Hant
表示繁体中文。
如果想精确到地区,则可以 zh-Hant-HK
表示香港地区的繁体中文,zh-Hant-TW
表示台湾地区的繁体中文。
不同需求用不同的标准。
如果只要求中英文版本的话,zh
+ en
足够。
如果要求支持英繁简,则可以 en
+ zh-Hans
+ zh-Hant
,也可以 en
+ zh-CN
+ zh-HK
。
只要满足需求,在系统内部统一。
就行。
]]>先来看一下 DOM 结构:
DOM 是 pdfjs 生成的,修改 DOM 是一种选择。无论是把嵌套的 DOM 做扁平化处理,还是为每个层级的标签都加上新的 class,都需要去操作 DOM,消耗大,复杂度也高。因此用 CSS 解决是比较好的方案。
但是书签的 DOM 是嵌套结构,这意味着无法简单的设置 padding-left 来达到目的,我们需要为不同层级的书签设置不同的 padding-left。
比如,第 1 层的 padding-left 为 10px,第 2 层为 30px,第 3 层 50px,以此类推,每多一层嵌套多 20px,容易得到如下 Sass 代码:
#outlineView { > .treeItem { // 这是第 1 层 a { padding-left: 10px; } > .treeItems { > .treeItem { // 这是第 2 层 a { padding-left: 30px; } > .treeItems { // 以此类推 ... } } } }}
手动硬编码 3 层已经是嵌套很深了。由于我们无法知道 pdf 的最高嵌套层数(世界之大无奇不有),因此只能尽可能多的提高覆盖率,也就是尽可能多的写嵌套,陷入嵌套地狱中去。
我们当然不能就这么陷进去,既然用了 Sass 就要用 Sass 的方式来解决。先设 N 为嵌套层级,那么,任意层均有 padding-left:(N - 1) * 20 + 10。
接下来思考一个问题,如果要在 Sass 中嵌入一段常用的 CSS,你会怎么做?
答案是用 @mixin/@include 来减少编码次数。
那如果在 @mixin 中 @include 自己,是不是就能递归了?
@mixin recursive-item-padding($cur, $end) { > .treeItem { a { padding-left: #{($cur - 1) * 20 + 10}px; } @if $cur < $end { > .treeItems { @include recursive-item-padding($cur + 1, $end); } } }}#outlineView { // 随意递归 10 层 @include recursive-item-padding(1, 10);}
完美。
]]>Object.defineProperty
, 因此 Vue 不支持 IE8 以下的浏览器。这个方法可以定义一个对象的某个属性的描述符,来具体看一下:const obj = { k: 1}let value = 0;Object.defineProperty(obj, 'k', { enumerable: true, // 该属性是否可以被枚举 configurable: true, // 该属性的描述符是否可以修改 get: function() { // 魔法的起源地,每当 obj 的 k 属性的值被读取,都会执行这个方法,Vue 在这里收集依赖 return value; }, set: function(newVal) { // Vue 在这里通知所有依赖,该属性被修改了 value = newVal; }});
上面的 get
和 set
函数分别就是 obj
对象 k
属性的 getter
和 setter
。
看起来很简单,实则还有很多问题:
我们一个一个来解决。
在 Vue 里,依赖是 Watcher
对象的实例,我们先来列举一下 Vue 在什么情况下会创建 Watcher
:
Watcher
,因此每个 Vue 组件都会对应一个 Watcher
。computed
属性时,会为每一个属性创建一个 Watcher
。watch
属性时,会为每一个被 watch
的属性创建一个 Watcher
。$watch
方法的时候。第 3 和第 4 种情况其实是一样的,唯一区别是,手动调用 $watch
会返回一个 unwatch
方法,可以用来取消 watch
。
接下来,简单了解 Watcher
对象的构造:
class Watcher { constructor(vm, expOrFn, cb) { this.depIds = new Set(); // 一个集合,存储 Dep 的 id,防止重复收集依赖 this.deps = []; // 一个列表,存储以自己为依赖的依赖表 this.vm = vm; // 响应式数据的上下文 if (typeof expOrFn === 'function') { this.getter = expOrFn; } else { this.getter = parsePath(expOrFn); // parsePath 用于解析 expOrFn,返回一个方法用来读取并返回响应式数据的值 } this.cb = cb; // 响应式数据更新后触发的回调函数 this.value = this.get(); // 用 get 方法取得数据,顺便 “摸一下” 响应式数据的 getter 方法,收集依赖 } // 下面的 Dep 是一个类,用于维护响应式数据的依赖表,这里把 Watcher 自身添加到 Dep 的静态成员变量上,然后执行 this.getter 方法 // this.getter 在上面介绍过,会读取并返回响应式数据的值 get() { Dep.target = this; let value = this.getter.call(this.vm, this.vm); Dep.target = undefined; return value; } // addDep 方法会在 Dep 的 depend 方法中被调用,Dep 把自身作为参数传进来 // 简单判断没有重复收集之后,调用 Dep 的 addSub 方法来让 Dep 实例收集这个 Watcher // 正是这里的交叉传递自身,让 Watcher 拥有把自己添加进响应式数据依赖表中的能力。 addDep(dep) { const id = dep.id; if (!this.depIds.has(id)) this.depIds.add(id); this.deps.push(dep); dep.addSub(this); } } // 响应式数据更新后会通知所有的依赖(Watcher)执行该方法。 // 方法内部会调用 Watcher 的回调函数,并传入新旧值作为参数。 update() { const oldVal = this.value; this.value = this.get(); this.cb.call(this.vm, this.value, oldVal); }}
上面代码中 this.getter
的值是 parsePath(expOrFn)
返回的。下面来了解一下 parsePath
的原理:
// 解析简单路径,路径通常的模样为:'a.b.c'const bailRE = /[^\w.$]/;function parsePath(path) { if (bailRE.test(path)) { return; } const segments = path.split('.'); return function(obj) { for (let i = 0; i < segments.length; i++) { if (!obj) return; obj = obj[segments[i]]; } return obj; }}
可以看到,parsePath
的返回值是一个函数,这个函数有个特点,就是会 “Touch”,摸一下 obj
在 path
路径下的对象,这会触发对象的 getter
,从而把 Watcher
自身添加进对象的依赖表里。
在 Vue 里,每一个响应式数据都有各自的 Dep
对象,你可以这样得到它 data.__ob__.dep
。Dep
对象的结构很简单,就是一个维护依赖表的类:
let uid = 0;class Dep { constructor() { this.id = uid++; // Dep 的 id this.subs = []; // 一个列表用来存储依赖(Watcher) } // 添加一个依赖,sub 就是 Watcher 实例 addSub(sub) { this.subs.push(sub); } // 故名思议,移除一个依赖 removeSub(sub) { remove(this.subs, sub); } // 把 Dep.target 添加进依赖表,Dep.target 就是 Watcher depend() { if (Dep.target) { Dep.target.addDep(this); } } // 通知所有依赖触发更新,可以看到,更新是通过调用 Watcher 对象的 update 方法进行的 notify() { const subs = this.subs.slice(); for (let i = 0, l = subs.length; i < l; i++) { subs[i].update(); } }}
每一个响应式数据的依赖都可能是不同的,因此他们都应该有自己的依赖表。
上面提到,依赖是 Watcher
,Watcher
在初始化的时候会把自己赋值到 Dep.target
,然后触发响应式数据的 getter
。
而收集依赖的方法是在响应式数据的 getter
中把 Dep.target
添加进依赖表,再结合上面 Dep
类的结构可以知道,收集依赖就是调用 Dep
对象的 depend
方法:
// 构造一个函数来把数据变成响应式数据function defineReactive(data, key, val) { const dep = new Dep(); Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function() { // 收集依赖 dep.depend(); return val; }, set: function(newVal) { val = newVal; // 通知s所有收集到的依赖 for (let i = 0; i < dep.length; i++) { dep[i].notify(); } } });}
一个完整的依赖收集流程是这样的:
Watcher
实例,初始化时访问响应式数据的值,把自身赋值给 Dep
类的静态变量 target
,触发 getter
。getter
中能访问到响应式数据的依赖表维护对象 dep
,调用 dep
的 depend
方法。depend
方法会把 Dep
实例自身作为参数传给 Watcher
实例的 addDep
方法。addDep
方法会把参数中的 Dep
实例收集起来,并把自身作为参数传给 Dep
实例的 addSub
方法。addSub
方法会把参数中的 Watcher
实例作为依赖收集起来。最后来看一下 Vue 官方提供的响应式原理图:
稍微解释一下,Vue 会把模板编译成代码字符串,运行时渲染函数会执行代码字符串生成虚拟 DOM
,在生成虚拟 DOM
的过程中会读取许多响应式数据,读取值会触发对应的 getter
,getter
会把组件对应的 Watcher 收集到依赖表中,每当修改的响应式数据的值都会触发 setter
,setter
会通知依赖表中的每一个 Watcher
,接到通知后,Watcher
对应的回调函数会被执行,对应的组件就会重新生成虚拟 DOM
。
看到这里你也许会注意到,响应式的原理是在 getter
中收集依赖,当调用操作数组的原型方法或者使用索引取值的时候,是不会触发 getter
的,这时响应式会失效。为避免这种情况,Vue 的做法是把数组和对象分开进行不同的响应式处理,这就不是本文所要讲的内容了。
最后,我们学习一个框架,了解源码不是必需的,确是必要的,这样在遇到疑难杂症的时候就能知道是框架本身的机制,还是代码逻辑的问题。除此之外,Vue 本身的设计思路和编码方式其实也是一个很好的学习对象。
]]>作为一个 5 年的老用户,连 Enthusiast
徽章都没有拿到实属惭愧。但是今天,我立下一个 Flag,让所有看的这篇的读者都能轻松的拿到 Fanatic
徽章!
先补充一下徽章的含义,Enthusiast
的获得条件是连续登录 Stack Overflow 30 天,Fanatic
是连续 100 天。看似简单,实则连续一周都难得要死,毕竟一般人遇到问题了才会去访问这个网站,谁能连续 100 天都有问题呢?除非你想回答问题。
获得这些徽章有什么好处呢?显然,可以装逼。
好的,先上源代码;
Nodejs
的内容没什么特别的,无非就是登录,获取用户 token,缓存,获取当前进度。重点在于,代码仓库使用了 Github Actions
,等于白嫖一台服务器,只需要简单 3 步,即可部署完毕:
fork
我的代码仓库。Actions
标签页,允许 fork
来的 workflow
。Settings
> Secrets
,设置你的 Stack Overflow 的邮箱账号和密码到 Repository Secret
,注意不是 Environment Secret
。对应的 key
分别是 FC_EMAIL
和 FC_PASSWORD
。完成,简单吧,100 天后,你就是拥有 Fanatic
黄金徽章的仔!
遇到任何问题,欢迎到这里反馈给我👏。
]]>这个故事里有三个关键词:显卡、矿、涨价。英伟达 30 系列显卡在短时间内价格翻倍,大家都戏称现在的显卡是理财产品。
Nvidia 官方指导价
如今市场价
如今二手市场价
综合各路分析,导致显卡价格上涨主要有三种原因:
供给下降,需求增多本身就会导致上涨,再加上人为因素,价格不高才怪。看看知乎很多关于显卡的回答和文章都在说现在不是买入时机,太亏了,要等价格下跌。但是我认为,还有另一条路可以走:你挖过矿吗?
我手头正好有一张 GTX1070,现在二手价格是 2400 左右,我们来算一笔账。
如果在矿池里挖 ETH,一张 GTX1070 的算力大概是 28.78 MH/s,用 Ethash 算法每日期望能挖到 0.00185 个 ETH,根据现价 12100 RMB/ETH,每天的收益为 22.385 RMB,一张 GTX1070 的挖矿功耗通常为 125w,加上其他用电设备的功耗,总体大概为 200w,每天耗电 4.8 度,电费按贵的算 1.5 RMB/kWh,日运行费用为 7.2 RMB,日净利润 15.185 RMB,按 15 算,每个月能赚 450 RMB。这意味着你买一张二手 1070,5 个月回满血。
好了,再来看 RTX3070,我心心念念的显卡,算力为 61.79 MH/s,功耗下降 25w,理论日净利润 42 RMB,月入 1260,按照影驰金属大师的 5560 RMB 算,4.4 个月即可回满血。
再看当今显卡之王 RTX3090,算力为 121.16 MH/s,功耗上涨 175w,理论日净利润 81 RMB,月入 2430,按照七彩虹战斧的 13999 RMB 算,5.8 个月回满血。
灵魂拷问:今年你的工资涨 2430 元了吗?
当然以上均为理论数值,我拿自己的 1070 做了个实验,实际日净利润为 11 元,至少咖啡自由了。过程中还边挖矿边在召唤师峡谷里无限乱斗,挖矿配置也还可以优化,显卡可以一键超频,利润还有上涨空间。
很多人买显卡是为了打游戏和视频处理,对于英雄联盟这种低要求游戏可以边玩边挖矿,但在玩高配置视频游戏的时候,还是需要中断挖掘的,顶多把回血时间后调半个月嘛。
结论:现在,以 5560 元的价格买一张 RTX3070,挖 ETH 回血,可行!
注意,以下内容非常重要!
也许你看了上面的分析热血上头跃跃欲试,仅仅投入 14000 就可以得到 2610 元每月的现金流,先冷静一下,看看下面几点:
最后,希望大家都能买到心仪的显卡,有光追的夜之城,很美 :)
]]>没遇到过的话,可以打开浏览器的开发者工具,在控制台输入 new Date('2020-01-01').getMonth()
你得到的结果会是 0。
有个叫 Hillel is on vacation 的大佬在帖子下详细的描述了他探索这个问题答案的过程,从现代 C 语言的 time.h 开始,最早追溯到 1964 年的 Multics 分时操作系统源码,令人拍案叫绝。
整个过程简单地用流水账记一下:
首先,Javascript 这么做纯粹是因为借鉴了 C 语言。
在 The C Programming Language 一书中写到,time.h 里有个叫 tm 的结构体,里面除了 tm_mday(day of the month) 是从 1 开始计算的,其它都是从 0 开始。为什么它这么特殊呢?在它的背后一定有故事。
在 The C Programming Language 第二版中有提到,time.h 来自 ANSI 标准 C89,关于 C89 有个有趣的事情,它需要兼容过去 17 年的所有不同版本的编译器,但是编译器似乎和问题的答案无关。
time.h 遵循 POSIX 标准,该标准用于在各种 Unix 操作系统上运行软件,于是开始对比各种版本的 Unix。令人惊讶的是,几乎所有不同的 Unix 操作系统都使用了相同的 tm 结构体,说明这是一个非常非常古老的问题。
有趣的是,1974 年的 Unix 5 根本就没有 time.h 而是 ctime.c, 从 Unix 7 开始才出现 tm 结构体。
Unix 5 的 day of the month 仍然是从 1 开始计数的。
比 Unix 5 更早的版本已经没有记录了,到此毫无进展,想到 Unix 的灵感来源于 Multics,于是查阅 Multics 的源码,所有的时间都是从 1 开始计数的,看来和问题的答案无关。
回到 Unix 5,查看实现方式,终于有了大胆的猜测:当时的计算机内存只有几 KB,因此,程序员们需要在有限的资源内完成计算,为了优化月份和 day of the week 的复杂指针计算,就把它们从 0 开始计数,而其它的只是用于展示,所以就怎么方便怎么来,所以出现了 day of the month 从 1 开始计数。
流水账终于记完了。
虽然看不懂这些古老的指针,但却有一种拨开云雾见天明的感觉。
为了方便你们阅读,特地整理了相关术语表:
当然,本篇文章需要你有一点 python
基础,如果没有的话,建议你先收藏,去找一些教程学习一下这门工具人语言。
好了,废话不多说,马上开始。
首先,导入所需要的包:
import queueimport timeimport threadingimport requestsimport pymongoimport loggingimport os# 配置用于日志打印的 logger,纯属个人爱好,你可以用 print 代替logging.basicConfig(level=logging.INFO)logger = logging.getLogger()
把获取的数据存入 MongoDB 中,为什么选择 MongoDB?因为非关系型数据库比较简单,我们用到的数据结构也不复杂,开发起来比较快。
if __NAME__ == '__MAIN__': try: # 打开数据库连接 logger.info('Connecting to MongoDB...') client = pymongo.MongoClient(MONGODB_URI) logger.info('Successfully connected!') # 在此进行爬虫逻辑 # 关闭数据库连接 logger.info('Closing MongoDB...') client.close() logger.info('Successfully closed!') except Exception as e: logger.error(e)
用 Chrome 浏览器的开发者工具对知识星球 PC 端的网络请求进行观察,发现获取星球话题的请求只有一个,我们把它赋值给 BASE_URL
。同时发现登录的 token
就在 cookie
里面: zsxq_access_token
,啧啧,太明显了。
GROUP = '15281148525182' # 星球idBASE_URL = 'https://api.zsxq.com/v1.10/groups/{}/topics'.format(GROUP)# 构造全局请求头headers = { 'cookie': '换成你的 Cookie', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36'}
分析话题数据,可以归纳总结出以下结论:
talk
是普通话题,只有 1 条内容,q&a
是问答,问答含有提问和回答 2 条内容。我的 CPU 有 4 个核心,考虑到文本、图片、文件出现的频次和下载时间,多线程设计如下:
topic_q
、images_q
、files_q
,分别存取 end_time
、图片信息、文件信息,分别用于获取话题信息、下载图片、下载文件。为了能让你更好地理解,我画了一副流程图,可以配合流程图来理解代码,事半功倍。
根据上面的分析,创建 3 个队列,4 个线程,并把下面的代码放到连接、关闭数据库代码的中间:
# 任务队列topic_q = queue.Queue()image_q = queue.Queue()file_q = queue.Queue()# 开启获取 topics 的线程t = threading.Thread(target=get_topics_thread)t.setDaemon(True)t.start()# 开启获取 images 的线程t = threading.Thread(target=get_images_thread)t.setDaemon(True)t.start()# 再开启一个获取 images 的线程t = threading.Thread(target=get_images_thread)t.setDaemon(True)t.start()# 开启获取 files 的线程t = threading.Thread(target=get_files_thread)t.setDaemon(True)t.start()# 把第一个任务添加进队列topic_q.put(None)# 等待任务队列结束topic_q.join()image_q.join()file_q.join()
下面是各个线程函数,作用是不断的从对应任务队列中取出参数并执行处理方法,fetch_topics
、fetch_images
、fetch_files
分别是下载对应内容的方法。
# 话题线程def get_topics_thread(): while True: job = topic_q.get() fetch_topics(job) # time.sleep(1) topic_q.task_done()# 图片线程def get_images_thread(): while True: job = image_q.get() fetch_images(job) # time.sleep(1) image_q.task_done()# 文件线程def get_files_thread(): while True: job = file_q.get() fetch_files(job) # time.sleep(1) file_q.task_done()
创建 fetch_topics
方法,用来发送获取星球话题的请求,上面已经设置好了 BASE_URL
,这里设置请求参数即可。
观察发现,API 的参数有 3 个,分别是:
scope
:话题范围,例如:精华话题还是图片话题。all
代表全部话题。count
:返回的话题数量,网站里默认 20 个,但经测试,30 个也能正常返回,40个以上报错。end_time
:关键参数,知识星球通过它来分页,不填则返回最新的 count
个话题,比如 20,如果你想得到第 21 - 40 个话题,那么就需要设置 end_time
为第 20 条话题的创建时间,并且要把创建时间的毫秒数减 1。# 调用一次该方法,就请求一次 API,根据 end_time 参数的值来控制返回的话题def fetch_topics(end_time=None): # 设置参数为全部话题,返回话题数量为 30 个 params = { 'scope': 'all', 'count': '30', } if end_time != None: params['end_time'] = end_time # 发送请求 r = requests.get(BASE_URL, headers=headers, params=params, allow_redirects=False) # 打印请求地址,用来 debug print(r.url) d = r.json() # 异常处理,如果服务器返回错误,则等候 15 秒,把 end_time 压入话题队列 if d['succeeded'] == False: logger.error('get topics error, url: {}, params: {}'.format(BASE_URL, params)) time.sleep(15) topic_q.put(end_time) return # 返回的话题数量为 0,说明已经爬取完毕,直接结束方法 if len(d['resp_data']['topics']) == 0: logger.info('Fetch topics done!') return 'done' # 到这里说明一切正常,把得到的话题数据全部存入 MongoDB try: db = client['zsxq'] collection = db['topics_{}'.format(GROUP)] insertItems = [{ 'raw_data': topic, 'topic_id': topic['topic_id'] } for topic in d['resp_data']['topics']] insertResult = collection.insert_many(insertItems, ordered=True) logger.info(str(len(insertResult.inserted_ids)) + ' documents were inserted') except Exception as e: logger.error('Insert to mongodb error, related page {}'.format(r.url)) logger.error(e) # 循环处理每一条话题数据,get_images 和 get_files 为把图片和文件的信息分别压入图片队列和文件队列 for topic in d['resp_data']['topics']: # 类型为 talk if topic['type'] == 'talk': if 'talk' in topic: get_images(topic['talk']) get_files(topic['talk']) # 类型为 q&a elif topic['type'] == 'q&a': if 'question' in topic: get_images(topic['question']) get_files(topic['question']) if 'answer' in topic: get_images(topic['answer']) get_files(topic['answer']) else: # debug 专用,因为不确定是否含有除 talk 和 q&a 以外的话题,如果有,则打印出来,方便处理 print(topic) # 到这里,说明得到的话题都处理过了,下面就要处理 end_time,然后把 end_time 压入话题队列 end_time = d['resp_data']['topics'][len(d['resp_data']['topics']) - 1]['create_time'] tmp = str(int(end_time[20:23]) - 1) while len(tmp) < 3: tmp = '0' + tmp end_time = end_time.replace('.' + end_time[20:23] + '+', '.' + tmp + '+') topic_q.put(end_time)
图片可能包含三种类型:thumbnail
缩略图、large
大图、original
原图,不一定全都有,因此在下载前要判断。
def fetch_images(img_info): # 下载图片函数 def download(url, image_id, type, subfix): # 设置目标文件位置 target_dir = './images/{}/{}.{}'.format(image_id, type, subfix) # 文件夹不存在的话,则创建文件夹 if not os.path.exists(os.path.dirname(target_dir)): try: os.makedirs(os.path.dirname(target_dir)) except Exception as e: logger.error(e) # 下载 with open(target_dir, "wb+") as file: response = requests.get(url) file.write(response.content) # 下面把图片保存的位置存在 MongoDB 中,和原文的 id 和类型对应。 try: db = client['zsxq'] collection = db['images_{}'.format(GROUP)] insertItem = { 'symbol': '{}_{}'.format(image_id, type), 'image_id': image_id, 'type': type, 'url': 'url', 'target_dir': target_dir } result = collection.insert_one(insertItem) logger.info('1 document was inserted into images_{} collection with the _id: {}'.format(GROUP, result.inserted_id)) except Exception as e: logger.error('download image failed, image_id: {}, type: {}'.format(image_id, type)) logger.error(e) # 下面处理不同类型的图片,并调用上面的下载方法 if 'thumbnail' in img_info: download(img_info['thumbnail']['url'], img_info['image_id'], 'thumbnail', img_info['type']) if 'large' in img_info: download(img_info['thumbnail']['url'], img_info['image_id'], 'large', img_info['type']) if 'original' in img_info: download(img_info['thumbnail']['url'], img_info['image_id'], 'original', img_info['type']) # 由于图片下载比较慢,每下载一组打印一次剩余图片数量,让自己知道当前进度 print('Remain: {}'.format(image_q.qsize())) pass
知识星球 PC 端是无法下载文件的,我用手机抓包后才得到了下载地址:
def fetch_files(file_info): # 下载文件函数 def download(url, filename): # 文件夹不存在的话,则创建文件夹 if not os.path.exists(os.path.dirname(filename)): try: os.makedirs(os.path.dirname(filename)) except Exception as e: logger.error(e) # 下载 with open(filename, "wb+") as file: response = requests.get(url) file.write(response.content) # 下面把文件保存的位置存在 MongoDB 中,和原文的 id 对应。 try: db = client['zsxq'] collection = db['files_{}'.format(GROUP)] insertItem = { 'file_id': file_info['file_id'], 'name': file_info['name'], 'target_dir': filename } result = collection.insert_one(insertItem) logger.info('1 document was inserted into files_{} collection with the _id: {}'.format(GROUP, result.inserted_id)) except Exception as e: logger.error('download file failed, file_id: {}, file_name: {}'.format(file_info['file_id'], file_info['name'])) logger.error(e) # 这里就是获取下载地址的 API,在手机上抓包得到的 url = 'https://api.zsxq.com/v1.10/files/{}/download_url'.format(file_info['file_id']) r = requests.get(url, headers=headers) d = r.json() # 异常处理,打印错误,然后直接结束方法 if d['succeeded'] != True: logger.error('fetch file download information failed, target: {}'.format(file_info)) return # 得到下载地址后,执行下载 download(d['resp_data']['download_url'], './files/{}/{}'.format(file_info['file_id'], file_info['name'])) pass
以上就是今天的实战指南。最后是你们最关心的哪里下载源码?老实说,我能给你的最好建议其实是按照上面的例子自己敲一遍,真的很管用,学编程就是要动手。
注:所有代码均基于 python 3.6.5 版本,使用其他版本可能无法运行。
如何下载源码以及更多的编程资源?只需简单 2 步:
2019年7月30日,Chrome 发布了 76 版本,标志着 prefers-color-scheme
被大部分的现代浏览器所支持。
prefers-color-scheme
是一种媒体特征(Media Feature),在媒体查询(Media Query)中使用,用于检测用户是否有要求浏览器使用浅色(light)或者深色(dark)模式。
该特征只有 2 种可能的值:dark
和 light
。
因此,我们可以这样设置颜色:
/* 深色模式 */@media (prefers-color-scheme: dark) { body { background-color: black; }}/* 浅色模式 */@media (prefers-color-scheme: light) { body { background-color: white; }}
这样设置会导致网站主题随着用户系统主题的改变而改变,如果使用场景需要允许用户自定义主题,那么单用 CSS 来控制就不够了,还需要配合 Javascript。
那么 js 如何获取媒体查询的结果呢?
在 window 对象中有这样一个方法:matchMedia
,几乎所有浏览都支持它,可以放心的使用。这个方法只接受一个参数,就是媒体查询的字符串,并返回一个 MediaQueryList
对象实例。该对象有只有 3 个属性:
// 本例通过设置 body 的 classname 来控制主题颜色const mql = window.matchMedia('(prefers-color-scheme: dark)');if (mql.matches) { // 当前为深色模式 document.body.classList.add('dark-mode');} else { // 当前为浅色模式 document.body.classList.remove('dark-mode');}
如果需要可以根据系统主题的改变而自动切换模式的功能,那么就可以设置 onchange 事件的回调函数,当媒体查询结果改变时,回调函数就会被触发,可以在函数内部进行主题切换。
// 当设置要根据系统主题自动切换模式,执行以下代码const mql = window.matchMedia('(prefers-color-scheme: dark)');// 设置 onchange 事件的回调函数mql.onchange = function (evt) { if (evt.matches) { // 当前为深色模式 document.body.parentElement.classList.add('dark-mode'); } else { // 当前为浅色模式 document.body.parentElement.classList.remove('dark-mode'); }}
再配合一键全站切换深色模式的魔法代码,即可快速实现根据系统主题选择用浅色还是深色模式的功能。
/* 魔法代码:一键全站切换深色模式 */html.dark-mode { filter: invert(1) hue-rotate(180deg);}
(完)
]]>nodejs
写爬虫的一些常用的技巧,并且表示写爬虫没有最好的语言,只有最合适的,选择自己最熟悉的最顺手的语言最好。然而,最近在爬取一些数据的过程中需要处理 HTML 表格,虽然可以自己手动处理,但是有现成的轮子干嘛不用?写爬虫就是为了获取数据,经过数据的分析处理得到有趣的结论,为了能够尽快的得到结论,根据场景选择合适编程语言就显得很重要,尤其当所需要的工具是语言独占的时候(其他语言也许有移植版,但是社区活跃度显然没有原版高),比如说 pandas
。想把表格数据转换成 dataframe
只需要一句话:
# 引入 pandasimport pandas as pd# 简单的一句话,即可将 html 转换成含有 dataframe 的列表df_list = pd.read_html(html)# 对每个 dataframe 也就是每张表格做处理for df in df_list: print(df)
pandas
的这个功能真的深深的戳中了我的痛点,于是把整个爬虫用 python
重新写了一遍,这里也和上一次总结 nodejs
一样,总结一下 python
写爬虫的常用方法,方便以后复用,提高效率。
传送门:用 nodejs 写爬虫的常用技巧、pandas 快速入门
注:本文代码均基于 python3.6.5 版本,使用其他版本有可能导致运行错误。
python
请求网页一般会使用 requests 这个库,正如其介绍的那样:built for human beings。相当的通俗易懂,简单易用。
import requests# get 请求r = requests.get(url)# 带参数payload = { 'key': 'value'}r = requests.get(url, params=payload)# post 请求r = requests.post(url)# 带 form 参数form_data = { 'key': 'value'}r = requests.post(url, data=form_data)# 带 json 参数json_data = { 'key': 'value'}r = requests.post(url, json=json_data)
其实伪装浏览器的本质就是修改请求头(request header),来让服务器难以辨别请求是来自爬虫还是真实用户的浏览器。通常设置 User-Agent
即可应对大部分网站。
headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.75 Safari/537.36'}r = request.get(url, headers=headers)
这里就涉及到 requests
发送请求后的返回值了,也就是上面例子中的 r
。用户存储在浏览器的 Cookies 通常是浏览器根据返回头(response header)中的 Set-Cookie
的值来设置的,在 python
中这样获取:
r = request.get(url)cookie = r.headers.get('set-cookie').split(';')[0].strip()
nodejs 的技巧文章中解释过,自动重定向可能会使用不正确的 Cookie 导致结果永远不对,因此有时需要取消这个功能:
r = requests.get(url, allow_redirects=False)
我喜欢使用 beautifulsoup:
from bs4 import BeautifulSoupr = requests.get(url)# 这个 html.parser 是解析器,你也可以设置其他的,比如:lxml,如果你要解析 XML 文档,那么你就要把它设置为:lxml-xml。当然使用前要安装 lxml。soup = BeautifulSoup(r.text, 'html.parser')# 例:找到并打印页面中的所有超链接for item in soup.find_all('a'): print('{} - {}'.format(item.get_text(), item.get('href')))
难免的情况,不论是 IP 被封,还是外网无法访问,均要使用代理来绕过限制:
教你科学上网:正确的科学上网方式
proxies = { 'http': 'http://127.0.0.1:6153/', 'https': 'http://127.0.0.1:6154/', }# 简直是直观的不能再直观了r = requests.get(url, proxies=proxies)
看了 nodejs 版本的朋友需要注意下,这里是多线程了,不是多进程。这里就提供一个线程池的模板:
import queueimport timeimport threading# 任务队列q = queue.Queue()# 线程数THREADS = 4# 任务列表JOBS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]# 单个任务的处理方法def run(job):print(job)# 线程方法,不断的从任务队列中取出参数并执行处理方法def thread_function():while True:job = q.get()run(job)time.sleep(1)q.task_done()# 开启 THREADS 个线程for i in range(THREADS):t = threading.Thread(target=thread_function)t.setDaemon(True)t.start()# 把任务添加进队列for param in JOBS:q.put(param)# 等待任务队列结束q.join()
python
爬虫我写的不多,本文只能分享这些基本常用的方法,日后有更多经验再来补充~
本文主要涉及的是工厂方法,它属于创建型模式,按照抽象程度可以划分为三种:
简单工厂模式提供统一的创建对象的方法,并决定实例化对象的类型。就是由调用者来决定创建哪种对象,一个很好的比喻就是自助餐厅里的饮料机,一台机器可以产出各种饮料,具体是要可乐还是雪碧由顾客决定。
我们以物流为例,假如我们是一家物流公司,现有两种方式运输:卡车陆运和轮船海运。
// 产品基类:运输方式class Transport { deliver() {};}// 卡车class Truck extends Transport { deliver() { console.log('Delivered by truck'); };}// 轮船class Truck extends Transport { deliver() { console.log('Delivered by ship'); };}// 工厂基类:物流公司class Logistics { // 运输方式由调用方选择 static createTransport(type) { switch (type) { case 'truck': return new Truck(); case 'ship': return new Ship(); } }}// 调用方选择使用卡车陆运Logistics.createTransport('truck').deliver(); // 输出:Delivered by truck
这时业务扩展,需要新增飞机空运,那么需要怎么做呢?修改工厂基类,这违反了设计模式的开闭原则(Open Closed Principle),该原则是指软件应该对扩展开放,而对修改关闭。如果业务复杂一些就不合适了,而工厂方法模式就是该问题的解决方案。
工厂方法模式是由父类提供创建对象的方法,允许子类决定实例化对象的类型。
// 修改工厂基类:物流公司class Logistics { createTransport() {}}// 陆运class RoadLogistics extends Logistics { createTransport() { return new Truck(); }}// 海运class SeaLogistics extends Logistics { createTransport() { return new Ship(); }}// 调用者选择使用卡车陆运new RoadLogistics().createTransport().deliver(); // 输出:Delivered by truck
这时业务扩展,需要新增飞机空运,只需要新增一种产品的子类和一种工厂的子类即可:
// 飞机class Airplane extends Transport { deliver() { console.log('Delivered by airplane'); };}// 空运class AirLogistics extends Logistics { createTransport() { return new Airplane(); }}// 调用者选择使用飞机空运new AirLogistics().createTransport().deliver(); // 输出:Delivered by airplane
又由于业务需要,需要加入不同品牌的卡车和轮船,工厂方法模式已经无法满足需求了,这时抽象工厂模式适时的出现。
抽象工厂模式它能够创建一系列对象,但无需指定其具体类。这个模式比较复杂,在 javascript 中比较难以理解,因为 javascript 没有抽象类的概念。在此我尽量用语言描述,结合下面的代码应该能够较好的解释。
在 java 中(不是 javascript),可以定义一种抽象类,里面定义了一个抽象类必要的接口(interface),子类必须要实现(implements)这些接口,因此可以保证所有的子类都拥有接口里定义的方法。而所谓抽象工厂模式,就是拥有多个抽象产品类(如巨头A公司和巨头B公司的运输工具),并实现多个产品(如巨头A公司的卡车和轮船),同时拥有一个抽象工厂类(如我们的物流公司),并实现多个工厂(如陆运和海运方式),每个工厂将会生产有多种同类的产品(如陆运方式可以派出巨头A公司的卡车和巨头B公司的卡车)。有点晕?没事,程序员的母语是代码,我用代码再解释一遍。
// 第一个抽象产品类:巨头A公司class MagnateATransport { deliver() {}}// 巨头A的卡车class MATruck extends MagnateATransport { deliver() { console.log('Delivered by Magnate A truck'); }}// 巨头A的轮船class MAShip extends MagnateATransport { deliver() { console.log('Delivered by Magnate A ship'); }}// 第二个抽象产品类:巨头A公司class MagnateBTransport { deliver() {}}// 巨头B的卡车class MBTruck extends MagnateBTransport { deliver() { console.log('Delivered by Magnate B truck'); }}// 巨头B的轮船class MBShip extends MagnateBTransport { deliver() { console.log('Delivered by Magnate B ship'); }}// 唯一的抽象工厂类:我们的物流公司,当中有两个接口,分别是使用巨头A的运输工具和使用巨头B的运输工具class Logistics { createMATransport() {} createMBTransport() {}}// 陆运方式:可以派出巨头A卡车和巨头B卡车class RoadLogistics extends Logistics { createMATransport() { return new MATruck(); } createMBTransport() { return new MBTruck(); }}// 海运方式:可以派出巨头A轮船和巨头B轮船class SeaLogistics extends Logistics { createMATransport() { return new MAShip(); } createMBTransport() { return new MBShip(); }}// 调用者只需关心抽象工厂类,如选择陆运并巨头B公司的运输工具new RoadLogistics().createMBTransport().deliver(); // 输出:Delivered by Magnate B truck
平时编写代码的过程中很可能有意无意的使用了某种设计模式,因为设计模式就是这样在实践经验中总结出来的,它不仅仅能够提高你的软件的可维护性可扩展性,还可以作为团队内高效的沟通工具,只需陈述模式名,大家便都理解这背后的想法。
当然,设计模式是为了解决问题而存在的,通常这些问题会在程序结构到达一定规模时出现,在简单的项目里强行使用设计模式也许是一种本末倒置的做法,初学者要注意不要为了设计而设计。
]]>这里总结一些用 nodejs 写爬虫的常用手段,学会了,就能爬取大部分网页了。开发爬虫的技巧很多也是复用的,记录下来,日后能省不少事。
用 got 发送 Get 和 Post 请求,返回值均为 Promise,可以使用 async/await 和 Promise.all 来控制流程。
// 习惯使用 gotconst got = require('got');// getclient.get(url);// post formdataconst FormData = require('form-data');const form = new FormData();form.append('name', 'admin');got.post(url, { body: form});// post jsongot.post(url, { json: { page: 2 }, responseType: 'json'});
通过设置 User-Agent 来模拟浏览器行为。现在很多服务器都会检查 User-Agent 来进行初步的反爬,设置 User-Agent 是很重要的一步。
got.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.75 Safari/537.36' }})
也可以通过下面的方式,来为每个请求带上 User-Agent,当然其他参数也是一样的。
// 所有通过 client 发起的请求均会带上 extend 里传入的参数const client = got.extend({ headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.75 Safari/537.36' }});client.get(url);
这里要说明一点,got 默认会自动进行重定向,并返回重定向后的返回值,而部分网站需要处理动态的 Cookie 才能得到正确的结果,自动重定向可能会使用不正确的 Cookie 导致结果永远不对,因此还是老老实实手动重定向吧。
client.get(url, { followRedirect: false // 取消自动重定向});
Cookie 通常用于用户的身份鉴别,偶尔也用于加密反爬。写爬虫经常需要和它打交道,但是读取 Cookie 的值不是很方便,这里提供一个方法来简单的获取所需要的 Cookie 值,该方法同样适用于获取 Set-Cookie 的值。
// 来自 stackoverflow: https://stackoverflow.com/questions/5142337/read-a-javascript-cookie-by-name// 稍微做了点修改function getCookie(cookiename, cookies) { // Get name followed by anything except a semicolon let cookiestring = RegExp(cookiename+"=[^;]+").exec(cookies); // Return everything after the equal sign, or an empty string if the cookie name not found return decodeURIComponent(!!cookiestring ? cookiestring.toString().replace(/^[^=]+./,"") : "");}
做爬虫也要讲文明,要在不影响对方服务的前提下进行,所以要控制爬虫的速率。但是 nodejs 本身并没有像 python 那样的 time.sleep() 方法,因此要自己实现一个。
function sleep(time = 0) { return new Promise((resolve, reject) => { setTimeout(() => { resolve(); }, time); });}
可以使用正则表达式来解析页面,比任何解析器都要强大。
想学习和在线测试正则表达式,强烈推荐到 RegExr,全是简单的英文,很好懂。
除了正则,npm 上有很多做 HTML 解析的包,最常用也最多人用的就是 cheerio。
用法很简单,和 jquery 一摸一样。
const cheerio = require('cheerio');// 设置 decodeEntities 为 false,为了防止中文被解码。const $ = cheerio.load(html, { decodeEntities: false });// 然后就把 $ 当作 jquery 用就好了$('body').text();
当然,你也可以用 jquery,只不过需要 mock window 和 document 对象,所以需要配合 jsdom 来食用。
const { JSDOM } = require( "jsdom" );const { window } = new JSDOM( "" );const $ = require( "jquery" )( window );
使用爬虫经常会碰到 IP 被封的情况,各种原因,要看具体的反爬机制,要是不幸中招,就需要使用代理来换一个 IP 继续爬。此外,一些国外网站可能被 GFW 阻挡,导致无法访问,如:P站,这时候就要使用魔法上网,同样需要通过代理的方式进行。
教你魔法上网:正确的科学上网方式
got 有一个 agent 参数,配合 tunnel 可以实现代理。
const tunnel = require('tunnel'); got.get(url, { agent: { https: tunnel.httpsOverHttp({ proxy: { host: '127.0.0.1:6152' // 代理地址及端口 } }) }});
javascript 是单线程的,nodejs 因此也一样,但是现在普遍多核的大背景下,nodejs 早就意识到了这点,为了能够充分的“压榨”机器的性能,nodejs 推出一个内置模块 – cluster。没错,单机集群,由一个父进程管理一群子进程。
这样就可以通过多个子进程的方式来实现并行爬取。
const cluster = require('cluster');const numCPUs = require('os').cpus().length; // cpu 核心数if (cluster.isMaster) { for (let i = 0; i < numCPUs; i++) { cluster.fork(); }} else { // 爬虫逻辑}
内容不多,但是非常实用,至少我自己在日常开发爬虫的过程中经常使用,希望对各位有帮助吧。
]]>废话不多说,直奔主题。
我们只有两个点的相对偏移量(offset),思路就是以这两个点作为对角,创建一个绝对定位的 Canvas,然后在两点中绘制一条曲线(Curve),最后在终点处绘制箭头(Arrow)。
因此分为 3 步:
先确定 Canvas 的绝对定位偏移量,因为是任意两点,所以对角可能是左上加右下,也可能是左下加右上,不论是哪一种,它的左偏移量一定是两个点的左偏移量的最小值,同理,上偏移量也是两个点的上偏移量的最小值。
再确定 Canvas 的宽和高,宽等于两点左偏移量之差的模,长等于两点上偏移量之差的模。
// 随机的起始点和终点,这里不考虑边缘情况,实际生产环境下,相近的两点应该很少会有加指向性箭头的需求const sp = { left: Math.floor(window.innerWidth * Math.random()), top: Math.floor(window.innerHeight * Math.random()) };const ep = { left: Math.floor(window.innerWidth * Math.random()), top: Math.floor(window.innerHeight * Math.random()) };const canvas = document.createElement('canvas');canvas.style.position = 'absolute'; // 设置绝对定位canvas.style.left = Math.min(sp.left, ep.left) + 'px'; // 设置左偏移量canvas.style.top = Math.min(sp.top, ep.top) + 'px'; // 设置右偏移量canvas.width = Math.abs(sp.left - ep.left); // 设置宽度canvas.height = Math.abs(sp.top - ep.top); // 设置高度// 顺便为 Canvas 加个红色的边框,方便 debugcanvas.style.border = '1px solid red';// 把 Canvas 放到 body 中document.body.appendChild(canvas);
Canvas 中绘制曲线很简单,API 中已经提供了贝塞尔曲线(Bezier Curve)的绘制方法。
而控制点的掌握…全靠经验 :)
这里提供一个很简单,很好算的控制点,绘制出的曲线效果也非常好。
const ctx = canvas.getContext('2d'); // 获取 Canvas 上下文// 下面求各点在 Canvas 中的坐标sp.x = sp.left - Math.min(sp.left, ep.left);sp.y = sp.top - Math.min(sp.top, ep.top);ep.x = ep.left - Math.min(sp.left, ep.left);ep.y = ep.top - Math.min(sp.top, ep.top);// 算贝塞尔曲线的控制点坐标,很简单,只需要把起始点和终点的 x 相加除以 3, y 永远和起始点的 y 一致// 这样向左和向右的箭头不会是一样的曲线,显得不那么死板const cp = { x: (sp.x + ep.x) / 3, y: sp.y};ctx.beginPath();ctx.moveTo(sp.x, sp.y);ctx.quadraticCurveTo(cp.x, cp.y, ep.x, ep.y);ctx.strokeStyle = '#FB9845';ctx.lineWidth = '3';ctx.stroke();ctx.closePath();// 绘制出控制点到终点的连线,方便 debugctx.beginPath();ctx.moveTo(cp.x, cp.y);ctx.lineTo(ep.x, ep.y);ctx.strokeStyle = 'red';ctx.lineWidth = '1';ctx.stroke();ctx.closePath();
绘制箭头的步骤稍微复杂一点点,因为涉及到数学运算。
本人对贝塞尔曲线并没有深入的研究,但是通过观察发现控制点到终点的连线近似曲线在终点处的切线,可以作为箭头的中线来使用。
所以问题被转化为求控制点顺时针和逆时针旋转特定角度后的坐标。这个角度我们取 20,别问,问就是好看。
涉及到旋转,就要理解参照系,为了简化计算,我们把终点作为原点,那么终点在不同的角上,我们所使用的坐标系是不同的,因此需要有坐标转换的方法。
// 把 Canvas 坐标转换成旋转计算所使用的坐标,接收 1 个参数,需要转换的点 pfunction coordEx(p) { const result = {}; if (ep.x < sp.x && ep.y < sp.y) { result.x = p.x; result.y = p.y; } else if (ep.x < sp.x && ep.y > sp.y) { result.x = p.x; result.y = Math.abs(sp.top - ep.top) - p.y; } else if (ep.x > sp.x && ep.y < sp.y) { result.x = Math.abs(sp.left - ep.left) - p.x; result.y = p.y; } else if (ep.x > sp.x && ep.y > sp.y) { result.x = Math.abs(sp.left - ep.left) - p.x; result.y = Math.abs(sp.top - ep.top) - p.y; } return result;}// 把旋转计算用的坐标转换回 Canvas 坐标,用于绘图function coordRe(p) { const result = {}; if (ep.x < sp.x && ep.y < sp.y) { result.x = p.x; result.y = p.y; } else if (ep.x < sp.x && ep.y > sp.y) { result.x = p.x; result.y = Math.abs(sp.top - ep.top) - p.y; } else if (ep.x > sp.x && ep.y < sp.y) { result.x = Math.abs(sp.left - ep.left) - p.x; result.y = p.y; } else if (ep.x > sp.x && ep.y > sp.y) { result.x = Math.abs(sp.left - ep.left) - p.x; result.y = Math.abs(sp.top - ep.top) - p.y; } return result;}
有了转换后的坐标就可以开始计算了,向量关于原点的逆时针旋转计算公式:
向量关于原点的顺时针旋转计算公式:
const CURVE_ARROW_ANGLE = 20; // 旋转的角度const CURVE_ARROW_LENGTH = 26; // 绘制箭头线段的长度const ncp = coordEx(cp); // 转换控制点坐标// 计算逆时针旋转后的坐标const nlp = { x: ncp.x * Math.cos((CURVE_ARROW_ANGLE * Math.PI) / 180) - ncp.y * Math.sin((CURVE_ARROW_ANGLE * Math.PI) / 180), y: ncp.x * Math.sin((CURVE_ARROW_ANGLE * Math.PI) / 180) + ncp.y * Math.cos((CURVE_ARROW_ANGLE * Math.PI) / 180)};// 计算箭头线段长度和 nlp 到原点距离的比值,用于计算绘制箭头的坐标const lRate = CURVE_ARROW_LENGTH / Math.sqrt(nlp.x * nlp.x + nlp.y * nlp.y);// 把 nlp 的坐标转换为绘制箭头的坐标nlp.x = nlp.x * lRate;nlp.y = nlp.y * lRate;// 把绘制箭头的坐标转换回 Canvas 坐标const lArrowPoint = coordRe(nlp);ctx.beginPath();ctx.moveTo(lArrowPoint.x, lArrowPoint.y);ctx.lineTo(ep.x, ep.y);ctx.strokeStyle = '#FB9845';ctx.lineWidth = '3';ctx.stroke();
以上是绘制逆时针箭头的方法,同样的方法可以绘制顺时针箭头,如果顺利的话,现在你看到的图像应该是这样的:
也有可能不顺利…
这是绘制超出了 canvas 的范围,因此需要为 canvas 添加 padding。
可以通过增加 canvas 的长宽,同时调用 tranlate 方法来解决。
const PADDING = 20;// 修改上面设置 canvas 宽高的代码canvas.width = Math.abs(sp.left - ep.left) + PADDING * 2; // 设置宽度canvas.height = Math.abs(sp.top - ep.top) + PADDING * 2; // 设置高度// 修改上面设置 canvas 偏移量的代码canvas.style.left = Math.min(sp.left, ep.left) - PADDING + 'px'; // 设置左偏移量canvas.style.top = Math.min(sp.top, ep.top) - PADDING + 'px'; // 设置右偏移量// 并在获取 canvas 上下文之后,设置 tranlatectx.tranlate(PADDING, PADDING);
注释掉辅助线代码,即可获得一条完美的带箭头的曲线。
]]>这里分享一下我所使用的调试开发环境:Surge + Lightproxy。
Lightproxy 是阿里巴巴出品的一款基于 whistle 的本地代理抓包软件,它可以根据规则任意的修改请求的 request 和 response,甚至可以模拟各种网络异常。
Surge 作为系统的全局代理,它根据规则来分发路由。在开发的时候,我可以用 Surge 指定需要调试的路由走 Lightproxy 的代理,这样就可以实现本地调试接口的同时还能够科学上网。
]]>const map = {};// 键值对map['key1'] = 'value1';map['key2'] = 'value2';map['key3'] = 'value3';// 检查 map 是否有 'key1' 属性if (map.hasOwnProperty('key1')) { console.log('Map contains key1');}// 获取 'key1' 属性对应的值console.log(map['key1']);
但其实,Javascript 里也有内建的一种数据结构专门用来作为 HashMap 来使用:Map。Map 对象是 es6 的标准,因此在 es5 中要配合适当的 polyfill 来使用。不过不用担心,大部分现代浏览器都已经很好的支持了 Map 对象。
上面是 Map 对象在 MDN 中的列出的浏览器兼容性,可以看到,已经被微软抛弃的 IE11 部分支持,其他平台几乎都是完全支持,绝对可以放心食用。
下面我解释一下为什么要用 Map 而不是 Object。
Object 的键只能是 Symbol 对象或者字符串。而 Map 可以使用任意数据类型作为键,甚至可以用 Object 或者 Function,只有想不到没有做不到:
const map = new Map();const myFunction = () => console.log('I am Function');const myNumber = 666;const myObject = { name: 'objectValue', otherKey: 'otherValue'};map.set(myFunction, 'Function 作为键');map.set(myNumber, 'Number 作为键');map.set(myObject, 'Object 作为键');console.log(map.get(myFunction)); // Function 作为键console.log(map.get(myNumber)); // Number 作为键console.log(map.get(myObject)); // Object 作为键
因为 Map 提供的一个叫做 size 的属性,用来统计键值对的数量,因此所需要的时间复杂度是 O(1)。而 Object 则需要通过 Object.keys() 来统计,所需要的时间复杂度是 O(n)。
const map = new Map();map.set('key1', 1);map.set('key2', 1);...map.set('key100', 1);console.log(map.size) // 100, 时间复杂度: O(1)const objMap = {};objMap['key1'] = 1;objMap['key2'] = 1;...objMap['key100'] = 1;console.log(Object.keys(objMap).length) // 100, 时间复杂度: O(n)
Map 针对高频地插入或者删除键值对的场景进行过优化。还有就是上一条说的,Map 可以瞬间获取键值对的数量,而 Object 则需要进行计算。
我在 Macbook Pro 2020 上进行过测试,1000 万个键值对,用 Map 和 Object 分别获取其键值对的数量,最终的平均耗时如下:
再说,使用过程中不需要把键转换成字符串,数据量大的情况下可以节约不少时间。
Object 必须先拿到所有键,然后才能进行迭代。而 Map 是可迭代的(iterable),这意味着可以直接进行迭代。
const map = new Map();map.set('key1', 1);map.set('key2', 2);map.set('key3', 3);for (let [key, value] of map) { console.log(`${key} = ${value}`);}// key1 = 1// key2 = 2// key3 = 3const objMap = {};objMap['key1'] = 1;objMap['key2'] = 2;objMap['key3'] = 3;for (let key of Object.keys(objMap)) { const value = objMap[key]; console.log(`${key} = ${value}`);}// key1 = 1// key2 = 2// key3 = 3
之前公司有一个项目,有大量的多层级分类要显示,一开始用 Object 存储来渲染,突然有一天这些分类需要按特定的顺序显示,用 Map 改起来不要太爽。不然就要转成字典数组或者新引进数组用来排序,不论哪一个都没有 Map 来的直接,来的优雅。
Javascript 中一个 Object 其实已经包含了一些属性(property),在使用的过程中可能会有属性冲突,而 Map 不存在这种问题。
⚠️注意:从 ECMAScript 2015 开始,你可以用 Object.create(null) 这样的方式创建纯净的对象,来避免属性冲突导致的问题,Vuejs 就是用的这种方法。
const map = new Map();map.set('key1', 1);map.set('key2', 2);map.set('toString', 3); // Map 不会导致 toString 原生方法被覆盖const objMap = {};objMap['key1'] = 1;objMap['key2'] = 2;objMap['toString'] = 3; // toString 原生方法没啦
]]>对于程序员,光第一条就有充分的理由使用 SS 了,因为 VPN 没法本地调试,除非你愿意不断的开关代理。
对于执着于 VPN 的朋友,这里有一个 2005 年就开始在中国生活的加拿大人做的网站,里面提供了各种 VPN 服务商的比较。Tip for China;
我建议自建 SS,有多种好处:
这里只推荐 Vultr。便宜(5 刀每月)、按时付费(0 成本换 IP)、支持支付宝(随心买)、随时开关服务器(换 IP 方便)。
操作系统请选择 ubuntu 18.04,Vultr 的该版本系统默认已经安装了 Google BBR(一种 TCP 拥塞控制算法,能够减少丢包率提升网速)。
如果选择了其他版本,并且有需要的话,可以按照链接里的步骤进行手动安装:Install Google BBR and Optimize the Server
ssh 连接进入云服务器后,先安装 C 版本的 shadowsocks:
$ sudo apt update$ sudo apt install shadowsocks-libev
然后修改配置文件:
$ vi /etc/shadowsocks-libev/config.json
// 按要求填入基本配置{ "server":"0.0.0.0", // 必须 0.0.0.0 或者服务器 IP "server_port":443, // SS 的监听端口,建议 443,运营商给的流量大一些 "local_port":1080, // 本地监听端口,默认就好 "password":"Passw0rd!", // 密码 "timeout":60, // 超时时间,默认就好 "method":"aes-256-gcm" // 加密方法,建议 ‘aes-265-gcm’}
最后一步,重启服务并设置自动启动即可。
$ sudo systemctl restart shadowsocks-libev.service$ sudo systemctl enable shadowsocks-libev.service
然后打开你的 SS 客户端,输入地址、端口、密码、加密方式即可开启科学上网之旅。
由于 GFW 可能会屏蔽你的服务器 IP,因此当 SS 无法使用的时候,就需要创建一个新的 Vultr 实例,然后删除之前的实例(顺序很重要,否则 IP 不会变),并重复部署的步骤,繁琐,相当繁琐。
为了更美好的明天,拿出更多时间陪女朋友,自动部署就很有必要。
好在 Vultr 提供了 Startup Scripts
功能,用于在部署操作系统后执行一个 Shell 脚本,只需要把以上部署的步骤依次写在脚本里,以后每次新建实例的时候选择该脚本即可。
$ # Shadowsocks Server 自动部署脚本$ sudo apt update$ sudo apt install shadowsocks-libev -y$ cat <<EOF > /etc/shadowsocks-libev/config.json$ {$ "server":"0.0.0.0",$ "server_port":443,$ "local_port":1080,$ "password":"Passw0rd!",$ "timeout":60,$ "method":"aes-256-gcm"$ }$ EOF$ sudo systemctl restart shadowsocks-libev.service$ sudo systemctl enable shadowsocks-libev.service
官网上的都推荐,由于需要翻墙才能打开,这里做一波搬运工:
pip install shadowsocks
brew install shadowsocks-libev
cpan Net::Shadowsocks
apt-get install shadowsocks-libev
cpan Net::Shadowsocks
opkg install shadowsocks-libev
opkg install shadowsocks-libev-polarssl
话说回来,这两天 Mac 突然连不上无线,手机能连能上网,电脑就不行,真是气人。一番折腾断定不是硬件问题,就拿去 Apple Store 抹掉所有数据重装系统了。
回来以后第一件事情就是装 Python 3.6.5,于是熟练的打开终端,输入以下神秘代码:
brew search python3.6
结果发现 HomeBrew 没了…
于是按照官网指南输入另一行神秘代码:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
结果:curl: (7) Failed to connect to raw.githubusercontent.com port 443: Connection refused
非常的不讲道理,也没有一点人性。
回到官网的神秘代码可以发现,其实就是执行一条远程的 bash 脚本,那个地址的返回结果就是脚本文件。所以我们直接通过浏览器配合科学上网工具打开 https://raw.githubusercontent.com/Homebrew/install/master/install.sh
,把内容复制粘贴到本地,文件就命名为 brew_install.sh
吧。
打开粗略的观察一下脚本,可以发现全文包含有 HomeBrew 的 github 仓库地址,如果我们直接使用这个脚本就会卡在 git clone 阶段(因为太慢了),所以找到BREW_REPO="https://github.com/Homebrew/brew"
这一行,替换成中科大的镜像 BREW_REPO="https://mirrors.ustc.edu.cn/brew.git"
。
给修改后的脚本可执行权限:
sudo chmod +x /path/to/brew_install.sh
运行脚本:
sh /path/to/brew_install.sh
结果:Cloning into '/usr/local/Homebrew/Library/Taps/homebrew/homebrew-core'...
又卡住了,没想到吧。
进入该目录 git remote -vv
发现,远程地址仍然是 github 的地址,于是 ctrl+c
结束脚本,通过中科大镜像来克隆仓库:
mkdir -p /usr/local/Homebrew/Library/Taps/homebrewcd /usr/local/Homebrew/Library/Taps/homebrewgit clone https://mirrors.ustc.edu.cn/homebrew-core.git/
克隆过程如丝般顺滑。
结束以后即可 brew -v
测试是否安装成功。
server { if ($scheme = 'http') { return 301 https://$host:9527$request_uri; } listen 9527 ssl; server_name zhuyan.io www.zhuyan.io; ...}
结果:
400 Bad Request
The plain HTTP request was sent to HTTPS port
nginx
每次必须手动在地址前加上 https,否则就 400,真的很过分。
一般情况下,会在 nginx 配置两个虚拟主机,一个监听 80 端口,标准的 http 端口,一个监听 443 端口,标准的 https 端口。这时候只需要把所有的 80 端口流量重定向到 443 端口就可以了。
server { if ($host = www.zhuyan.io) { return 301 https://$host$request_uri; } if ($host = zhuyan.io) { return 301 https://$host$request_uri; } listen 80; server_name zhuyan.io www.zhuyan.io; return 404;}server { listen 443 ssl; server_name zhuyan.io www.zhuyan.io; ...}
这种方法无法用在自定义的端口上,因为不允许配置相同端口的 2 台虚拟主机。
Nginx 提供的一个自定义的状态码 497,来专门处理这个问题,配置方法几乎和传统的配置一样:
server { server_name zhuyan.io www.zhuyan.io; listen 9527 ssl; error_page 497 https://$host:9527$request_uri; ...}
问题解决!现在所有访问 9527 端口的 http 流量都会被正确重定向到 https。
以上。
]]>Nginx 比较轻量,占用的资源少,可同时处理的请求多,Apache 拥有的模块多,即开即用,省事儿,但是意味着进阶的配置相对复杂。对于我的小网站来说 Nginx 足够了,加上公司的服务用的是 Nginx,比较熟悉,因此决定把服务器迁移成 Nginx。
总所周知,Wordpress 是 php heavy 的,但是 Nginx 不像 Apache 一样,打开 php 模块就能解释 php 脚本,要通过 cgi 协议和 php 解释器通信的迂回战术满足需求,因此需要在 Nginx 的中添加实现该协议的程序的配置。
php-fpm(php fastcgi process manger)就是这样一种程序,用于 web server 和 php 解释器通信,并高效管理通信进程。
相关的配置,可以参考 Nginx 官网的例子。而且只需要配置 80 端口,对 https 的支持通过 Cerbot 命令行工具自动配置就好了。不知道如何免费自动配置 https 的同学,可以参考我的这篇文章。
使用官方推荐的配置其实已经可以让 Nginx 和 Wordpress 很好的一起工作了,对于剩下的一些自定义的特殊配置等有需求的时候再补充。
]]>这篇文章就是记录我如何为自己的博客添加代码高亮功能,同时后台支持为代码区块(Gutenberg)设置语法高亮的语言。这是一种通用的方法,可以为任何区块新增想要的功能。
由于不可抗力,文章将分为两部分,本文为第二部分:
本文需要读者具有一定的 Wordpress 主题开发的基础知识。
创建 myguten.js 和 myguten.css 2 个文件到主题目录下。
Wordpress 提供了添加区块功能的方法,只不过是通过 Javascript 来控制,因此要先添加用于修改区块编辑器的相关脚本文件(myguten.js)和样式文件(myguten.css),在 functions.php 中添加以下代码:
// 升级 code Block 以适配 highlightjsfunction myguten_enqueue() { wp_enqueue_script( 'myguten-script', get_template_directory_uri() . '/js/myguten.js', array( 'wp-blocks', 'wp-dom-ready', 'wp-edit-post' ), filemtime( get_template_directory_uri() . '/js/myguten.js' ) ); wp_enqueue_style('myguten-style',get_template_directory_uri() . '/css/myguten.css' );}add_action( 'enqueue_block_editor_assets', 'myguten_enqueue' );
值得注意的是,脚本要求添加三个依赖项 wp-blocks、wp-dom-ready、wp-edit-post,这样在 myguten.js 的上下文里就可以获取到 Wordpress 提供的 Api。
在 myguten.js 中,为 blocks.registerBlockType
钩子添加函数:
wp.hooks.addFilter( 'blocks.registerBlockType', 'Namespace', setBlockType);
添加完成后,当 blocks.registerBlockType
钩子被触发,Wordpress 便会执行 setBlockType
方法,我们就在这个方法里为区块添加功能。
首先要知道 blocks.registerBlockType
钩子会在什么时候触发,顾名思义,在注册一个区块类型的时候会被触发。
该钩子会传入 2 个参数给 setBlockType
方法:
settings
:Object
,该类型区块的所有配置name
:String
,该类型区块的名字我们就是通过判断区块名来修改特定区块的配置,从而达到为区块添加、删除或者修改功能的。
接下来开始编写 setBlockType
方法。
好的,让我们写的通用一点,这样方便以后修改别的类型:
function setBlockType(settings, blockName) { switch (blockName) { case 'core/code': return modifyCodeBlock(settings); default: return settings; }}
通过 switch 来判断区块类型,然后调用特定的方法来修改区块并返回修改后的配置对象。
你需要了解它,才能正确的修改它。本文的需求只需要使用到配置对象里的其中 3 个属性:
attributes
:Object
,里面是配置对象的所有属性。edit
:Function
,这是一个方法,返回 React Element 列表,用来渲染对应类型的区块编辑器,第一个元素是编辑器的控制器部分,第二个元素是编辑器的编辑部分。save
:Function
,这是一个方法,返回 React Element,就是显示在文章中的元素。对应的处理方法也很直接,在 attributes
里面添加新的属性,用来表示选择的高亮语言,重写 edit
和 save
方法。
我们把新的属性命名为 language
:
settings.attributes.language = { type: String, default: 'plain'}
先看一下 hightlighjs
要求的 HTML 格式:
<pre><code class="language">...</code></pre>
然后按要求在 save
方法里返回即可:
settings.save = function (_ref) { var attrs = _ref.attributes; return wp.element.createElement("pre", null, Object(wp.element.createElement)("code", { className: attrs.language || 'plain' }, attrs.content))}
这里的 wp.element.createElement
其实就是 React 的 createElement
方法。
目标有两个:添加语言选择器,和添加当前选择语言的水印。
settings.edit = function (_ref) { var attributes = _ref.attributes, setAttributes = _ref.setAttributes, className = _ref.className; // Build Language Selector var langSelector = wp.element.createElement(wp.blockEditor.BlockControls, { key: 'controls' }, wp.element.createElement(wp.components.DropdownMenu, { className: 'animus-code-block-language-dropdown-menu', icon: 'hammer', label: '选择编程语言', children: function (props) { let langs = [ { name: 'Plain', val: 'plain' }, { name: 'Javascript', val: 'javascript' }, { name: 'Python', val: 'python' }, { name: 'PHP', val: 'php' }, { name: 'HTML', val: 'html' }, { name: 'CSS', val: 'css' }, { name: 'SASS', val: 'sass' }, { name: 'Shell', val: 'shell' } ]; let children = []; for (let i = 0; i < langs.length; i++) { let lang = langs[i]; let langElem = wp.element.createElement(wp.components.MenuItem, { value: lang.val, onClick: function () { props.onClose(); setAttributes({ language: lang.val }); } }, lang.name); children.push(langElem); } return children; } })); // Build Plain Text Editor var plainTextEditor = wp.element.createElement(wp.blockEditor.PlainText, { className: 'animus-code-editor', value: utils_unescape(attributes.content), onChange: function (content) { return setAttributes({ content: utils_escape(content) }); }, placeholder: wp.i18n.__('Write code…'), "aria-label": wp.i18n.__('Code') }); var codeEditorWaterMark = wp.element.createElement("div", { className: 'animus-code-editor-watermark', }, attributes.language); var element = wp.element.createElement("div", { className: className }, plainTextEditor, codeEditorWaterMark); return [langSelector, element];}
这些方法均来自 Wordpress
:
/** * Converts the first two forward slashes of any isolated URL from the HTML entity * I into /. * * An isolated URL is a URL that sits in its own line, surrounded only by spacing * characters. * * See https://github.com/WordPress/wordpress-develop/blob/5.1.1/src/wp-includes/class-wp-embed.php#L403 * * @param {string} content The content of a code block. * @return {string} The given content with the first two forward slashes of any * isolated URL from the HTML entity I into /. */function unescapeProtocolInIsolatedUrls(content) { return content.replace(/^(\s*https?:)//([^\s<>"]+\s*)$/m, '$1//$2');}/** * Returns the given content translating all [ into [. * * @param {string} content The content of a code block. * @return {string} The given content with all [ into [. */function unescapeOpeningSquareBrackets(content) { return content.replace(/[/g, '[');}/** * Returns the given content with all its ampersand characters converted * into their HTML entity counterpart (i.e. & => &) * * @param {string} content The content of a code block. * @return {string} The given content with its ampersands converted into * their HTML entity counterpart (i.e. & => &) */function unescapeAmpersands(content) { return content.replace(/&/g, '&');}/** * Escapes ampersands, shortcodes, and links. * * @param {string} content The content of a code block. * @return {string} The given content with some characters escaped. */function utils_escape(content) { return lodash.flow(escapeAmpersands, escapeOpeningSquareBrackets, escapeProtocolInIsolatedUrls)(content || '');}/** * Unescapes escaped ampersands, shortcodes, and links. * * @param {string} content Content with (maybe) escaped ampersands, shortcodes, and links. * @return {string} The given content with escaped characters unescaped. */function utils_unescape(content) { return lodash.flow(unescapeProtocolInIsolatedUrls, unescapeOpeningSquareBrackets, unescapeAmpersands)(content || '');}/** * Converts the first two forward slashes of any isolated URL into their HTML * counterparts (i.e. // => //). For instance, https://youtube.com/watch?x * becomes https://youtube.com/watch?x. * * An isolated URL is a URL that sits in its own line, surrounded only by spacing * characters. * * See https://github.com/WordPress/wordpress-develop/blob/5.1.1/src/wp-includes/class-wp-embed.php#L403 * * @param {string} content The content of a code block. * @return {string} The given content with its ampersands converted into * their HTML entity counterpart (i.e. & => &) */function escapeProtocolInIsolatedUrls(content) { return content.replace(/^(\s*https?:)\/\/([^\s<>"]+\s*)$/m, '$1//$2');}/** * Returns the given content with all opening shortcode characters converted * into their HTML entity counterpart (i.e. [ => [). For instance, a * shortcode like [embed] becomes [embed] * * This function replicates the escaping of HTML tags, where a tag like * <strong> becomes <strong>. * * @param {string} content The content of a code block. * @return {string} The given content with its opening shortcode characters * converted into their HTML entity counterpart * (i.e. [ => [) */function escapeOpeningSquareBrackets(content) { return content.replace(/\[/g, '[');}/** * Returns the given content with all its ampersand characters converted * into their HTML entity counterpart (i.e. & => &) * * @param {string} content The content of a code block. * @return {string} The given content with its ampersands converted into * their HTML entity counterpart (i.e. & => &) */function escapeAmpersands(content) { return content.replace(/&/g, '&');}
以上便是为 Wordpress 代码区块添加高亮语言选择的方法。
]]>这篇文章就是记录我如何为自己的博客添加代码高亮功能,同时后台支持为代码区块(Gutenberg)设置语法高亮的语言。这是一种通用的方法,可以为任何区块新增想要的功能。
由于不可抗力,文章将会分为两部分,本文为第一部分:
本文需要读者具有一定的 Wordpress 主题开发的基础知识。
下载 highlightjs 的脚本和样式,并添加到主题目录下。
然后,创建 myguten.js 和 myguten.css 2 个文件到主题目录下。
这样,准备工作就完成了。
Wordpress 调用 highlightjs 很简单,只需要在文章页尾引入并执行 highlightjs 脚本就好了:
// 引入 highlightjsfunction add_highlight_scripts() { // 如果当前查询的页面是已存在的文章,且文章类型为 post,则引入 highlightjs 包 if ( is_single() && get_post_type() === 'post' ) {?> <script src="<?php echo get_template_directory_uri() . '/path/to/highlight.pack.js'; ?>"></script> <script> // 配置 highlightjs hljs.configure({ useBR: false }); hljs.initHighlightingOnLoad(); </script><?php }}// 把方法添加到 wp_footer 钩子上add_action( 'wp_footer', 'add_highlight_scripts' );
然后把 highlightjs 的 css 添加到 wordpress 的样式队列:
// 引入 highlightjs 的 cssfunction add_highlight_style() { // 如果当前查询的页面是已存在的文章,且文章类型为 post,则引入 highlightjs 的样式 if ( is_single() && get_post_type() === 'post' ) { // 加入样式队列 wp_enqueue_style( 'highlightjscss', get_template_directory_uri() . '/path/to/highlight_js_styles.css' ); }}// 把方法添加到 wp_enqueue_scripts 钩子上add_action( 'wp_enqueue_scripts', 'add_highlight_style' );
把上述代码添加到 functions.php 里,每当访问文章页时,都会在页尾加入 highlightjs 脚本,当 wp_enqueue_scripts 触发时会加入 highlightjs 的样式,这些样式针对于 code 标签,如果无效,请检查要显示的代码块是否在 code 标签内。
highlightjs 文档里有说明,高亮显示特定的语言需要为 code 标签添加对应的类名,比如说 python:
<code class="python">...<code>
但是我们在编辑文章的时候,原生区块并没有提供选择语言的功能,因此需要自己添加。
有一种比较笨的办法,就是使用代码编辑器,然后手动为每个 code 标签添加类名。
笨,但是有效 :)
可是很烦人,每次都要手动更新,如果代码段数量多的话,简直要命。
所以有必要针对代码区块添加直接选择使用语言的功能,我将在第二部分中介绍添加的方法。
]]>