aaaaash / blog Goto Github PK
View Code? Open in Web Editor NEW✍️不定期断更
✍️不定期断更
combineReducer
是redux中相当重要的一个函数,它接受一个对象作为参数,包含一组子reducer,当应用较为庞大时需要按照某种规则切分reducer,combineReducer就是用来组合这些reducer给createStore
生成store树的
createStore函数的第一个参数是可以返回一个state对象的reducer
以下对部分类型判断以及错误信息做了省略,可结合官方源码combineReducers一同食用
export default function combineReducers(reducers) {
// 使用Object.keys方法将reducers中可枚举的属性生成一个keys数组
const reducerKeys = Object.keys(reducers)
const finalReducers = {}
for (let i = 0; i < reducerKeys.length; i++) {
const key = reducerKeys[i]
/*省略部分类型判断*/
if (typeof reducers[key] === 'function') {
// 遍历keys数组,并把相应的subreducer赋值给finalReducers
finalReducers[key] = reducers[key]
}
}
//第一步其实只是简单的将reducers对象中type为’function’的subreducer筛选出来赋值给finalReducers对象
// 将finalReducers中可枚举的属性生成一个keys数组
const finalReducerKeys = Object.keys(finalReducers)
// 这里返回最终合成的reducer,注意这个reducer将会被传递给createStore函数
/* !!!!!!!!!! 划重点
这个函数不同于之前例子中的reducer
将它称为reducer函数是因为他们会有相同的作用
既传入默认state和action将会生成一个计算后的state对象
不同之处在于: 这个函数并不需要根据action.type使用相应的策略更新state
只是简单依次执行所有reducer并将所有reducer生成的state合并起来返回给store
并且它所接受的state参数也是所有reducer生成的state组合后产生的总的state
!!!!!!!!!!
(东厂厂公)
*/
return function combination(state = {}, action) {
let hasChanged = false
// 最终产生的state树
const nextState = {}
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i]
// 根据key获取相应的subReducer
const reducer = finalReducers[key]
// 根据key获取相应的subState
const previousStateForKey = state[key]
/*
这里直接执行reducer函数, 传入subState和action
这个循环内会依次执行subReducer函数
第一个参数state会根据每个reducer的key不同来进行筛选
第二个参数action不出意外会每个reducer都传一遍,有相应策略则会更新
所以不同action的type不能相同,否则会出现状态混乱的情况
*/
const nextStateForKey = reducer(previousStateForKey, action)
// 把新的state赋值给nextState对象
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
// 简单对比后返回最终state
return hasChanged ? nextState : state
}
}
最后返回的combination
函数通过引用finalReducers
对象形成了一个闭包,当这个函数被返回给createStore调用后,内部依然可以访问finalReducers,而createStore内部dispatch调用reducer时也会把state对象传递进去,再通过reducer的策略更新并返回,形成一个闭环
PureRender
是react应用中最常见的优化方式之一,顾名思义是纯·渲染,React的核心**可以用一个表达式来概括
view = f(model)
这个很简单的表达式阐述了一个最基本的**,数据的更新触发视图的更新,如果把它看做一个纯函数,那么给定相同的输入必定得到相同的输出,简而言之就是如果state&props没有改变,理论上讲组件不会重新渲染
React生命周期有一个函数shouldComponentUpdate
,看名字就知道这个函数决定了组件要不要更新(重新渲染),默认情况下这个函数始终返回true
但是过多的rerender势必会引起性能问题,所以在必要的情况下开发者需要自己手动实现shouldComponentUpdate:
shouldComponentUpdate(nextProps, nextState) {
return this.props.value !== nextProps.value;
}
事实上在较新版本的React中内置了一个已经实现shouldComponentUpdate方法的类,叫做PureComponent
,使用时只要将原先的Component替换为PureComponent即可
import react, { PureComponent } from 'react';
class MyPage extends PureComponent {
// your code
}
然而不管是上面那个简单的例子,还是PureComponent,它们实现的方式很简单,都是浅比较
对于基本数据类型,只需要对比值,而引用类型则只对比引用地址,试想一下如果有一个长度为50+的数组,单纯的!==
浅对比就完全没有任何用处了,因为数组是引用类型,每次传来新的数组都是不同的引用,始终还是返回true,但是深对比带来的开销更大,到底如何取舍?
ImmutableData(不可变数据)就是指一旦创建就不能被改变的数据
上对ImmutableData的任何修改都会返回一个新的immutable对象
Immutablejs实现了List、Map等常用数据类型,分别对应js中的数组和对象
简单来说,当对一个immutabledata进行增删改操作时,并不会修改原本的数据,而是生成了新的immutable对象,如果没有任何修改则返回原对象
基本用法可参考官方文档
可以将组件state中的数据转为immutabledata,也可以将redux的state转为immutabledata
// state
import React from 'react';
import { fromJS } from 'immutable';
class Dmoe extends PureComponent {
state = {
someDeepData: fromJS({
name: 'misaka',
age: 20,
}),
}
handleChangeAge = () => {
const { someDeepData } = this.state;
const prevAge = someDeepData.get('age');
this.setState({
someDeepData: someDeepData.set('age', prevAge - 1),
});
}
render() {
const { someDeepData } = this.state;
return (<div>
<h1>{someDeepData.get('name')}</h1>
<button onClick={this.handleChangeAge}>-1s</button>
</div>);
}
}
例如一个父子组件嵌套,父组件数据改变导致自身rerender从而引发子组件一起rerender,这种情况使用ImmutableData + PureComponent则可以很好的避免子组件的重复渲染
class Child extends PureComponent {
render() {
const { info } = this.props;
console.log("render");
return (
<div>
<h1>my name is {info.name}</h1>
<p>i am {info.age} years old!</p>
</div>
);
}
}
class Parent extends PureComponent {
state = {
info: fromJS({
name: "misaka",
age: 10
}),
age: 20
};
handleChangeAge = () => {
const { age } = this.state;
this.setState({
age: age + 1
});
};
render() {
const { info, age } = this.state;
return (
<div>
<Child info={info} />
I am Sakura, {age} years old. this is my child!
<button onClick={this.handleChangeAge}>+1s</button>
</div>
);
}
}
createStore
函数是redux状态管理流程的入口,通过调用createStore(reducer, preloadState)
之后可以创建一个store对象,store包含四个方法(不包含调用后产生的ubsubscribe)
getState
subscribe
dispatch
replaceReducer
let currentReducer = reducer // 传入的reducer函数
let currentState = preloadedState // 初始state
let currentListeners = [] // 事件监听列表
let nextListeners = currentListeners // 当前监听函数
let isDispatching = false // 是否正在dispatch
很简单,直接返回当前state对象
function getState() {
return currentState
}
redux内部实现了一个简单的观察者模式, 通过subscribe可以传入事件监听函数
function subscribe(listener) {
// 接受一个事件处理函数
//在react-redux库的connect函数中的用法大致如下
/*
trySubscribe() {
if (shouldSubscribe && !this.unsubscribe) {
this.unsubscribe = this.store.subscribe(this.handleChange.bind(this))
}
}
调用store.subscribe时传入了this.handleChange函数
this.handleChange函数的作用是在收到新的store后手动更新传给react组件的store对象:
handleChange = () => {
const prevStore = this.state.currentStore;
const nextStore = this.store.getState();
// 这里通常会做一次深度对比,确定store是否更新
this.setState({ currentStore: nextStore });
}
*/
/* 省略部分代码*/
nextListeners.push(listener) // 将事件监听函数添加到监听列表
// store调用subscribe方法后会得到一个ubsubscribe方法用于取消事件监听
return function unsubscribe() {
// 找到当前监听的函数并使用splice方法移除
const index = nextListeners.indexOf(listener)
nextListeners.splice(index, 1)
}
}
dispatch
方法接受action
作为参数,用于触发一个action,可以理解为手动触发事件
function dispatch(action) {
try {
// 设置当前dispatch状态为true
isDispatching = true
// 将当前state和action作为参数传给当前reducer函数
/*
reducer函数应当类似于这种形式:
function someReducer(state = {}, action) {
switch (action.type) {
case xxx:
return Object.assign({}, state, {
xx: xx,
});
default:
return state;
}
}
*/
// 调用后reducer函数会根据传递的action.type来对当前state做出相应修改最后返回一个新的state
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
// 将所有订阅监听的函数赋值给listers
const listeners = currentListeners = nextListeners
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
// 分别执行lister
listener()
}
/*
到这一步基本实现:
创建store=>subscribe
订阅更新 =>�reducer
根据action.type完成更新 => 更新完执行事件监听函数
*/
// 返回action
return action
}
用于在�redux热更新等场景中替换reducer
function replaceReducer(nextReducer) {
// 传入新的reducer函数并替换当前reducer
currentReducer = nextReducer
// 手动触发初始action
dispatch({ type: ActionTypes.INIT })
}
实际上createStore做的事情很简单,创建store,添加订阅接口,手动触发事件,自动执行订阅函数,createStore最多接受三个参数,前两个分别是reducer
和preloadState
,第三个参数为enhancer
,由于逻辑较为复杂,将在applyMiddleware
部分单独解读.
下一篇分析combineReducers.js
树是一种分层数据的抽象模型,一个树结构包含一系列存在父子关系的节点,每个节点都有一个父节点(除了根节点)以及多个子节点.
根节点
.子树
.深度
属性,表示当前节点在树的层级.高度
.二叉树
中一个节点最多只能有两个子节点,分别为左侧节点以及右侧节点
二叉搜索树
(BST)是另一种二叉树,但是它只允许左侧子节点存储比父节点小的值,而右侧子节点存储比父元素大的值
二叉树的节点Node
类有一个指向左侧子节点的属性left
以及一个指向右侧子节点的属性right
,以及表示自身的属性key
class Node {
constructor(public key: Node, public left?: Node, public right?: Node){}
}
class BinarySearchTree {
root: Node;
constructor() {
this.root = null;
}
}
public insert(key: any) {
const node = new Node(key);
if (this.root === null) {
this.root = node;
} else {
insertNode(this.root, node);
}
}
向树中插入一个键分为三部
insertNode
函数递归插入function insertNode(node: Node, newNode: Node) {
// 当新节点的key小于当前节点时,从当前节点左侧递归查找空位
if (newNode.key < node.key) {
if (node.left === null) {
node.left = newNode;
} else {
insertNode(node.left, newNode);
}
} else {
// 新节点的key大于当前节点时,从当前节点右侧递归查找空位
if (node.right === null) {
node.right = newNode;
} else {
insertNode(node.right, newNode);
}
}
}
树的遍历分为先序
,中序
以及后序
中序遍历是一种以上行顺序访问BST所有节点的遍历方式,也就是以从小到大的方式顺序访问所有节点,可以使用递归的方式依次遍历左侧节点和右侧节点,以节点为null作为递归终止条件
public inOrderTraverse(callback: Function) {
inOrderTraverse(this.root, callback);
}
function inOrderTraverse(node: Node, callback: Function) {
if (node !== null) {
inOrderTraverse(node.left, callback);
callback(node.key);
inOrderTraverse(node.right, callback);
}
}
先序遍历是以优先于后代节点的顺序访问树
public preOrderTraverse(callback: Function) {
preOrderTraverseNode(this.root, callback);
}
function preOrderTraverseNode(node: Node, callback: Function) {
if (node !== null) {
callback(node.key);
preOrderTraverseNode(node.left, callback);
preOrderTraverseNode(node.right, callback);
}
}
后序遍历是指先访问节点的后代节点,再访问节点本身
public postOrderTraverse(callback: Function) {
postOrderTraverseNode(this.root, callback);
}
function postOrderTraverseNode(node: Node, callback: Function) {
if (node !== null) {
postOrderTraverseNode(node.left, callback);
postOrderTraverseNode(node.right, callback);
callback(node.key);
}
}
之前一篇文章大致介绍了 lsif-typescript-chrome-extension 的基本功能和实现原理, 经过这段时间的开发, 已经实现了令我比较满意的使用体验
主要做了几点优化
其中插件几个 script 之间以及和 lsif-server 的通信机制也做了两次大的优化. 一开始没有考虑到复用 WebSocket 连接, 每个页面都注入了一个 content script, 并且每次打开一个 GitHub 的代码页面都会和 lsif-server 之间建立一个 WebSocket 连接, 考虑到多数情况下打开代码页面不一定会停留太久, 同时太多连接势必会拖慢服务甚至浏览器性能, 所以第一步是把 WebSocket 连接挪到 background page.
简单解释一下这里 background script 是指 Chrome 插件的一个背景页, 每个插件都可以有一个独立的后台脚本, 会随浏览器启动运行, 而 content script 是指可以访问当前页面的一段脚本, 准确来说 content script 可以和当前的页面共享 DOM, 但并不能访问页面上的 window 对象. 我的思路是, background 负责维护一个和 lsif-server 的 WebSocket 连接, content script 只负责当前页面的事件监听及 DOM 操作, 另外还有一个 popup script (也就是右上角插件点击后弹出的小框)负责显示 WebSocket 连接状态.
content script 不直接和 lsif-server 通信, 所有消息都经过 background 转发, Chrome 插件支持在 content 和 background 之间维持一个长连接
// content script
const messagePort = chrome.runtime.connect({ name: 'lsif-typescript-message-channel' });
messagePort.postMessage({
//...
});
// background script
chrome.runtime.onConnect.addListener((messagePort) => {
if(messagePort.name === 'lsif-typescript-message-channel') {
messagePort.onMessage.addListener((message) => {
// ...
}
}
});
这种模式下, content 只需要维持和 background 之间的通信即可, 同时 background 还需要及时向 content 发送连接状态, 保证 content < - > background < - > lsif-server 消息同步.
第二个优化源于一个想法, 先来回顾一下插件流程, 当打开一个 GitHub 代码页面, content 会检查 background 和 lsif-server 的连接状态, 然后依次发送 initialize, documentSymbol 等请求, 一旦切换到另一个页面(这里我用 insight.io 插件的文件树功能切换代码页面), 会刷新页面并跳转到新的文件, 然后依然是上述流程, 这个过程没有太大问题. 但当我从 GitHub 项目主页点文件链接时发现页面并没有刷新, 而是直接请求了代码页面的数据并且渲染出来, 这时插件是没有工作的, 因为一开始进入页面 content 脚本只会检查一次 window.location, 非代码页面实际什么也不会发生, 而通过这种方式不刷新直接打开代码页时插件没有监听任何事件, 所以此时插件依然不会运行.
解决方案自然是监听 url change 事件, 进入代码页面开始运行插件, 很遗憾虽然有相应的 API 直接修改 url(不是 hash), 但并没有监听这个操作的事件, 好在社区依然有很 hack 的方案, 也就是魔改 window.history.pushState
function nativeHistoryWrapper(eventType: string): () => ReturnType<typeof history['pushState']> {
const origin = window.top.history[eventType];
return function () {
const rev = origin.apply(this, arguments);
const event = new Event(eventType);
// @ts-ignore
event.args = arguments;
window.dispatchEvent(event);
return rev;
}
}
const wrappedPushState = nativeHistoryWrapper('pushState');
window.history.pushState = wrappedPushState;
window.addEventListener('pushState', () => {
//...
});
当调用 pushState 时会自动 dispatchEvent, 然后直接监听即可.
看上去很完美, 直到我在 content 脚本里加入了这段代码, 从项目主页开始点击链接, 没有任何反应. 还记得之前说的吗, content 脚本和当前页面共享 DOM, 但并不能访问当前页面的 window 对象, 也就是这段代码修改了的 window.history 并不会在当前页面生效, 因为 content 脚本本身运行就不在当前页面上下文.
当然解决办法也是有的, 常见的方式是 content 页面不做具体逻辑处理, 只负责在 document.body 里动态插入一个 script 标签, src 即是我们真正的 content 代码.
const script = document.createElement('script');
script.src = chrome.runtime.getURL('out/content.js');
script.type = 'text/javascript';
document.body.appendChild(script);
但这样显然还不够, 因为之前 content 和 background 之间的长连接在content 被直接注入到页面后无法通信了, 而且因为这种行为本身就比较 hack , 所以并没有官方的通信方案. 不过我们还是可以借助强大的 postMessage.
为了区分我们把注入的 content 脚本叫做 inject script, 被注入到页面真正的 content 叫做 injected script, 这两个脚本之间可以通过 postMessage 通信, 我们需要把之前 content 和 background的通信方式改为 inject < - > injected < - > background < - > lsif-server, 而 injected 可以看做一个代理 agent, 它和 inject 通过 postMessage 通信, 和 background 通过长连接通信, inject 通过 window.postMessage 发送消息到 injected, injected 不需要做任何处理直接发送给 background, background 再发送到 lsif-server , 请求响应流程则是反过来.
这样我们先前魔改 window.history 的代码就可以直接运行在当前页面, 当从项目主页进入时, 插件不会发送任何请求, 一旦通过页面链接点开代码页面, 插件会按照上述的流程向 lsif-server 发送请求获取相关的索引信息.
上个月 GitHub 一个新功能(Navigating code)开启 beta 测试, 目前只对部分用户开放. 作为一个非常依赖 GitHub 看源码/学(chao)技(dai)术(ma)的程序员, 虽然我日常一直使用 sourcegraph 插件作为源码辅助阅读工具, 看到 GitHub 官方终于开始着力提升代码阅读体验, 还是期待了很久.
简单来说它主要的作用是在 GitHub 仓库代码里点击相应符号显示一些信息(譬如函数签名, 变量类型)并且可以跳转到定义的位置, 也就是我们在 IDE 里常用到的 hover 和 gotoDefinition. 可以方便的在线阅读代码, 对于一些中大型的项目可以省去 clone 到本地用 IDE 阅读的成本. 最近在某些仓库代码区域顶部已经可以看到 You're using jump to definition to discover and navigate code. 字样, 表示 jump to definition 功能可以在这个仓库使用. 初步体验了一下除了第一次索引较慢, 之后跳转和显示信息速度都还能接受.
在 GitHub 官方博客中可以看到这个功能是基于前段时间开源的 semantic 实现的, 关于这个项目纸糊上也有相关的讨论, 有兴趣可以移步围观, 这里我就不献丑了.
关于在线代码阅读辅助工具, 我个人比较常用的就是 sourcegraph , 包含了独立的网站, 命令行工具和 Chrome 插件, 这个项目使用支持 LSP 的语言服务在后台对项目代码进行分析, 关于一些技术细节可以看一下他们的官方博客, 其中一些文章详细介绍了他们基于 LSP 的整体架构和对 LSP 的一些扩展. sourcegraph 也支持类似 VS Code API 的插件系统, 开发者可以通过插件的形式增强 sourcegraph 对语言的支持, 目前 sourcegraph 已经支持数十种主流编程语言, 并且完全免费开源.
今年 2 月份, VS Code 官方博客更新了一篇名为**The Language Server Index Format (LSIF)**的文章, 介绍了主要用于增强代码阅读体验的语言服务索引格式(LSIF)规范, 定义了一种基于图的索引数据结构, 将 IDE 中 hover , 跳转, 引用等 feature 的结果预先缓存下来, 可以为 GitHub 这种代码托管平台提供丰富的阅读体验, 只要平台提供相应的 Client 请求并显示这些内容. 目前 LSIF 规范还在草案阶段, 但已经有了 TypeScript , Java 等语言的实现. sourcegraph 也已经着手准备开发下一代代码阅读辅助工具. 但到目前为止, 还没有看到基于 LSIF 的代码阅读工具, 官方只有一个 VS Code 插件作为演示 demo, 但我的需求并不是在 VS Code 里看代码, 而是 GitHub. 所以我开发了基于 LSIF 的 Chrome 插件(目前只支持 TypeScript), 一方面作为一个尝试, 另一方面可以弥补在网络状况不佳(你懂)的情况下 sourcegraph 速度太慢的不足.
先来看一下插件的功能
鼠标划过显示类型或注释信息
和 VS Code outline 同款的代码导航
是的, 目前只有这两个功能. 这个插件大概花了不到一星期的时间开发, 还有很多坑, 目前也只是能实现基本的功能.
首先插件需要通过类似 LSP 的方式和一个 LSIF 后端通信, 这里借鉴了 LSP 的一部分方法, 初次打开 GitHub 项目会发送一个 initialize 请求告诉 LSIF 后端开始初始化, LSIF 后端会 clone 项目代码并使用 lsif-tsc 工具分析一遍项目代码, 然后将结果缓存在一个特定文件中, 索引结果大概长这样
{"id":1,"type":"vertex","label":"metaData","version":"0.4.2","projectRoot":"file:///path/to/project"}
{"id":2,"type":"vertex","label":"project","kind":"typescript"}
{"id":3,"type":"vertex","label":"$event","kind":"begin","scope":"project","data":2}
{"id":4,"type":"vertex","label":"document","uri":"file:///path/to/project/file.ts","languageId":"typescript","contents":"xxxx"}
{"id":5,"type":"vertex","label":"$event","kind":"begin","scope":"document","data":4}
{"id":6,"type":"vertex","label":"resultSet"}
{"id":7,"type":"vertex","label":"moniker","kind":"export","scheme":"tsc","identifier":"out/common/file:"}
{"id":8,"type":"edge","label":"moniker","outV":6,"inV":7}
{"id":9,"type":"vertex","label":"range","start":{"line":0,"character":0},"end":{"line":0,"character":0},"tag":{"type":"definition","text":"","kind":7,"fullRange":{"start":{"line":0,"character":0},"end":{"line":39,"character":1}}}}
{"id":10,"type":"edge","label":"next","outV":9,"inV":6}
{"id":11,"type":"vertex","label":"document","uri":"file:///path/to/project/file.ts","languageId":"typescript","contents":"yyyyy"}
{"id":12,"type":"vertex","label":"$event","kind":"begin","scope":"document","data":11}
{"id":13,"type":"vertex","label":"resultSet"}
{"id":14,"type":"vertex","label":"moniker","kind":"export","scheme":"tsc","identifier":"out/common/diffHunk:"}
{"id":15,"type":"edge","label":"moniker","outV":13,"inV":14}
{"id":16,"type":"vertex","label":"range","start":{"line":0,"character":0},"end":{"line":0,"character":0},"tag":{"type":"definition","text":"","kind":7,"fullRange":{"start":{"line":0,"character":0},"end":{"line":291,"character":0}}}}
{"id":17,"type":"edge","label":"next","outV":16,"inV":13}
{"id":18,"type":"vertex","label":"resultSet"}
{"id":19,"type":"vertex","label":"moniker","kind":"export","scheme":"tsc","identifier":"out/common/diffHunk:DiffHunk"}
{"id":20,"type":"edge","label":"moniker","outV":18,"inV":19}
{"id":21,"type":"vertex","label":"range","start":{"line":49,"character":13},"end":{"line":49,"character":21},"tag":{"type":"definition","text":"DiffHunk","kind":5,"fullRange":{"start":{"line":49,"character":0},"end":{"line":59,"character":1}}}}
{"id":22,"type":"edge","label":"next","outV":21,"inV":18}
{"id":23,"type":"vertex","label":"hoverResult","result":{"contents":[{"language":"typescript","value":"class DiffHunk"}]}}
{"id":24,"type":"edge","label":"textDocument/hover","outV":18,"inV":23}
{"id":25,"type":"vertex","label":"resultSet"}
{"id":26,"type":"edge","label":"next","outV":25,"inV":18}
{"id":27,"type":"vertex","label":"moniker","scheme":"$local","identifier":"vYHm3Ot2dv3ly39PHoEc0w=="}
{"id":28,"type":"edge","label":"moniker","outV":25,"inV":27}
{"id":29,"type":"vertex","label":"range","start":{"line":5,"character":9},"end":{"line":5,"character":17},"tag":{"type":"definition","text":"DiffHunk","kind":7,"fullRange":{"start":{"line":5,"character":9},"end":{"line":5,"character":17}}}}
{"id":30,"type":"edge","label":"next","outV":29,"inV":25}
{"id":31,"type":"vertex","label":"hoverResult","result":{"contents":[{"language":"typescript","value":"(alias) class DiffHunk\nimport DiffHunk"}]}}
{"id":32,"type":"edge","label":"textDocument/hover","outV":25,"inV":31}
{"id":33,"type":"vertex","label":"range","start":{"line":5,"character":25},"end":{"line":5,"character":37},"tag":{"type":"reference","text":"'./diffHunk'"}}
{"id":34,"type":"edge","label":"next","outV":33,"inV":13}
{"id":35,"type":"vertex","label":"resultSet"}
{"id":36,"type":"vertex","label":"moniker","kind":"export","scheme":"tsc","identifier":"out/common/file:GitChangeType"}
这个过程一般不会很久(除非是超大项目), 例如 vscode-languageserver-node 这个项目大概需要 20s 以内的时间, 最终会生成 24m 的索引文件, 然后将这个文件逐行读取并构造出一个图(来不及解释了, 这段代码是我抄的), 可以以很快的速度查询 hover/references 等数据. 之后会返回 initialized 表示初始化完毕, 这时候就可以发起像 LSP 一样的请求了.
我的第一个需求是显示一个类似 VS Code 大纲视图的列表, 方便我在读超长的代码时快速跳转到文件内相应的位置, 只需要发送 documentSymbol 请求, 在后端会去之前构造的图里找到对应文件的 documentSymbol 结果并返回给客户端(这里的客户端就是我们的 Chrome 插件). documentSymbol 的结构长这样
{
"result": [
{
"name": "uriToFilePath",
"detail": "",
"kind": 12,
"range": {
"start": { "line": 15, "character": 0 },
"end": { "line": 35, "character": 1 }
},
"selectionRange": {
"start": { "line": 15, "character": 16 },
"end": { "line": 15, "character": 29 }
}
},
{
"name": "isWindows",
"detail": "",
"kind": 12,
"range": {
"start": { "line": 37, "character": 0 },
"end": { "line": 39, "character": 1 }
},
"selectionRange": {
"start": { "line": 37, "character": 9 },
"end": { "line": 37, "character": 18 }
}
},
],
"id": 2,
"method": "documentSymbol"
}
可以看到相应是一个数组, 包含了文件中所有 definition 的名称, kind(表示他是啥)以及位置信息(zero base).
拿到这些就可以在 GitHub 代码页面展示出来了, 大概就是每个 item 放一个 a 标签, 指向对应的行
https://github.com/microsoft/vscode-languageserver-node/blob/[commit]/server/src/files.ts#L162 , 点击这个 a 标签会跳转到相应的行并高亮显示(GitHub 自带).
另一个功能是 hover 效果, 这也是 LSP 本身就支持的方法, 需要先找到触发事件的 token 所处的位置, 可以通过遍历页面 DOM 节点计算得出, 具体不再赘述. 然后发起 hover 请求, 并带上表示 token 位置和路径的参数, 在 LSIF 服务端同样去图里找到预先缓存的 hoverResult 返回即可. 界面上可以用 marked + highlight.js 这套组合将返回的信息以 markdown 的形式渲染出来, 因为标准的 jsdoc 等注释内容在 LSP/LSIF 的实现里可以被解析为 markdown 格式的字符串. 剩下的事情对我这个切图仔来说就很简单了😀.
以上就是整个插件的实现过程, 因为大部分是抄了 LSP 的实现, 所以一些代码是从其他开源项目中直接 copy 过来的, 当然也有一些坑点需要解决.
插件和 LSIF 服务端代码都在我的 GitHub, 有兴趣的可以 pr/issue 甩过来.
lsif-typescript-chrome-extension
后续会继续维护这两个项目, 尽量实现 TypeScript 代码阅读的体验能超过 sourcegraph.
一种链式存取的数据结构,用一组地址任意的存储单元存放线性表中的数据元素。链表中的数据是以结点来表示的,每个结点的构成:元素(数据元素的映象) + 指针(指示后继元素存储位置),元素就是存储数据的存储单元,指针就是连接每个结点的地址数据
单项链表的基本结构
class Node {
constructor(public element = element, public next = null) {}
}
class LinkedList {
constructor() {
this.length = 0;
this.head = null;
}
public append(element: any) {}
public insert(position: number, element: Node) {}
public removeAt(position: number){}
public remove(element: Node){}
public indexOf(element: Node){}
public isEmpty(): boolean{}
public size(): number{}
public getHead(): Node{}
public toString(): string {}
public print() {}
}
向链表对象尾部添加一个元素时,可能有两种场景: 列表空时,添加的是第一个元素,列表不为空时,向其追加元素
// 省略部分代码
public append(element: Node) {
const node = new Node(element);
let current;
// 列表为空时添加为第一个元素
if (this.head === null) {
head = node;
} else {
current = this.head;
while(current.next) {
current = current.next;
}
current.next = node;
}
this.length += 1;
}
// 省略部分代码
移除元素也分两种场景: 移除第一个元素或移除第一个以外的任一元素
// 省略部分代码
/**
* 移除指定位置的元素
* */
public removeAt(position: number) {
if (position > -1 && position < length) {
let current = this.head;
let previous;
let index = 0;
// 移除第一项
if (position === 0) {
this.head = current.next;
} else {
while (index++ < position) {
previous = current;
current = current.next;
}
previous.next = current.next;
}
this.length -= 1;
return current.element;
} else {
return null;
}
}
// 省略部分代码
移除列表最后一项或中间某一项时, 需要依靠一个细节来迭代列表,直到到达目标位置,使用一个内部递增的index
变量, current
变量为所
循环列表的当前元素进行引用,以及一个对当前元素的前一个元素引用的变量previous
从列表中删除当前元素,要做的就是将previous.next和current.next连接起来
insert
方法用于在任意位置插入一个元素
public insert(position: number, element: any): boolean {
if (position >= 0 && position <= this.length) {
const node = new Node(element);
let current = this.head;
let previous;
let index = 0;
// 当position为0 则在第一个位置添加新元素
if (position === 0) {
node.next = current;
head = node;
} else {
while(index++ < position) {
previous = current;
current = current.next;
}
node.next = current;
previous.next = node;
}
this.length += 1;
return true;
} else {
return false;
}
}
当插入位置为0时,先把新插入node的next设为当前head,再把head修改为node,就成功把新元素插入到了列表头部
当插入位置在中间或尾部时,循环遍历列表,找到目标位置,在previous和current中间添加新元素,将新元素的next设为current,previous的next设为新元素,这样就成功在列表中间插入了新元素
toString方法需要将LinkedList对象转换成一个字符串
public toString() {
let current = this.head;
let string = '';
while(current) {
string += current.element + (current.next ? 'n' : '');
current = current.next;
}
return string;
}
indexOf方法接受一个元素的值,如果在列表中找到它,就返回元素的位置,否则返回-1
public indexOf(element: any) {
let current = this.head;
let index = -1;
while(current) {
if (element === current.element) {
return index;
}
index += 1;
current = current.next;
}
return -1;
}
remove方法可以依赖indexOf方法先找到要移除元素的index,然后调用this.removeAt
将index传进去从而删除元素
public remove(element: any) {
const index = this.indexOf(element);
return this.removeAt(index);
}
public isEmpty() {
return this.length === 0;
}
public size() {
return this.length;
}
public getHead() {
return this.head;
}
双向链表与单项链表的区别是,双向链表有两个分别指向上一个元素以及下一个元素的链接
因此,双向链表除了head
属性外,还有一个表示尾部元素的tail
属性
class DoublyLinkedList {
constructor {
this.length = 0;
this.head = null;
this.tail = null;
}
}
双向链表还提供了两种迭代列表的方法: 从头到尾以及从尾到头.我们也可以访问一个特定节点的下一个或上一个元素
双向链表插入操作和单项链表相似,唯一的区别是单项链表只需要维护一个next指针,而双向链表需要同时维护next和prev指针
public insert(position: number, element: any): boolean {
if (position >= 0 && position <= this.length) {
const node = new Node(element);
let current = this.head;
let previous;
let index = 0;
if (position === 0) {
if (!this.head) {
this.head = node;
this.tail = node;
} else {
node.next = current;
current.prev = node;
this.head = node;
}
} else if (positon === length) {
current = this.tail;
current.next = node;
node.prev = current;
this.tail = node;
} else {
whild (index++ < position) {
previous = current;
current = current.next;
}
node.next = current;
previous.next = node;
current.prev = node;
node.prev = previous;
}
this.length += 1;
return true;
} else {
return false;
}
}
public removeAt(position: number) {
if (position > -1 && position < this.length) {
let current = this.head;
let previous;
let index = 0;
if (position === 0) {
this.head = current.next;
if (this.length === 1) {
this.tail = null;
} else {
this.head.prev = null;
}
} else if (position === this.length - 1) {
current = this.tail;
this.tail = current.prev;
this.tail.next = null;
} else {
while (index++ < position) {
previous = current;
current = current.next;
}
previous.next = current.next;
current.next.prev = previous;
}
this.length -= 1;
return current.element;
} else {
return null;
}
}
同样需要处理三种场景
循环链表可以像链表一样只有单向引用,也可以像双向链表一样有双向引用,循环链表和链表之间唯一的区别是,最后一个元素的next指针并不指向null,而是头部元素
想请问下,如果自己的web程序里面要想提供一个可以提供go to definition和类似find implementations/references的功能的代码展示区,前端组件需要用monaco editor还是任意都行? 中文资料这方面有点少,我拜读了你的博客后有一些不知道是否正确的认识想取经下。
一个LSP服务(jdt ls)就是一块独立进程用来解析指定目录文件系统?然后当我前端用codemirror或者monaco editor进行操作时候可以获取对应action和指定元素的偏移 长度,将这些信息用json格式和jdt ls通信然后获取对应的结果?前台根据这个再进行页面渲染? 不知道这个流程是这样吗?如果是的话,有个疑问就是jdt ls初始化的地方和我前台浏览器组件必须都是同一个目录吗?
applyMiddleware
用于应用自定义中间件,也是整个redux架构中的难点所在,代码非常简短,但是运用了一些较难理解的函数式编程范式,而middleware的多种使用方式也使得这短短的几十行代码非常难以理解
这篇文章推荐深度使用redux及middleware之后再阅读,本文会结合之前createStore中遗漏的第三个参数enhancer一同解读
第一种通过调用createStore
函数传入第三个参数enhancer
,官方文档的解释是
enhancer
(Function): Store enhancer 是一个组合 store creator 的高阶函数,返回一个新的强化过的 store creator。这与 middleware 相似,它也允许你通过复合函数改变 store 接口。
实际在使用时这个参数就是事先调用applyMiddleware
函数之后的返回结果
/*...省略部分代码*/
const middlewares = [
middlewarex,
middlewarey,
];
const enhaner = applyMiddleware(...middlewares);
const store = createStore(reducers, {}, enhaner);
applyMiddleware源码中有这个enhaner
函数的定义
export default function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState, enhancer) => {
/*省略*/
}
}
使用es6箭头函数后这段代码看起来有点难懂,实际这里做了柯里化处理,翻译一下就是这样的
function applyMiddleware(...middlewares) {
// 第一次调用applyMiddleware(...middlewares)后返回的是一个接受createStore函数为参数的函数
return function (createStore) {
// 最终返回类似于createStore的增强型函数
return function (reducer, preloadState, enhancer) {
/* 省略*/
}
}
}
在调用createStore时,内部对第三个参数enhancer
做了这样的处理
if (typeof enhancer !== 'undefined') {
if (typeof enhancer !== 'function') {
throw new Error('Expected the enhancer to be a function.')
}
// 直接返回并把自身传给enhancer,并不会执行之后剩余的代码
// 因为在applyMiddleware第一次调用后就返回了一个接受createStore做为参数的函数
// 而传入createStore之后最终又返回了一个增强后的createStore
// applyMiddleware中增强的这个createStore同样接受reducer、state、enhancer三个参数
return enhancer(createStore)(reducer, preloadedState)
}
而在applyMiddleware中这个所谓增强型的createStore是这样定义的
// 再回头调用createStore传入参数创建一个store
const store = createStore(reducer, preloadedState, enhancer)
// 获取dispatch
let dispatch = store.dispatch
// 用户缓存middleware运行结果
let chain = []
// 获取middleware所需的api,分别为getState和dispatch
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args)
}
// 依次给每个middleware传入api并直接运行
// 将运行结果储存在chain数组中
chain = middlewares.map(middleware => middleware(middlewareAPI))
/*
最重要的一步
用compose组合所有middleware的运行结果并创建一个新的dispatch函数
*/
dispatch = compose(...chain)(store.dispatch)
return {
...store,
dispatch
}
compose
是函数式编程中将多个函数一起使用的过程,叫做组合函数,想象有两个功能不同的函数,第一个函数的返回值(输出)可以使是第二个函数的参数(输入),那么这两个函数可以被组合使用,类似于这样
const funca = (a, b) => a + b;
const funcs = (num) => num * 2;
funcs(funca(1,2)); // 6
// 使用组合
const compose = (fn2, fn1) => (...args) => fn2(fn1(...args));
// 注意调用时最后调用的函数要最先传进去
// 调用顺序和传参顺序是相反的
const result = compose(funcs, funca);
result(1, 2); // 6
而如果现在我们有多个函数,恰好前一个函数的输出是后一个函数的输出,那么可以使用通用的compose
函数,最简单的compose函数可以是这样
const compose = (...fns) => result => {
const list = fns.slice();
while(list.length > 0) {
result = list.pop()(result);
}
return result;
}
const result = compose(funcs, funca, ...func);
result(1, 2);
理解了这一点再来看redux中的compose函数源码
export default function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
/*
比之前实现方式更简单的是,redux直接用数组的reduce方法
遍历funcs数组并按照顺序最后传进的函数被最先调用依次执行最后返回结果
*/
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
经过这些步骤最终applyMiddleware返回了一个新的store,包含了增强后的dispatch函数,每次调用dispatch触发一个action都会经过每个middleware然后才到达reducer
而第二种调用方式是这样的
const store = applyMiddleware(...middlewares)(createStore)(reducers, initialState);
/*
这里拆开来看
applyMiddleware(...middlewares) === enhancer
enhancer(createStore) ==== createStore
createStore(reducers, initialState)
*/
经过上面的分析其实可以看得出来这种调用方式省去了先调用createStore传入enhancer又把createStore传给enhancer的过程,相当于直接执行createStore里的enhancer(createStore)(reducer, preloadedState)
,之后运行过程就和第一种方式一样了
applyMiddleware作为redux中最重要的api之一,其运行过程中增强了createStore函数,并改造了store的dispatch方法,使action => reducer这个过程中middleware可以进行副作用操作
全称HyperText Transfer Protocol,超文本传输协议
是一个客户端和服务器端请求和应答的标准(TCP),属于应用层的通信协议
方法(GET,POST,PUT,DELETE等)
URI(统一资源标识符)
URI表示资源是什么,URL表示资源的位置
HTTP协议版本
请求头部字段
包含Host,Connection,Content-type,Content-length等
内容实体
HTTP协议版本
状态码
状态码原因短语
响应头部
包含Date,Content-type,Content-length等
内容主体
HTML字符串,JSON及XML数据等
GET
主要用于获取资源
使用 GET 方法,浏览器会把 HTTP Header 和 Data 一并发送出去,服务器响应 200(OK)并返回数据
POST
主要目的是传输存储在内容实体中的数据
使用 POST 方法,浏览器先发送 Header,服务器响应 100(Continue)之后,浏览器再发送 Data,最后服务器响应 200(OK)并返回数据
HEAD
获取报文首部
不返回报文实体主体部分
主要用于确认 URL 的有效性以及资源更新的日期时间等
PUT
上传文件,不带验证机制
DELETE
删除文件,同样不带验证机制
OPTIONS
查询指定的 URL 能够支持的方法
服务器返回的 响应报文 中第一行为状态行,包含了状态码以及原因短语,用来告知客户端请求的结果。
表示接受的请求正在处理
重定向
客户端错误
服务器错误
单线程,因为作为浏览器的脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM.如果是多线程,一个线程添加了一个DOM节点,另一个线程删除了DOM节点,浏览器无法判断以哪个线程为主
通过WebWorker标准可以简单模拟多线程操作,但子线程完全受主线程控制,且无法操作DOM,本质上还是单线程
单线程同步的情况下一些耗时较长的任务将会导致页面渲染阻塞甚至卡死,浏览器是多线程的,因此浏览器为这些耗时较长的任务比如HTTP,定时器以及事件监听等单独开一个线程,由于JavaScript单线程的特性,所有任务会形成一个任务队列等待被执行,所以异步线程通过回调函数
将执行完毕的异步任务放到JavaScript主线程的任务队列里
JavaScript主线程会一直循环查找任务队列里是否还有其他任务
function fn2(){
//dosomething...
}
function fn1() {
fn2()
// dosomething...
}
function foo(){
fn1();
}
foo();
比如这段代码,函数foo
在执行时,主线程发现它还需要执行函数fn1
,就把它推入一个栈中,执行到fn1时又发现它需要先执行fn2,于是又将fn1推入栈中,等到fn2执行结束,继续执行fn1,fn1执行结束将继续执行foo,待所有函数执行完成,主线程会让这些函数出栈,此时说明一个任务已经执行完毕,主线程会从下一个任务队列(callback equeue)中寻找下一个任务推入栈中,这个过程就叫做EventLoop
扑街
var a = {
value: 'aaa',
say: function(){
console.log(this.vlaue);
}
}
var b = {
value: 'bbb',
say: function() {
console.log(this.value);
}
}
a.say = b.say;
a.say(); // 输出结果是什么
输出字符串aaa
,因为函数中this的指向始终是运行时调用它的对象
将请求包装为一个Promise数组,使用Promise.all执行,之后调用.then即可
高阶组件可以将多个组件中逻辑相同的部分抽离出来,由一个函数包装后形成一个新的组件,并且不影响原组件
class A extends PureComponent {
componentWillMount() {
console.log(this.props.name);
}
render(){
return <div>I am {this.props.name}</div>
}
}
class B extends PureComponent {
componentWillMount() {
console.log(this.props.name);
}
render(){
return <div>I am {this.props.name}</div>
}
}
function HighOrderLog(Component) {
class Com extends PureComponent {
componentWillMount() {
console.log(this.props.name);
}
return <Component name={this.props.name} />
}
return Com;
}
异步,因为每次调用setState之后,React内部不光要更新state,还要进行一系列比如diff算法来决定下一次render,setState多次调用会造成一定程度上性能的损耗,所以React会将多个setState先合并再执行,这样一来避免了不必要的性能损失
拿到新的state有两种方法
分两种情况
因为PureComponent实现了shouldComponentUpdate
方法,收到新的props后会做一次浅对比,既仅对比引用是否相同,shouldComponentUpdate方法返回布尔值,将决定组件是否进行重渲染
Redux是单Store的**,通过view->action->reducer->view的单向数据流管理页面状态
getState
,dispatch
,subscribe
等方法的对象,它接受reducer作为参数
不冲突,业务层面上可以将React的state作为内部状态,既不依赖父组件或外部环境的组件可以使用state,而一些后端返回的数据,可能需要在多个组件**享的,则可以作为全局的状态存放在redux的state中
简单来讲就是指一个算法解决相应问题其代码执行基本语句需要的次数
算法中某个特定步骤的执行次数/对于总执行时间的估算成本,随着「问题规模」的增大时,增长的形式。
决定算法复杂度的两个重要因素是
function swap(arr, i, j) {
const tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
const arrays = [21,454,6578,784534,443565,87978,4567];
function bubbleSort(arr) {
const length = arr.length;
for (let i = 0; i < length; i += 1) {
for (let j = 0; j < length - 1; j += 1) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
}
}
}
}
bubbleSort(arrays); // [21, 454, 4567, 6578, 87978, 443565, 784534]
冒泡排序的策略是依次循环比较两个相邻的项,如果第一个比第二个大,则交换他们的位置,较小的项会逐渐向上移动到正确的位置
由于无论数组是否已经排序,都会对每一项进行双重的循环遍历,所以冒泡排序的时间复杂度是O(n²)
const arrays = [21,454,6578,784534,443565,87978,4567];
function insertionSort(arr) {
const length = arr.length;
for (let i = 1; i < length; i += 1) {
for (let j = i - 1; j >= 0; j -= 1) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
}
}
}
}
insertionSort(arrays); // [21, 454, 4567, 6578, 87978, 443565, 784534]
插入排序的策略是,将数组分为两个部分, 假设原第一项是已经排好序的一个数组,那么用其余数组项依次与这个已经排好序的数组对比,小的交换位置,直到其余数组为空,排序结束
这段代码中,外循环从i = 1开始,即表示不论第一项多大,它被看做一个已经排好序的数组,从原数组第二项开始遍历,由于第一轮循环,有序数组只有一项,所以直接与第二项对比后交换位置,依次类推
插入排序是不稳定的排序,最好的情况下时间复杂度为O(n),最坏情况下为O(n²)
将[123, [2,32,445,[32,54,4]]]
转为[123,2,32,445,32,54,4]
使用isArray方法判断是否为数组,递归调用即可
const arrays = [123, [2, 32, 445, [32, 54, 4]]];
function flatten(arr) {
return arr.reduce((pre, cur) => {
return pre.concat(Array.isArray(cur) ? flatten(cur) : cur);
},[])
}
flatten(arrays); // [123, 2, 32, 445, 32, 54, 4]
function reversalBST(root) {
if (root === null) return null;
const temp = root.left;
root.left = root.right;
root.right = temp;
temp(root.left);
temp(root.right);
}
上一篇文章简单介绍了 LSP 协议和如何利用 LSP 为 Monaco 编辑器提供语言特性功能,以及如何向 Web 端的在线编辑器适配 LSP 服务。本文将继续深入这一话题,了解面向在线编辑器环境下,利用 LSP 实现这些功能有哪些需要注意的点以及填坑指南。由于笔者水平有限,如有疏漏之处还请指出。
上篇说到,要实现这样一个服务,需要有一层 WebSocket 与客户端相连接做中转层,由于 LSP 服务不涉及其他功能,所以这个服务器只需要有一个简单的 HTTP 服务,能够与客户端连接相互通信即可。
我们使用 socket.io 来搭建 WebSocket 服务,代码非常简单
import * as http from "http";
import * as io from "socket.io";
const server = http.createServer();
const socket = io(server);
server.listen(PORT, () => {
logger.info("Language Server start in 9988 port!");
});
在客户端同样使用 socket.io-client 模块来连接这个服务器
import io from 'socket.io-client';
const socketOptions = {
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 10000,
path: '',
transports: ['websocket'],
};
const ws = io.connect('localhost', socketOptions);
需要注意的一点是,我们使用的 vscode-ws-jsonrpc
是扩展了原本的vscode-jsonrpc
,为其添加了 websocket 功能的支持。但它只接受原生 WebSocket 对象作为 listen 方法的参数,两者实现的接口略有不同,我们需要对 socket.io 进行一层包装
import { listen } from 'vscode-ws-jsonrpc';
import { createMonacoServices } from 'monaco-languageclient';
const socket = createWebSocket();
// send 方法包装为 socket.emit
const ioToWebSocket = {
send: (message) => {
socket.emit('message', { message })
},
onerror: err => socket.on('error', err),
onclose: socket.onclose,
close: socket.close,
};
/**
* 原生 websocket 在连接成功后会触发一个 onopen 方法
* 用于连接成功后的回调函数
* 所以在这里我们手动调用 onopen
*/
socket.on('connect', () => {
ioToWebSocket.onopen()
});
socket.on('message', ({ data }) => {
ioToWebSocket.onmessage({ data })
});
// 然后将这个 ioToWebSocket 对象传递给� listen 方法作为参数
const services = createMonacoServices(null, { rootUri: `file://xxx` });
listen({
webSocket: this.ioToWebSocket,
onConnection: (connection) => {
// connection 连接成功后返回的一个连接对象,languageServer-client 借助这个 connection 来收发消息
const client = new BaseLanguageClient({
name: 'lsp',
clientOptions: {
commands: undefined,
// 表示相应语言的选择器
documentSelector: ['python'],
synchronize: {
configurationSection: 'pyls',
},
// 连接成功后的初始化参数,每个语言的 lsp 实现略有不同,可在相应项目的 package.json 中找到。
// vscode文档中也有介绍 https://code.visualstudio.com/docs/extensions/example-language-server
initializationOptions: {
...initializationOption,
// 提供 lsp 服务的项目 uri,绝对地址
workspaceFolders: [`file:///xxx`]
},
// 默认错误处理函数
initializationFailedHandler: (err) => {
const detail = err instanceof Error ? err.message : ''
},
diagnosticCollectionName: language,
},
// 服务对象,与客户端的区别在于,这个 services 主要用于绑定一些编辑器的操作命令及消息的转换
// 而客户端里,这个 services 被叫做 serverOptions ,用于在本地启动 LSP 服务,会根据不同类型的参数以指定的模式启动 LSP
services,
connectionProvider: {
get: (errorHandler, closeHandler) =>
Promise.resolve(createConnection(connection, errorHandler, closeHandler)),
},
});
}
});
其中createMonacoServices
函数所接受的rootUri
以及BaseLanguageClient
的workSpaceFolders
均为一个标准的 URI,表示需要提供 LSP 服务的项目绝对路径,也可以传输一个相对路径然后在 Server 端做转换处理。
foo://example.com:8042/over/there?name=ferret#nose
\_/ \______________/\_________/ \_________/ \__/
| | | | |
scheme authority path query fragment
| _____________________|__
/ \ / \
urn:example:animal:ferret:nose
到这一步,客户端已经可以成功的通过 WebSocket 连接到服务器,不出意外的话,客户端会发出第一条 initialize
请求。此时我们的服务还没有对请求做处理,所以客户端也不会收到任何回复。
前文说到,传统客户端的实现中,new LanguageClient
在实例化时需要传入一个 serverOptions
的参数用于启动本地的 LSP 程序,以 vscode-java 为例,这个 repo 是一个 vscode 的插件,用于在 vscode 中为 Java 语言提供 LSP 相关功能。
查看其源码可以得出,当找到环境变量 SERVER_PORT
时,会开启一个 TCP 服务器,等待 vscode-java 底层的 jdt.ls 作为客户端通过这个端口来建立连接。反之则将 jdt.ls 的启动参数及 JAVA_HOME 作为 serverOptions ,然后由客户端自行启动。
在我们的服务端同样可以用这两种方式来启动 LSP 程序,我们创建一个名为JavaLanguageServer
的类来管理这个 LSP 连接。这个类需要监听 WebSocket 的消息,在初始化时启动 jdt.ls ,以及在客户端断开连接时杀死进程以确保资源及时回收。还有一点是建议在客户端连接 WebSocket 时携带两个参数language
和workspace
,方便服务端区分不同的语言和相应的项目目录,同时类似 jdt.ls 这种服务,在运行时会产生一些元数据,可以通过 workspace 名来指定元数据存放在哪个目录,否则这些数据会直接被保存在当前服务运行的目录下,启动多个项目时会产生错误消息。
// 使用 stdio 模式启动 LSP
import * as cp from 'child_process';
import * as io from 'socket.io';
class JavaLanguageServer {
constructor(
private socket: io.Socket,
) {}
start() {
const javahome = 'xxx/bin/java';
const params = this.prepareParams();
this.process = cp.spawn(javahome, params);
}
// 准备 jdt.ls 启动参数
prepareParams() {
const params: string[] = [
'-Xmx256m',
'-Xms256m',
'-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=,quiet=y',
'-Declipse.application=org.eclipse.jdt.ls.core.id1',
'-Dosgi.bundles.defaultStartLevel=4',
'-noverify',
'-Declipse.product=org.eclipse.jdt.ls.core.product',
'-jar',
// serverUri 表示 jdt.ls 构建后的目录
// 启动服务所需的 jar 包
`${serverUri}/${launchersFound[0]}`,
'-configuration',
// 不同平台的配置文件,可以使用 process.platform 来获取系统信息,指定不同的配置
`${serverUri}/${JAVA_CONFIG_DIR}`,
`-data`,
// 客户端传入的 workspace 参数,用于存放元数据
workspace,
];
return params;
}
}
服务端 WebSocket 收到客户端的 'connection' 事件后,实例化这个 JavaLanguageServer,将 WebSocket 对象作为参数,之后调用 start 方法就会启动一个 jdt.ls 的子进程。
在实例对象内部,我们需要监听 WebSocket 的消息,并通过 childProcess.stdin.write
传送给 jdt.ls 进程,然后监听 childProcess.stdout
的 ondata
事件接收返回的消息。
但是这里有一个坑,我们知道 TCP 协议传输的是字节流,直接连接 TCP 服务进行通信,在数据量较大时会产生所谓的粘包
问题,也就是多个消息包粘在一起。如果不经过处理直接把消息发送给客户端的话,编辑器无法识别并处理这些消息。
实际上 TCP 协议中并没有包
这个概念,所有数据都是以流的形式来传输,而 TCP 协议为了保证可靠传输,减少每次发送数据都要验证的额外开销,使用流的形势传输,并且使用了优化算法(Nagle算法),会将多次间隔较小/量小的数据合并成一个大的数据块,这样一来减少了发送包的数量,提高了传输效率。而接受方也会引起这个问题,由于接收数据不及时,导致下一段数据被放在系统缓冲区,等待接收进程取出消息,若下一段数据还未被取出就收到了新的消息,那么这两段消息会被粘
在一起,从而产生粘包现象。在这里我们使用标准输入输出的方式也会有同样的情况,也正是因为 Stdio 基于字节流,数据量较大时没有及时处理数据,缓冲区数据滞留从而引发粘包问题。
并且从理论上来讲,TCP 协议只是传输层协议,也并不存在粘包
这个概念。我们需要再建立一层应用层协议来自行处理这些问题,这也就是网络编程中常见的所谓分包
等问题的来源。
传统的粘包
处理方式有几种,
push
,接收到该指令后,会将消息立即发送出去,不必等待缓冲区满。对于我们的 LSP 程序来说,第一种方式需要修改 LSP 源码,显然行不通。第二种方式只能减少粘包出现的频率,并不能完全解决问题。第三种方式则最完美,因为 LSP 协议本身就包含了 Content-Length
,所以我们可以根据这个消息长度来获取消息内容。
服务端我们使用vscode-jsonrpc
这个包已经解决了这一问题,查看MessageReader源码可以得知在接收到消息后,将消息写入一个 Buffer 中,然后在这个 Buffer 里寻找消息的 Header,也就是 Content-Length
字段。读取到消息长度后,继续在接受到的消息包里截取这个长度的内容,将其组合起来再发送给 callback 函数。
private onData(data: Buffer | String): void {
// 写入 buffer
this.buffer.append(data);
while (true) {
if (this.nextMessageLength === -1) {
// 读取消息头
let headers = this.buffer.tryReadHeaders();
if (!headers) {
return;
}
let contentLength = headers['Content-Length'];
if (!contentLength) {
throw new Error('Header must provide a Content-Length property.');
}
let length = parseInt(contentLength);
if (isNaN(length)) {
throw new Error('Content-Length value must be a number.');
}
// 将取到的消息长度赋值给 nextMessageLength
this.nextMessageLength = length;
// Take the encoding form the header. For compatibility
// treat both utf-8 and utf8 as node utf8
}
// 根据 nextMessageLength 长度读取消息内容
var msg = this.buffer.tryReadContent(this.nextMessageLength);
if (msg === null) {
/** We haven't recevied the full message yet. */
this.setPartialMessageTimer();
return;
}
this.clearPartialMessageTimer();
this.nextMessageLength = -1;
this.messageToken++;
var json = JSON.parse(msg);
this.callback(json);
}
}
这里tryReadHeader
和tryReadContent
函数的实现方法不再赘述,有兴趣的可以阅读源码。
vscode-jsonrpc
包中不但解决了粘包问题,还以不同的连接方式抽象出了几个 Reader 类以供我们使用。
NodeJS.ReadableStream
对象为参数Process | ChildProcess
对象为参数net.Socket
对象为参数在这里我们使用StreamMessageReader
,传入 childProcess.stdout 来读取子进程的可读流消息。
// JavaLanguageServer.ts
const messageReader = new StreamMessageReader(this.process.stdout);
this.socket.on('message', (data) => {
this.process.stdin.write(data.message);
});
messageReader.listen((data) => {
const jsonrpcData = JSON.stringify(data);
const length = Buffer.byteLength(jsonrpcData, 'utf-8');
const headers: string[] = [
contentLength,
length.toString(),
CRLF,
CRLF,
];
this.socket.send({ data: `${headers.join('')}${jsonrpcData}` });
});
这段代码中我们创建了一个 StreamMessageReader 实例,调用 listen
方法传入回调函数。在收到完整的消息包后将消息序列化并调用 Buffer.byteLength
方法获取序列化后消息的字节数。这里需要非常注意的是,虽然 JSON.stringify 将对象序列化成了字符串,但是不能直接用 jsonrpcData.length
作为 Content-Length 消息长度,因为 LSP 协议规定合法的 Content-Length 值应当为内容部分的字节长度,而不是内容部分的字符串数,这两者有些许差别。
在纯ASCII码下,字节数=字符串长度=字符个数,因为每个字符就一个字节。
在Unicode下,字节数/2=字符串长度=字符个数,因为每个字符都是2个字节。
在ASCII码与其它双字节字符系统混用时,字节数=ASCII码字符个数+双字节字符个数*2,而此时字符串长度到底怎么统计就不好说了,有的语言如C语言,此时字符串长度=字节数,有的语言如JS,此时字符产长度=字符个数。
使用 string.length
把字符数当做字节长度会导致客户端接收消息时产生读取消息出错的问题。
到这里我们的客户端与服务端成功的建立了连接,并在 LSP 的作用下在线编辑器有了基本的代码提示、诊断等功能。
在客户端断开连接后要调用 process.kill
方法及时杀死进程,某些情况下可能存在进程没有杀死的情况,建议使用node-tree-kill来确保进程退出。
可以看出向 Web 端在线编辑器提供 LSP 服务是完全可行的,但每次打开一个项目或目录就在服务器启动一个 LSP 实例进程,且单个进程内存占用较大,例如 jdt.ls 启动后平均内存占用在 400m 左右,用户量较多时资源消耗太大,这对相对紧张的服务资源来说是一个非常奢侈的。LSP 协议也不支持多个用户共享同一进程,所以在功能实现和资源占用之间需要权衡一下。但其他语言如 TypeScirpt
内存消耗只有100m左右,这对服务端来说是完全可以承受的(TypeScript大法好)。
在这个服务中,我们使用 NodeJs 的 childProcess
来启动 LSP 程序,如果单纯的把服务运行在 Docker 中显然不能接受,因为这样的话我们的 Docker 镜像需要包含 NodeJs、Java、Python 等许多语言的运行环境,这将导致生成的镜像非常大,也违背了容器单服务单进程的约定。所以最好的办法是将每个 LSP 程序拆成一个容器,通用服务也作为一个容器运行,使用 docker-compose 来管理多个容器。
本文代码托管在 GitHub,CloudStudio 已经实现 Java、Python 的 LSP 服务,有兴趣可以戳这里体验。
容器化完成后再来续下一篇...
本系列文章为Monaco-Editor编辑器折腾、踩坑记录,涉及到协同编辑、代码提示、智能感知等功能的实现,不定期更新
monaco-editor是微软开源的一款web端文本编辑器,也就是vscode内置的编辑器,扩展性很强,原生暴露了很多用于代码提示、高亮显示等API
仅为核心编辑器部分,不包含vscode的插件系统、文件数及terminal
monaco的基本用法非常简单,导入核心依赖及相应语言依赖包,调用monaco.editor.create
方法即可创建一个简单的编辑器
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands';
import 'monaco-editor/esm/vs/editor/contrib/find/findController';
// php依赖包,提供代码语法解析及代码高亮等功能
import 'monaco-editor/esm/vs/basic-languages/php/php';
import 'monaco-editor/esm/vs/basic-languages/php/php.contribution';
const container = document.querySelector('#container');
const editor = monaco.editor.create(container, {
language: 'php',
glyphMargin: true,
lightbulb: {
enabled: true,
},
theme: 'vs-dark',
});
monaco的文档是基于typescript的类型声明及注释生成的,所以要开发高级功能大多数情况下需要翻阅monaco.d.ts文件来查找api定义及用法(参考如何画马)😆
多人协同编辑,顾名思义就是像Google Doc以及石墨文档、腾讯文档等在线文档产品一样可以两人或两人以上同时编辑同一个文件,双方编辑操作互不干扰且能够自动解决冲突,这里不讨论代码编辑器实时协作功能的必要性,只谈实现。
协同编辑基本实现思路有两种
CRDT即无冲突可复制数据类型
,看上去很难理解(其实我也不怎么理解),这是一些分布式系统中适应于不同场景且可以保持最终一致性的数据结构的统称。
也就是说CRDT本身只是一个概念,应用于协作编辑中需要自行实现数据结构,比如GitHub团队开源的teletype-crdt,ATOM的实时协作功能就是基于这个库来实现的,数据传输采用WebRTC,只有在最初的邀请/加入阶段依赖GitHub的服务器外,所有的传输都是点对点的(peer-to-peer),同时以确保隐私,所有数据都是加密的。
Operational-Transformation
或者叫操作转换,是指对文档编辑以及同时编辑冲突解决的一类技术,不仅仅是一个算法。与CRDT不同的是,OT算法全程依赖于服务器来保持最终一致性。成本而言,CRDT优于OT,但因CRDT的实现复杂性(没学会),本文主要介绍基于OT算法的实时协同编辑。
OT算法不仅可用于纯文本操作,同时还支持一些更为复杂的场景:
协同图形编辑
CoFlash 支持实时协作的多媒体编辑器,可以让多个用户在同一 Adobe Flash 中同时编辑同一文档
协同HTML/XML以及富文本编辑
EtherPad 基于网络的实时协作编辑器
协同电子表格、Word文档等
计算机辅助设计(Maya)
CoMaya 用于多人协同编辑 Autodesk Maya 文档
等
OT算法维持一致性的基本思路是根据先前执行的并发操作的影响
将编辑操作转换为新形式,以便转换后的操作可以实现正确的效果,并确保复制的文档相同。事实上,并不是在多人同时编辑相邻字符时才必须要使用OT,OT的适用性与并发操作的字符/对象数量无关,无论这些目标对象是否相互重叠,无论这些字符相邻远近,OT都会针对具有位置依赖关系的对象进行并发控制。
OT将文档变更表示为三类操作(Operational)
Insert 插入
Retain 保留
Delete 删除
例如对于一个原始内容为“abc”的文档,假设用户O1在文档位置0处插入一个字符“x”,表示为Insert[0,"x"]
,用户O2在文档位置2处删除一个字符,表示为Delete[2,1]
(或者Delete[2,'c']),这将产生一个并发操作。在OT的控制下,本地操作会如期执行,远端服务器收到两个操作后会进行转换Transformation
,具体过程如下
O2' = T(O2,O1) = Delete[3,1]
,产生了一个新的操作,此时位置增加了1,因为O1插入了一个字符。然后在文档“xabc”执行O2',此时文档内容变为“xab”,即“c”被正确的删除。(如果不进行转换,会错误的删除“b”)。O1' = T(O1, o2) = Insert[0,"x"]
,也产生了一个新的操作,由于先前执行的O2与O1互不影响,转换后的O1'与O1相同,文档内容变为“xab”。这里忽略了光标操作,实际上多用户实时编辑时,应用在编辑器上,并不会真正的去移动光标,只会在相应的位置插入一个fake cursor。
我们使用ot.js来实现Monaco-Editor的协同编辑。
ot.js包含客户端与服务端的实现,在客户端,它将编辑操作转换为一系列的operation。
// 对于文档“Operational Transformation”
const operation = new ot.Operation()
.retain(11) // 前11个字符保留
.insert("color"); // 插入字符
// 这将使文档变更为 "Operationalcolor"
// “abc”
const deleteOperation = new ot.Operation()
.retain(2) //
.delete(1)
.insert("x") // axc
同时operation也是可组合的,比如将两个操作组合为一个操作
const operation0 = new ot.Operation()
.retain(13)
.insert(" hello");
const operation1 = new ot.Operation()
.delete("misaka ")
.retain(13);
const str0 = "misaka mikoto";
const str1 = operation0.apply(str0); // "misaka mikoto hello"
const str2a = operation1.apply(str1); // "mikoto hello"
// 组合
const combinedOperation = operation0.compose(operation1);
const str2b = combinedOperation.apply(str0); // "mikoto dolor"
应用到Monaco中,我们需要监听编辑器的onChange事件以及光标相关操作事件(selectionChange,cursorChange,blur等)。在文本内容修改的事件中,将每次修改产生的changes
转换为一个或多个操作,也叫operation
。光标的操作很好处理,转换成一个Retain
操作即可。
const editor = monaco.editor.create(container, {
language: 'php',
glyphMargin: true,
lightbulb: {
enabled: true,
},
theme: 'vs-dark',
});
editor.onDidChangeModelContent((e) => {
const { changes } = e;
let docLength = this.editor.getModel().getValueLength(); // 文档长度
let operation = new TextOperation().retain(docLength); // 初始化一个operation,并保留文档原始内容
for (let i = changes.length - 1; i >= 0; i--) {
const change = changes[i];
const restLength = docLength - change.rangeOffset - change.text.length; // 文档
operation = new TextOperation()
.retain(change.rangeOffset) // 保留光标位置前的所有字符
.delete(change.rangeLength) // 删除N个字符(如为0这个操作无效)
.insert(change.text) // 插入字符
.retain(restLength) // 保留剩余字符
.compose(operation); // 与初始operation组合为一个操作
});
这段代码首先创建了一个编辑器实例,监听了onDidChangeModelContent
事件,遍历changes数组,change.rangeOffset代表产生操作时的光标位置,change.rangeLength代表删除的字符长度(为0即没有删除操作),restLength是根据文档最终长度 - 光标位置 - 插入字符长度得出,用于在文档中间位置插入字符时保留剩余字符的操作。
但同时我们也要考虑到撤销/重做,ot.js中对撤销/重做的处理是每次编辑操作都需要产生对应的逆操作
,并存入撤销/重做栈,在上面代码的循环体中,我们还需要添加一个名为inverse
的操作。
let inverse = new TextOperation().retain(docLength);
// 获取删除的字符,实现略
const removed = getRemovedText(change, this.documentBeforeChanged);
inverse = inverse.compose(
new TextOperation()
.retain(change.rangeOffset) // 与编辑相同
.delete(change.text.length) // 插入变为删除
.insert(removed) // 删除变为插入
.retain(restLength); // 同样保留剩余字符
这样就产生了一个编辑操作和一个用于撤销的逆操作,编辑操作会发送到服务端进行转换同时再发送到给其他客户端,逆操作保存在本地用于实现撤销。
撤销/重做的思路很简单,因为不论如何都会对编辑器产成一个change事件,并且实时编辑的状态下,两个用户的撤销/重做栈需要互相独立,也就是说A的操作不能进入B的撤销栈,因而在B执行撤销的时候只能对自己先前的操作产生影响,不能撤销A的编辑,所以我们需要实现一个自定义的撤销函数来覆盖编辑器自带的撤销功能。
得益于monaco强大的扩展性,我们很容易就覆盖了默认的撤销
this.editor.addAction({
id: 'cuctom_undo',
label: 'undo',
keybindings: [
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_Z
],
run: () => {
this._undoFn()
}
})
这里_undoFn
的实现不再赘述,实际就是将先前change事件中产生的逆操作保存在一个自定义的undoManager中,每次执行撤销就undoStack.pop()
拿出最近一次的操作并应用在本地,同时发送给协作者,因为undoManager中并未保存协作者的逆操作,所以执行撤销不会影响协作者的操作。
ot.js还包含了服务端的实现,只需要将ot.js的服务端代码运行在nodejs中,同时搭建一个简单的websocket服务器即可。
const EditorSocketIOServer = require('ot.js/socketio-server.js');
const server = new EditorSocketIOServer("", [], 1);
io.on('connection', function(socket) {
server.addClient(socket);
});
服务端接收到每个协作者的operation并进行转换后下发到其他协作者客户端,转换操作实际是调用一个transform
函数,可以戳这里ot.js的transform实现查看,实际上这个函数也正是OT技术的核心,由于笔者技术有限,所以不再详细解读这个函数的源码(逃
以上是使用OT在Monaco编辑器中实现实时协同编辑的过程,除了文本编辑操作、撤销/重做机制,还需要处理多光标、多选区等行为,Monaco都有对应的API,很容易就可以实现。
本文简单介绍了Monaco编辑器、实时协同编辑的相关技术、OT技术的基本思路,以及结合Monaco编辑器与ot.js实现协同编辑的方法和服务端的相关处理,如有感兴趣的读者可以点击前往CloudStudio试用。
本系列文章为Monaco-Editor编辑器折腾、踩坑记录,涉及到协同编辑、代码提示、智能感知等功能的实现,不定期更新
LanguageServerProtocol(以下简称LSP)是由微软提出,并与 Redhat、Codenvy、Sourcegraph 等公司联合推出的开源协议。用于语言服务程序向编辑器、IDE 等工具提供一系列代码提示、定义跳转等功能的通用协议。它将高级语言相关的一些功能特性从传统 IDE 中抽象出一个单独的程序来运行,LSP 定义了一套通用的API,遵循LSP协议实现某个语言的特性功能后,编辑器只需要调用该语言的 LanguageServer ,即可实现代码提示、定义跳转、代码诊断等功能。
传统的IDE或编辑器要实现诸如智能提示、自动补全等功能,需要根据不同的IDE来开发相应语言的特性功能程序,多个 IDE 要想支持多种高级语言,且每个 IDE 的具体实现及 API 可能都大不相同,开发成本非常高。LSP的出现则很好的解决了这个问题,N 个 IDE 和 M 个语言,只需要开发一次相应语言的语言服务器程序即可在每个IDE中使用。
LSP使用JSON-RPC协议作为 Server/Client 通信的消息格式,且支持 TCP、Stdin/Stdout 进行消息传输,所以它即可以运行在本地客户端,也可以运行在远程服务器上。
截至目前 LSP 版本为3.8,实现了数十个方法(具体没数😆),部分主流 IDE/编辑器也已经支持了 LSP ,包括 Eclipse、VScode、Sublime Text & Sublime Text 3、Atom 等。
LSP协议基本消息格式由 header
与 content
组成,中间使用\r\n
作为分隔符。
Content-length: ... \r\n
\r\n
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/didOpen",
"params": {
...
}
}
LSP消息大体来说分为三种类型
通信是双向的,Client 可以向 Server 发送请求/通知,比如打开文件、修改文档内容等。 Server 也可以向 Client 发送请求/通知,比如动态注册客户端功能。每个请求需要使用 id
为唯一标识符,对这个请求的返回值也应当包含这个 id,一般来说 id 为递增的数字。
LSP的工作流程如下:
Client 发送 initialize
请求,包含一些初始化参数。Server 收到请求后开始准备启动语言服务,之后 Server 会发送 initialized
通知到客户端,语言服务开始工作。
初始化成功后 Server 可能会向 Client 发送一些动态注册功能的请求 client/registerCapability
。
每次打开一个文件, Client 需要向 Server 发送一个 textDocument/didOpen
请求,携带文件 URI 参数。同理关闭文件后要发送一个 textDocument/didClose
请求。
编辑文档时,当输入.
或按下语法提示快捷键时, Client 发送 textDocument/completion
请求来获取智能提示列表。
当用户查询某个类/变量/方法的声明时(点击跳转),Client 发送 textDocument/definition
,Server 将返回对应的文件 URI 及位置信息。Client 需要实现打开这个新文件的方法。
当用户关闭编辑器时,Client 先发送 shutdown
请求,Server 收到请求后会立即关闭但并不会退出进程,而是等待 Client 发送 exit
通知。
虽然 monaco 编辑器脱胎于 VScode ,但其只是一个编辑器实现,没有文件树,多标签页支持。同时 VScode 是基于 Electron 的桌面端应用,自带 Nodejs 环境,可以利用 TCP 或 Stdin/Stdout 来开启语言服务,虽然 VScode 团队开源了一些 LSP 相关的库,但由于运行环境的巨大差异,在 Web 端并不能直接应用。
要在 VScode 中体验 LSP, 需要先下载安装 vscode-java 插件。这个插件由 redhat-developer 团队开源,使用 TypeScript 及 JavaScript 编写,主要作用是下载和构建 eclipse.jdt.ls 程序, 以及创建 LanguageClient 使 VScode 能够启动 LSP。 eclipse.jdt.ls 就是 eclipse 开发的 Java 语言服务器 LSP 实现。
LanguageClient 类由 VScode 团队开源的 vscode-languageclient 库提供,它的主要作用是根据传入的配置连接到指定语言的 LSP,并对 LSP 支持的各种方法做一层封装,还包含了本地运行 LSP 程序时对 TCP 消息进行粘包处理的功能。
import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-language-client';
const clientOptions: LanguageClientOptions = {
documentSelector: [
{ scheme: 'file', language: 'java' },
{ scheme: 'jdt', language: 'java' },
{ scheme: 'untitled', language: 'java' }
],
synchronize: {
configurationSection: 'java',
fileEvents: [
workspace.createFileSystemWatcher('**/*.java'),
workspace.createFileSystemWatcher('**/pom.xml'),
workspace.createFileSystemWatcher('**/*.gradle'),
workspace.createFileSystemWatcher('**/.project'),
workspace.createFileSystemWatcher('**/.classpath'),
workspace.createFileSystemWatcher('**/settings/*.prefs'),
workspace.createFileSystemWatcher('**/src/**')
],
},
};
const serverOptions: ServerOptions = {
command: 'java',
args: [
// jdt.ls 启动参数
],
options: {
// 相关配置
}
}
const client = new LanguageClient({
'java',
'Language Support for Java',
serverOptions,
clientOptions,
});
client.start();
这个库源代码实际包含在 vscode-languageserver-node 中,猜测可能是 VScode 团队实现 Nodejs 的 LSP 客户端/服务端后觉得它可以作为一个通用的客户端实现,所以单独发布到了 npm 上。
之前说过,LSP 支持 TCP 和 Stdin/out 来和客户端通信。
如果是以 TCP 的方式, jdt.ls 启动时需要指定一个 CLIENT_PORT
参数表明 TCP 服务的端口,需要注意的是以 TCP 模式启动 jdt.js,LSP 是作为 TCP 客户端,所以需要再开启一个 TCP 服务器,之后 jdt.ls 才会连接到指定端口的服务上进行通信。
如果是以 Stdin/Stdout 启动,则只需要使用 Nodejs 的 Childprocess 开启一个子进程,然后利用标准输入输出与 Client 通信。
浏览器是一个封闭的环境,它只能操作 DOM ,所以要想在浏览器中为 monaco 编辑器提供 LSP 服务,必须要把 LSP 运行在服务器上。
由于 LSP 和 monaco 本身就是同一个团队开发的,所以 jdt.ls 的实现也可以完美兼容 monaco。我们使用 webSocket 与服务端通信,由于浏览器端的限制,我们无法直接使用 vscode-languageclient ,幸好 typefox 团队基于 vscode-languageclient 开发了使用于浏览器端的适配器 monaco-languageclient。借助这个库,我们可以使用 webSocket 轻松的连接远端 LSP 服务。
import { createMonacoServices } from 'monaco-languageclient';
import { listen, MessageConnection } from 'vscode-ws-jsonrpc';
import * as monaco from 'monaco-editor';
const editor = monaco.editor.create(root, {
model: monaco.editor.createModel(value, 'java', monaco.Uri.parse(`file://javademo/Hello.java`)),
theme: 'vs-dark',
});
const url = 'ws://127.0.0.1/java-lsp';
// 创建 services,向编辑器注册一系列命令
const services = createMonacoServices(editor, { rootUri: `file://javademo` });
const webSocket = new WebSocket(url);
// 监听 webSocket 连接,连接成功后创建客户端并启动
listen({
webSocket,
onConnection: (connection: MessageConnection) => {
const languageClient = createLanguageClient(connection);
const disposable = languageClient.start();
connection.onClose(() => disposable.dispose());
}
});
function createLanguageClient(connection: MessageConnection): BaseLanguageClient {
return new BaseLanguageClient({
name: "Java LSP client",
clientOptions: {
documentSelector: ['java'],
errorHandler: {
error: () => ErrorAction.Continue,
closed: () => CloseAction.DoNotRestart
}
},
services,
connectionProvider: {
get: (errorHandler, closeHandler) => {
return Promise.resolve(createConnection(connection, errorHandler, closeHandler))
}
}
})
}
这里我们还使用了一个库 vscode-ws-jsonrpc
,这也是 typefox 团队根据原 VScode 的 vscode-jsonrpc
修改而来。原本的 vscode-jsonrpc
并不支持 WebSocket,所以对它进行了扩展以支持浏览器端。
在服务端我们需要用 Nodejs 的 Childprocess 启动 jdt.ls,同时还要再开启一个 webSocket 服务器。监听 websocket 的 onmessage 事件,将 data 通过 stdin 发送给 LSP, 再监听 stdout 的 ondata 事件,将返回结果通过 webSocket 发送到浏览器端。
import * as cp from 'child-process';
import * as express from 'express';
import * as glob from 'glob';
import WebSocket from 'ws';
const CONFIG_DIR = process.platform === 'darwin' ? 'config_mac' : process.platform === 'linux' ? 'config_linux' : 'config_win';
const BASE_URI = '/data/eclipse.jdt.ls/server';
type IJavaExecutable = {
options: any;
command: string;
args: Array<string>;
}
const PORT = 9988;
const SERVER_HOME = 'lsp-java-server';
const launchersFound: Array<string> = glob.sync('**/plugins/org.eclipse.equinox.launcher_*.jar', { cwd: `./${SERVER_HOME}` });
if (launchersFound.length === 0 || !launchersFound) {
throw new Error('**/plugins/org.eclipse.equinox.launcher_*.jar Not Found!');
}
const params: Array<string> = [
'-Xmx256m',
'-Xms256m',
'-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=,quiet=y',
'-Declipse.application=org.eclipse.jdt.ls.core.id1',
'-Dosgi.bundles.defaultStartLevel=4',
'-noverify',
'-Declipse.product=org.eclipse.jdt.ls.core.product',
'-jar',
`${BASE_URI}/${launchersFound[0]}`,
'-configuration',
`${BASE_URI}/${CONFIG_DIR}`
];
export function prepareExecutable(): IJavaExecutable {
let executable = Object.create(null);
let options = Object.create(null);
options.env = process.env;
options.stdio = 'pipe';
executable.options = options;
executable.command = 'java';
executable.args = params;
return executable;
}
const executable = prepareExecutable();
const app = express();
const server = app.listen(3000);
const ws = new WebSocket.Server({
noServer: true,
perMessageDeflate: false
});
server.on('upgrade', (request: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
const pathname = request.url ? url.parse(request.url).pathname : undefined;
if (pathname === '/java-lsp') {
wss.handleUpgrade(request, socket, head, webSocket => {
const socket: rpc.IWebSocket = {
send: content => webSocket.send(content, error => {
if (error) {
throw error;
}
}),
onMessage: cb => webSocket.on('message', cb),
onError: cb => webSocket.on('error', cb),
onClose: cb => webSocket.on('close', cb),
dispose: () => webSocket.close()
};
if (webSocket.readyState === webSocket.OPEN) {
launch(socket);
} else {
webSocket.on('open', () => launch(socket));
}
});
}
});
function launch(socket) {
const process = cp.spawn(executable.command, executable.args);
sockt.onMessage((data) => {
process.stdin.write(data)
});
process.stdout.on('data', (respose) => {
socket.send(respose)
});
}
webSocket 服务器实际作为一个中转层,将浏览器与 LSP 连接起来,这样就实现了最基本的语言服务连接。
除此之外,jdt.ls 还支持 maven 项目的原生支持以及 gradle 项目的有限支持(不支持 Android )项目,客户端还需要实现文件监控功能,当 pom.xml
、budile.gradle
等构建工具相关配置文件发生改变时语言服务会自动下载依赖修改项目配置。
本文介绍了 LanguageServerProtocol 的基本概念及编辑器与 LSP 的简单交互流程,了解了 VScode 如何利用 LSP 实现代码提示、智能感知、自动完成等功能,最后在 Web 端实现了编辑器与 LSP 服务的简单连接。LSP 打破了传统 IDE 重复实现多次语言特性功能的尴尬局面,并在 VScode 上做了非常好的实践,文中使用的 eclipse.jdt.ls
语言服务器已经在 Cloud Studio 2.0 版本正式上线,感兴趣的读者可以点击创建一个 Java 项目试用。
写这篇文章是因为最近一段时间的工作涉及到 Cloud Studio 插件这一块的内容,旧的插件系统在面向用户开放后暴露了安全性、扩展性等诸多问题。调研了几个不同架构下 IDE 的插件系统实现( Theia, VSCode 等),也大致阅读了一遍 VSCode 插件系统相关的源码,在这里做一个简单的分享,个人水平有限,如有错误之处还请观众老爷们指点一下。
以我们熟悉的 vscode-eslint 为例,查看源码会发现入口是 extension.ts 文件里的 activate 函数,它的函数签名像这样:
activate(context: ExtensionContext): void
需要了解的一点是, package.json 里的 activationEvents 字段定义了插件的激活事件,考虑到性能问题,我们并不需要一启动 VSCode 就立即激活所有的插件。activation-events 定义了一组事件,当 activationEvents 字段指定的事件被触发时才会激活相应的插件。包含了特定语言的文件被打开,或者特定的【命令】被触发,以及某些视图被切换甚至是一些自定义命令被触发等等事件。
例如在 vscode-java 中,activationEvents 字段的值为
"activationEvents": [
"onLanguage:java",
"onCommand:java.show.references",
"onCommand:java.show.implementations",
"onCommand:java.open.output",
"onCommand:java.open.serverLog",
"onCommand:java.execute.workspaceCommand",
"onCommand:java.projectConfiguration.update",
"workspaceContains:pom.xml",
"workspaceContains:build.gradle"
]
其中包含 languageId 为 java 的文件被打开,以及由该插件自定义的几个 JDT 语言服务命令被触发,和【工作空间】包含 pom.xml/buld.gradle 这些事件。在以上事件被触发时插件将会被激活。
这段逻辑被定义在 src/vs/workbench/api/node/extHostExtensionService.ts
中
// 由 ExtensionHostProcessManager 调用并传入相应事件作为参数
public $activateByEvent(activationEvent: string): Thenable<void> {
return (
this._barrier.wait()
.then(_ => this._activateByEvent(activationEvent, false))
);
}
/* 省略部分代码 */
// 实例化 activator
this._activator = new ExtensionsActivator(this._registry, {
/* 省略部分代码 */
actualActivateExtension: (extensionDescription: IExtensionDescription, reason: ExtensionActivationReason): Promise<ActivatedExtension> => {
return this._activateExtension(extensionDescription, reason);
}
});
// 调用 ExtensionsActivator 的实例 activator 的方法激活插件
private _activateByEvent(activationEvent: string, startup: boolean): Thenable<void> {
const reason = new ExtensionActivatedByEvent(startup, activationEvent);
return this._activator.activateByEvent(activationEvent, reason);
}
其中 ExtensionsActivator 定义在 src/vs/workbench/api/node/extHostExtensionActivator.ts 中
export class ExtensionsActivator {
constructor(
registry: ExtensionDescriptionRegistry,
// 既上文中实例化 activator 传的第二个参数
host: IExtensionsActivatorHost,
) {
this._registry = registry;
this._host = host;
}
}
当调用 activator.activateByEvent 方法时(既某个事件被触发),activator 会获取所有符合该事件的插件并逐一执行 extHostExtensionService._activateExtension 方法(也就是 activator.actualActivateExtension) ,中间省去获取上下文,记录日志等一通操作后调用了 extHostExtensionService._callActivateOptional 静态方法
/* 省略部分代码 */
// extension.ts 里的 activate 函数
if (typeof extensionModule.activate === 'function') {
try {
activationTimesBuilder.activateCallStart();
logService.trace(`ExtensionService#_callActivateOptional ${extensionId}`);
// 调用并传入相关参数
const activateResult: Thenable<IExtensionAPI> = extensionModule.activate.apply(global, [context]);
activationTimesBuilder.activateCallStop();
activationTimesBuilder.activateResolveStart();
return Promise.resolve(activateResult).then((value) => {
activationTimesBuilder.activateResolveStop();
return value;
});
} catch (err) {
return Promise.reject(err);
}
}
至此,插件被成功激活。
再来看插件的代码,插件中需要引入一个叫 vscode 的模块
import * as vscode from 'vscode';
熟悉 TypeScript 的朋友都知道这实际上只是引入了一个 vscode.d.ts 类型声明文件而已,这个文件包含了所有插件可用的 API 及类型定义。
这些 API 在插件 import 时就被注入到了插件的运行环境中,它们定义在源码 src/vs/workbench/api/node/extHost.api.impl.ts
文件 createApiFactory
函数中,通过 defineAPI 函数统一被注入到插件运行环境。
function defineAPI(factory: IExtensionApiFactory, extensionPaths: TernarySearchTree<IExtensionDescription>, extensionRegistry: ExtensionDescriptionRegistry): void {
// each extension is meant to get its own api implementation
const extApiImpl = new Map<string, typeof vscode>();
let defaultApiImpl: typeof vscode;
// 已被全局劫持过的 require
const node_module = <any>require.__$__nodeRequire('module');
const original = node_module._load;
// 重写 Module.prototype._load 方法
node_module._load = function load(request: string, parent: any, isMain: any) {
// 模块名不是 vscode 调用原方法返回模块
if (request !== 'vscode') {
return original.apply(this, arguments);
}
// 这里会为每一个插件生成一份独立的 API (为了安全考虑?)
const ext = extensionPaths.findSubstr(URI.file(parent.filename).fsPath);
if (ext) {
let apiImpl = extApiImpl.get(ext.id);
if (!apiImpl) {
// factory 函数会返回所有 API
apiImpl = factory(ext, extensionRegistry);
extApiImpl.set(ext.id, apiImpl);
}
return apiImpl;
}
/* 省略部分代码 */
}
}
实际上也很简单,这里的 require
已经被 Microsoft/vscode-loader 劫持了,所以在插件代码中所有通过 import (运行时会被编译为 require) 引入的模块都会经过这里,通过这种方式将 API 注入到了插件执行环境中。
一般我们查看资源管理器或者进程会发现 VSCode 创建了很多个子进程,且所有插件都在一个独立的 Extension Host 进程在运行,这是考虑到插件需要在一个与主线程完全隔离的环境下运行,保证安全性。那么问题来了,我们调用 vscode.window.setStatusBarMessage('Hello World') 时是怎么在编辑器状态栏插入消息的?前文我们提到所有的 API 被定义在 extHost.api.impl.ts 文件的 createApiFactory 里,例如 vscode.window.setStatusBarMessage 的实现
const window: typeof vscode.window = {
/* 省略部分代码 */
setStatusBarMessage(text: string, timeoutOrThenable?: number | Thenable<any>): vscode.Disposable {
return extHostStatusBar.setStatusBarMessage(text, timeoutOrThenable);
},
/* 省略部分代码 */
}
实际调用的是 extHostStatusBar.setStatusBarMessage
函数,而 extHostStatusBar 则是 ExtHostStatusBar 的实例
const extHostStatusBar = new ExtHostStatusBar(rpcProtocol);
ExtHostStatusBar 包含了两个方法 createStatusBarEntry 和 setStatusBarMessage,createStatusBarEntry 返回了一个 ExtHostStatusBarEntry ,它被包装了一层代理,在 ExtHostStatusBar 被实例化化的同时也会产生一个 ExtHostStatusBarEntry 实例
export class ExtHostStatusBar {
private _proxy: MainThreadStatusBarShape;
private _statusMessage: StatusBarMessage;
constructor(mainContext: IMainContext) {
// 获取代理
this._proxy = mainContext.getProxy(MainContext.MainThreadStatusBar);
// 传入 this, StatusBarMessage 中也随即实例化了一个 ExtHostStatusBarEntry
this._statusMessage = new StatusBarMessage(this);
}
/* 省略部分代码 */
}
class StatusBarMessage {
private _item: StatusBarItem;
private _messages: { message: string }[] = [];
constructor(statusBar: ExtHostStatusBar) {
// 调用 createStatusBarEntry
this._item = statusBar.createStatusBarEntry(void 0, ExtHostStatusBarAlignment.Left, Number.MIN_VALUE);
}
/* 省略部分代码 */
}
所以当我们调用 setStatusBarMessage 时,先是调用了 this._statusMessage.setMessage 方法
// setStatusBarMessage 方法
let d = this._statusMessage.setMessage(text);
而 this._statusMessage.setMessage 方法经过层层调用,最终调用了 ExtHostStatusBarEntry 实例的 update 方法,也就是前面的 StatusBarMessage 构造函数中的 this._item.update,而这里就到了重头戏,update 方法中包含了一个 延时为 0 的 setTimeout :
this._timeoutHandle = setTimeout(() => {
this._timeoutHandle = undefined;
// Set to status bar
// 还记得一开始实例化 ExtHostStatusBar 中的 this._proxy = mainContext.getProxy(MainContext.MainThreadStatusBar); 吗
this._proxy.$setEntry(this.id, this._extensionId, this.text, this.tooltip, this.command, this.color,
this._alignment === ExtHostStatusBarAlignment.Left ? MainThreadStatusBarAlignment.LEFT : MainThreadStatusBarAlignment.RIGHT,
this._priority);
}, 0);
这里的 this.proxy 就是 ExtHostStatusBar 构造函数中的 this.proxy
constructor(mainContext: IMainContext) {
this._proxy = mainContext.getProxy(MainContext.MainThreadStatusBar);
this._statusMessage = new StatusBarMessage(this);
}
这里的 IMainContext 其实就是继承了 IRPCProtocol 的一个别名而已,new ExtHostStatusBar 的参数是一个 rpcProtocol 实例,它被定义在 src/vs/workbench/services/extensions/node/rpcProtocol.ts 中,我们重点看一下 getProxy 的实现
// 我错了,这里才是重头戏,VSCode 源码太绕了 /(ㄒoㄒ)/~~
public getProxy<T>(identifier: ProxyIdentifier<T>): T {
// 这里只是根据对应的 identifier 生成对应的 scope 而已,插件调用和 API 的调用一模一样比较方便一些
const rpcId = identifier.nid;
// 例如 StatusBar 的 identifier.nid 就是 'MainThreadStatusBar'
if (!this._proxies[rpcId]) {
// 缓存中没有代理则生成新的代理
this._proxies[rpcId] = this._createProxy(rpcId);
}
// 返回代理后的对象
return this._proxies[rpcId];
}
// 创建代理
private _createProxy<T>(rpcId: number): T {
let handler = {
get: (target: any, name: string) => {
// target 即表示 scope,name 即为被调用方法名
if (!target[name] && name.charCodeAt(0) === CharCode.DollarSign) {
target[name] = (...myArgs: any[]) => {
// 插件中的 API 实际被代理到 remoteCall,因为这是一个 RPC 协议
return this._remoteCall(rpcId, name, myArgs);
};
}
return target[name];
}
};
// 返回 API 代理
return new Proxy(Object.create(null), handler);
}
_createProxy 返回的是一个代理对象,即它代理了主线程中真正实现这些 API 的对象,例如 'MainThreadStatusBar' 返回的是一个 MainThreadStatusBarShape
类型的代理。
export interface MainThreadStatusBarShape extends IDisposable {
$setEntry(id: number, extensionId: string, text: string, tooltip: string, command: string, color: string | ThemeColor, alignment: MainThreadStatusBarAlignment, priority: number): void;
$dispose(id: number): void;
}
插件 API 定义中并没有实现这个接口,它只需要被主线程中对应的模块实现即可,前面我们说到 setStatusMessage 最终调用了 this._proxy.$setEntry。
_remoteCall 里会调用 RPCProcotol 的静态方法 serializeRequest 将 rpcId 方法名以及参数序列化成一个 Buffer 并发送给主线程。
const msg = MessageIO.serializeRequest(req, rpcId, methodName, args, !!cancellationToken, this._uriReplacer);
// 省略部分代码
this._protocol.send(msg);
关于主线程中接收到消息如何处理其实已经不用多说了,根据 rpcId 找到对应的 Services 以及方法,传入参数即可。
recompose
可以看做React技术栈的lodash
,提供了许多用于创建react函数式组件和高阶组件的工具函数,包括compose
、branch
、withState
、withStateHandlers
等
// 接收三个参数,第一个参数为注入state的key名,第二个参数为修改state的函数名,第三个参数为默认值
const enhance = withState('counter', 'setCounter', 0);
const Counter = enhance(({ counter, setCounter }) =>
<div>
Count: {counter}
<button onClick={() => setCounter(n => n + 1)}>Increment</button>
<button onClick={() => setCounter(n => n - 1)}>Decrement</button>
</div>
)
// 无状态组件
const ExpensiveComponent = ({ propA, propB }) => <div>{/*xxx*/}</div>
// 使用pure函数(效果等同于 extends PureComponent)
const OptimizedComponent = pure(ExpensiveComponent)
// 指定props更新
const HyperOptimizedComponent = onlyUpdateForKeys(['propA', 'propB'])(ExpensiveComponent)
compose
方法和之前redux源码中的compose方法一模一样,因为react的高阶组件实际上是接受组件作为参数并最终返回一个组件的高阶函数
,所以对于相同的组件支持使用compose函数直接组合不同的高阶组件,类似于这样一种方式
func0 = (component) => finalComponent;
func1 = (component) => finalComponent;
func2 = (component) => finalComponent;
func0(func1(func2(Component)));
直接看recompose的源代码实现会发现很多方法比如withState
、withProps
、withHandles
、withContext
都是直接返回一个高阶组件的,他们都长这样
const withXXX = (...args) => (BaseComponent) => {
class WithXXX extends PureComponent {
// 内部实现
}
return WithXXX;
}
对于这样的高阶组件,就可以使用compose方法来进行组合,compose函数的参数个数没有限制,但必须是一个高阶组件(满足输入一个组件并返回一个新组件),使用compose函数就可以组合这些方法,包装一个纯函数组件,利用这个办法可以把组件的副作用剥离出来,使外部业务逻辑不会干扰组件自身
// Toggle.js
const Toggle = ({ title, message, toggleVisibility, isVisible, name }) => (
<div>
<h1>{title}</h1>
{isVisible ? <p>{"I'm visible"}</p> : <p>{'Not Visible'}</p>}
<p>{message}</p>
<p>{name}</p>
<button onClick={toggleVisibility}> Click me! </button>
</div>
);
// 使用recompose包装Toggle
export default compose(
withState('isVisible', 'toggleVis', false),
withHandlers({
toggleVisibility: ({ toggleVis, isVisible }) => (event) =>
toggleVis(!isVisible),
}),
withProps(({ isVisible }) => ({
title: isVisible
? 'This is the visible title'
: 'This is the default title',
message: isVisible
? 'Hello I am Visible'
: 'I am not visible yet, click the button!',
})),
)(Toggle);
recompose推崇函数式无状态组件,因为这样既方便书写也可以把无用的逻辑抽离出来,使用lifecycle
函数可以为无状态组件添加生命周期函数
import { lifecycle } from 'recompose';
const cycle = {
componentDidMount() {
// didMount
},
componentWillReceiveProps(nextProps) {
// nextProps
},
componentWillUnmount() {
// willUnmount
}
};
const Toggle = (props) => <div>{props.value}</div>;
export default lifecycle(cycle)(Toggle);
nest
函数的作用是将传入的组件按照顺序一层一层嵌套起来,形成这样的jsx结构
<A>
<B>
<C>
{/*xxx*/}
</C>
</B>
</A>
使用nest可以将这些组件自动嵌套在一起
import { nest } from 'recompose';
const A = ({data, children}) => (
<div>
this is some node
{children}
</div>
);
const B = ({data, children}) => (
<div>
this is some node
{children}
</div>
);
const C = ({data, children}) => (
<div>
this is some node
{children}
</div>
);
export default nest(A, B, C);
需要注意的是组件传入的顺序决定嵌套层级,以及父组件需要使用{children}引用子组件
React组件大致可分为三种写法
一种es6的class语法,继承React.Component类实现带有完整生命周期的组件
import React, { Component } from 'react';
export default class SingleComponent extends Component {
/*
包含一些生命周期函数、内部函数以及变量等
*/
render() {
return (<div>{/**/}</div>)
}
}
第二种是无状态组件
,也叫函数式组件
const SingleComponent = (props) => (
<div>{props.value}</div>
);
export default SingleComponent;
还有一种较为特殊,叫高阶组件
,严格来说高阶组件只是用来包装以上两种组件的一个高阶函数
const HighOrderComponent = (WrappedComponent) => {
class Hoc extends Component {
/*包含一些生命周期函数*/
render() {
return (<WrappedComponent {...this.props} />);
}
}
return Hoc;
}
高阶组件的原理是接受一个组件并返回一个包装后的组件,可以在返回的组件里插入一些生命周期函数做相应的操作,高阶组件可以使被包装的组件逻辑不受干扰从外部进行一些扩展
react中组件自身的状态叫做state,在es6+的类组件中可以使用很简单的语法进行初始化
export default class Xxx extends Component {
state = {
name: 'sakura',
}
render() {
const { name } = this.state;
return (<div>{name}</div>);
}
}
state可以赋值给某个标签,如果需要更新state可以调用this.setState()
传入一个对象,通过这个方法修改state之后绑定了相应值的元素也会触发渲染,这就是简单的数据绑定
不能通过this.state.name = 'xxx'
的方式修改state,这样就会失去更新state同时相应元素改变的效果
setState
函数是react中较为重要也是使用频率较高的一个api,它接受最多两个参数,第一个参数是要修改的state对象,第二个参数为一个回调函数,会在state更新操作完成后自动调用,所以setState函数是异步
的。
调用this.setState之后react并没有立刻更新state,而是将几次连续调用setState返回的对象合并到一起,以提高性能,以下代码可能不会产生期望的效果
class SomeButton extends Component {
state = {
value: 1,
}
handleClick = () => {
const { value } = this.state;
this.setState({ value: value + 1 });
this.setState({ value: value + 1 });
this.setState({ value: value + 1 });
this.setState({ value: value + 1 });
}
render() {
const { value } = this.state;
return (<div>
<span>{vlaue}</span>
<button onClick={this.handleClick}>click!</button>
</div>);
}
}
实际上这里并没有对value进行4次+1的操作,react会对这四次更新做一次合并,最终只保留一个结果,类似于
Object.assign({},
{ value: value + 1 },
{ value: value + 1 },
{ value: value + 1 },
);
并且因为setState是异步的,所以不能在调用之后立马获取新的state,如果要用只能给setState传入第二个参数回调函数来获取
/*省略部分代码*/
this.setState({
value: 11,
}, () => {
const { value } = this.state;
console.log(value);
})
props
是由父元素所传递给给子元素的一个属性对象,用法通常像这样
class Parent extends Component {
/*父组件的state中保存了一个value*/
state = {
value: 0,
};
handleIncrease = () => {
const { value } = this.state;
this.setState({ value: value + 1 });
}
render() {
const { value } = this.state;
// 通过props传递给子组件Child,并传递了一个函数,用于子组件点击后修改value
return (<div>
<Child value={value} increase={this.handleIncrease} />
</div>)
}
}
// 子组件通过props获取value和increase函数
const Child = (props) => (
<div>
<p>{props.value}</p>
<button onClick={props.increase}>click!</button>
</div>
);
props像一个管道,父组件的状态通过props这个管道流向子组件,这个过程叫做单向数据流
react中修改state和props都会引起组件的重新渲染
生命周期是一组用来表示组件从渲染到卸载以及接收新的props以及state声明的特殊函数
这张图展示了react几个生命周期函数执行的过程,可以简单把组件的生命周期分为三个阶段,共包含9个生命周期函数,在不同阶段组件会自动调用
这个阶段组件准备开始渲染DOM节点,可以在这个方法里做一些请求之类的操作,但是因为组件还没有首次渲染完成,所以并不能拿到任何dom节点
正式渲染,这个方法返回需要渲染的dom节点,并且做数据绑定,这个方法里不能调用this.setState
方法修改state,因为setState会触发重新渲染,导致再次调用render函数触发死循环
这个阶段组件首次渲染已经完成,可以拿到真实的DOM节点,也可以在这个方法里做一些请求操作,或者绑定事件等等
当组件收到新的props和state且还没有执行render时会自动触发这个方法,这个阶段可以拿到新的props和state,某些情况下可能需要根据旧的props和新的props对比结果做一些相关操作,可以写在这个方法里,比如一个弹窗组件的弹出状态保存在父组件的state里通过props传给自身,判断这个弹窗弹出可以这样写
class Dialog extends Component {
componentWillReveiceProps(nextProps) {
const { dialogOpen } = this.props;
if (nextProps.dialogOpen && nextProps.dialogOpen !== dialogOpen) {
/*弹窗弹出*/
}
}
}
shouldComponentUpdate
是一个非常重要的api。react的组件更新过程经过以上几个阶段,到达这个阶段需要确认一次组件是否真的需要根据新的状态再次渲染,确认的依据就是对比新旧状态是否有所改变,如果没有改变则返回false,后面的生命周期函数不会执行,如果发生改变则返回true,继续执行后续生命周期,而react默认就返回true
所以可以得出shouldComponentUpdate可以用来优化性能,可以手动实现shouldComponentUpdate函数来对比前后状态的差异,从而阻止组件不必要的重复渲染
class Demo extends Component {
shouldComponentUpdate(nextProps, nextState) {
return this.props.value !== nextProps.value;
}
}
这段代码是一个最简单的实现,通过判断this.props.value
和nextProps.value
是否相同来决定组件要不要重新渲染,但是实际项目中数据复杂多样,并不仅仅是简单的基本类型,可能有对象、数组甚至是更深嵌套的对象,而数据嵌套越深就意味着这个方法里需要做更深层次的对比,这对react性能开销是极大的,所以官方更推荐使用Immutable.js来代替原生的JavaScript对象和数组
由于immutablejs本身是不可变的,如果需要修改状态则返回新的对象,也正因为修改后返回了新对象,所以在shouldComponentUpdate方法里只需要对比对象的引用就很容易得出结果,并不需要做深层次的对比。但是使用immutablejs则意味着增加学习成本,所以还需要做一些取舍
这个阶段是在收到新的状态并且shouldComponentUpdate确定组件需要重新渲染而还未渲染之前自动调用的,在这个阶段依然能获取到新的props和state,是组件重新渲染前最后一次更新状态的机会
根据新的状态重新渲染
重新渲染完毕
组件被卸载之前,在这里可以清除定时器以及解除某些事件
很多业务场景中经常会涉及到父=>子组件或者是子=>父组件甚至同级组件间的通信,父=>子组件通信非常简单,通过props传给子组件就可以。而子=>父组件通信则是大多数初学者经常碰到的问题
假设有个需求,子组件是一个下拉选择菜单,父组件是一个表单,在菜单选择一项之后需要将值传给父级表单组件,这是典型的子=>父组件传值的需求
const list = [
{ name: 'sakura', id: 'x0001' },
{ name: 'misaka', id: 'x0003' },
{ name: 'mikoto', id: 'x0005' },
{ name: 'react', id: 'x0002' },
];
class DropMenu extends Component {
handleClick = (id) => {
this.props.handleSelect(id);
}
render() {
<MenuWrap>
{list.map((v) => (
<Menu key={v.name} onClick={() => this.handleClick(v.id)}>{v.name}</Menu>
))}
</MenuWrap>
}
}
class FormLayout extends Component {
state = {
selected: '',
}
handleMenuSelected = (id) => {
this.setState({ selected: id });
}
render() {
<div>
<MenuWrap handleSelect={this.handleMenuSelected} />
</div>
}
}
这个例子中,父组件FormLayout
将一个函数传给子组件,子组件的Menu
点击后调用这个函数并把值传进去,而父组件则收到了这个值,这就是简单的子=>父组件通信
而对于更为复杂的同级甚至类似于叔侄关系的组件可以通过状态提升的方式互相通信,简单来说就是如果两个组件互不嵌套,没有父子关系,这种情况下,可以找到他们上层公用的父组件,将state存在这个父组件中,再通过props给两个组件传入相应的state以及对应的回调函数即可
React中最常用的路由解决方案就是React-router,react-router迄今为止已经经历了四个大版本的迭代,每一版api变化较大,本文将按照最新版react-router-v4进行讲解
使用路由,要先用Router
组件将App包起来,并把history对象通过props传递进去,最新版本中history被单独分出一个包,使用的时候需要先引入。对于同级组件路由的切换,需要使用Switch组件将多个Route包起来,每当路由变更,只会渲染匹配到的一个组件
import ReactDOM from 'react-dom';
import createHistory from 'history/createBrowserHistory';
import { Router } from 'react-router';
import App from './App';
const history = createHistory();
ReactDOM.render(
<Router history={history}>
<App />
</Router>,
element,
);
// App.js
//... 省略部分代码
import {
Switch, Route,
} from 'react-router';
class App extends Component {
render() {
return (
<div>
<Switch>
<Route exact path="/" component={Dashboard} />
<Route path="/about" component={About} />
</Switch>
</div>
);
}
}
关于单页面应用状态管理可以先阅读民工叔这篇文章单页应用的数据流方案探索
React生态圈的状态管理方案由facebook提出的flux架构为基础,并有多种不同实现,而最为流行的两种是
Flux is the application architecture that Facebook uses for building client-side web applications. It complements React's composable view components by utilizing a unidirectional data flow. It's more of a pattern rather than a formal framework, and you can start using Flux immediately without a lot of new code.
Flux是facebook用于构建web应用的一种架构,它通过使用单向数据流补充来补充React的组件,它只是一种模式,而不是一个正式的框架
首先,Flux将一个应用分为三个部分:
dispatcher
是管理Flux应用中所有数据流的中心枢纽,它的作用仅仅是将actions分发到stores,每一个store都监听自己并且提供一个回调函数,当用户触发某个操作时,应用中的所有store都将通过监听的回调函数来接收这个操作
stores包含应用程序的状态和逻辑,类似于传统MVC中的model,stores用于存储应用程序中特定区域范围的状态
一个store向dispatcher注册一个事件并提供一个回调函数,这个回调函数可以接受action作为参数,并且基于actionType来区分并解释操作。在store中提供相应的数据更新函数,在确认更新完毕后广播一个事件用于应用程序根据新的状态来更新视图
// Facebook官方实现FluxReduceStore的用法
import { ReduceStore, Dispatcher } from 'flux';
import Immutable from 'immutable';
const dispatch = new Dispatcher();
class TodoStore extends ReduceStore {
constructor() {
super(dispatch);
}
getInitialState() {
return Immutable.OrderedMap();
}
reduce(state, action) {
switch(action.type) {
case 'ADD_TODO':
return state.set({
id: 1000,
text: action.text,
complete: false,
});
default:
return state;
}
}
}
export default new TodoStore();
React提供了views所需的可组合以及可以自由的重新渲染的视图,在React最顶层组件里,通过某种粘合代码从stores中获取所需数据,并将数据通过props传递到它的子组件中,我们就可以通过控制这个顶层组件的状态来管理页面任何部分的状态
Facebook官方实现中有一个FluxContainer.js用于连接store与react组件,并在store更新数据后刷新组件状态更新视图。基本原理是用一个高阶组件传入Stores和组件需要的state与方法以及组件本身,返回注入了state和action方法的组件,基本用法像这样
import TodoStore from './TodoStore';
import Container from 'flux';
import TodoActions from './TodoActions';
// 可以有多个store
const getStores = () => [TodoStore];
const getState = () => ({
// 状态
todos: TodoStore.getState(),
// action
onAdd: TodoActions.addTodo,
});
export default Container.createFunctional(App, getStore, getState);
CodeSanbox在线示例
后续会补充flux官方实现的源码解析
Redux是由Dan Abramov对Flux架构的另一种实现,它延续了flux架构中views
、store
、dispatch
的**,并在这个基础上对其进行完善,将原本store中的reduce函数拆分为reducer
,并将多个stores合并为一个store,使其更利于测试
The Evolution of Flux Frameworks这篇文章,是他对原Flux架构的看法以及他的改进
The first change is to have the action creators return the dispatched action.What looked like this:
export function addTodo(text) {
AppDispatcher.dispatch({
type: ActionTypes.ADD_TODO,
text: text
});
}
can look like this instead:
export function addTodo(text) {
return {
type: ActionTypes.ADD_TODO,
text: text
};
}
stores拆分为单一store和多个reducer
const initialState = { todos: [] };
export default function TodoStore(state = initialState, action) {
switch (action.type) {
case ActionTypes.ADD_TODO:
return { todos: state.todos.concat([action.text]) };
default:
return state;
}
Redux把应用分为四个部分
views可以触发一个action,reducer函数内部根据action.type的不同来对数据做相应的操作,最后返回一个新的state,store会将所有reducer返回的state组成一个state树,再通过订阅的事件函数更新给views
react组件作为应用中的视图层
action是一个简单的JavaScript对象,包含一个type属性以及action操作需要用到的参数,推荐使用actionCreator
函数来返回一个action,actionCreator函数可以作为state传递给组件
function singleActionCreator(payload) {
return {
type: 'SINGLE_ACTION',
paylaod,
};
}
reducer是一个纯函数,简单的根据指定输入返回相应的输出,reducer函数不应该有副作用,并且最终需要返回一个state对象,对于多个reducer,可以使用combineReducer函数组合起来
function singleReducer(state = initialState, action) {
switch(action.type) {
case 'SINGLE_ACTION':
return { ...state, value: action.paylaod };
default:
return state;
}
}
function otherReducer(state = initialState, action) {
switch(action.type) {
case 'OTHER_ACTION':
return { ...state, data: action.data };
default:
return state;
}
}
const rootReducer = combineReducer([
singleReducer,
otherReducer,
]);
redux中store只有一个,通过调用createStore传入reducer就可以创建一个store,并且这个store包含几个方法,分别是subscribe, dispatch,getState,以及replaceReducer,subscribe用于给state的更新注册一个回调函数,而dispatch用于手动触发一个action,getState可以获取当前的state树,replaceReducer用于替换reducer,要在react项目中使用redux,必须再结合react-redux
import { connect } from 'react-redux';
const store = createStore(rootReducer);
// App.js
class App extends Component {
render() {
return (
<div>
test
</div>
);
}
}
const mapStateToProps = (state) => ({
vlaue: state.value,
data: state.data,
});
const mapDispatchToProps = (dispatch) => ({
singleAction: () => dispatch(singleActionCreator());
});
export default connect(mapStateToProps, mapDispatchToProps)(App);
// index.js
import { Provider } from 'react-redux';
ReactDOM.render(
<Provider store={store}>
<APP />
</Provider>,
element,
);
Redux本身从action==>reducer==>store==>views这个过程是完全同步的,如果要进行异步操作比如请求接口,那么异步请求写在哪里是个问题,actioncreator是一个只返回action的函数,而reducer又是一个纯函数,原则上最好不要有其他操作
基于这个问题,redux很巧妙的实现了中间件
机制,中间件的用法可以看我的这篇文章了解applymiddleware
目前较为常用的redux中间件是redux-saga、redux-observable
用法可以参考社区的这篇文章Redux异步方案选型
VSCode 作为时下最为流行的代码编辑器,自2015年推出以来逐渐蚕食了 Sublime Text、Atom 等编辑器的市场份额,占领了编辑器领域的半壁江山,截至目前其 GitHub 仓库的 star 数已经达到了 7w+,GitHub 2018年度报告 显示 VSCode 占据开源项目热度第一,Contributors 接近 2w。
上一篇文章 只对插件系统及其运行机制做了粗略的剖析,本文将开始尝试从源码入手解读 VSCode 的整体架构。
Workbench 即「工作区」,也就是 VSCode 主界面,众所周知 VSCode 是基于 Electron 构建的桌面应用程序,Electron 是基于 Chromium 和 Node.js 的跨平台桌面应用框架,VSCode 的工作区即是一个 Electron 的 BrowserWindow,与浏览器不同的是它还包含一个 Node.js 运行时,其渲染进程可以和 Node.js 进程通过 IPC 通信,所以在 BrowserWindow 中可以运行任何 Nodejs.js 模块。
src/main.js 负责初始化 Electron 应用
// src/main.js
const app = require('electron').app;
app.once('ready', function () {
onReady();
}
onReady 中读取了用户语言设置并劫持了默认的 require 为一个修改过的 loader,用它来加载 src/vs/code/electron-main/main 模块,这是 VSCode 真正的入口,负责解析环境变量和初始化主界面以及创建其他模块所依赖的「Services」。
Services(服务) 是 VSCode 中一系列可以被注入的公共模块,这些 Services 分别负责不同的功能,在这里创建了几个基本服务
// src/vs/code/electron-main/main.ts
function createServices(args: ParsedArgs, bufferLogService: BufferLogService): IInstantiationService {
const services = new ServiceCollection();
const environmentService = new EnvironmentService(args, process.execPath);
const logService = new MultiplexLogService([new ConsoleLogMainService(getLogLevel(environmentService)), bufferLogService]);
process.once('exit', () => logService.dispose());
// environmentService 一些基本配置,包括运行目录、用户数据目录、工作区缓存目录等
services.set(IEnvironmentService, environmentService);
// logService 日志服务
services.set(ILogService, logService);
// LifecycleService 生命周期相关的一些方法
services.set(ILifecycleService, new SyncDescriptor(LifecycleService));
// StateService 持久化数据
services.set(IStateService, new SyncDescriptor(StateService));
// ConfigurationService 配置项
services.set(IConfigurationService, new SyncDescriptor(ConfigurationService));
// RequestService 请求服务
services.set(IRequestService, new SyncDescriptor(RequestService));
// DiagnosticsService 诊断服务,包括程序运行性能分析及系统状态
services.set(IDiagnosticsService, new SyncDescriptor(DiagnosticsService));
return new InstantiationService(services, true);
}
除了这些基本服务,VSCode 内还包含了大量的服务,如 IModeService、ICodeEditorService、IPanelService 等,通过 VSCode 实现的「依赖注入」模式,可以在需要用到这些服务的地方以 Decorator 的方式做为构造函数参数声明依赖,会被自动注入到类中。
例如
// src/vs/workbench/electron-browser/workbench.ts
class Workbench extends Disposable implements IPartService {
constructor(
private container: HTMLElement,
private configuration: IWindowConfiguration,
serviceCollection: ServiceCollection,
private mainProcessClient: IPCClient,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,
@IStorageService private readonly storageService: IStorageService,
@IConfigurationService private readonly configurationService: WorkspaceService,
@IEnvironmentService private readonly environmentService: IEnvironmentService,
@ILogService private readonly logService: ILogService,
@IWindowsService private readonly windowsService: IWindowsService
){}
}
这些服务会在不同的阶段被创建,关于依赖注入的细节之后会单独写一篇文章解读原理,这里不再赘述。
基础服务初始化完成后会加载 IPC 信道并创建 CodeApplication 实例,调用 startup 方法启动 code
// src/vs/code/electron-main/app.ts
function startup(args: ParsedArgs): void {
// We need to buffer the spdlog logs until we are sure
// we are the only instance running, otherwise we'll have concurrent
// log file access on Windows (https://github.com/Microsoft/vscode/issues/41218)
const bufferLogService = new BufferLogService();
// 使用之前创建的 services 创建「实例服务」
const instantiationService = createServices(args, bufferLogService);
instantiationService.invokeFunction(accessor => {
const environmentService = accessor.get(IEnvironmentService);
const stateService = accessor.get(IStateService);
// 根据 environmentService 的配置将必要的环境变量添加到 process.env 中
const instanceEnvironment = patchEnvironment(environmentService);
// Startup
return initServices(environmentService, stateService as StateService)
.then(() => instantiationService.invokeFunction(setupIPC), error => { // setupIPC 负责加载 IPC 信道用于进程间通信
// Show a dialog for errors that can be resolved by the user
handleStartupDataDirError(environmentService, error);
return Promise.reject(error);
})
.then(mainIpcServer => {
bufferLogService.logger = createSpdLogService('main', bufferLogService.getLevel(), environmentService.logsPath);
// 实例服务创建 CodeApplication 实例并调用 startup
return instantiationService.createInstance(CodeApplication, mainIpcServer, instanceEnvironment).startup();
});
}).then(null, err => instantiationService.invokeFunction(quit, err));
}
CodeApplication.startup 中首先会启动 SharedProcess 共享进程,同时也创建了一些窗口相关的服务,包括 WindowsManager、WindowsService、MenubarService 等,负责窗口、多窗口管理及菜单等功能。
app.ts 中的 openFirstWindow 负责处理首次开启窗口,这里会先创建一系列 Electron 的 IPC 频道,用于主进程和渲染进程间通信
const appInstantiationService = accessor.get(IInstantiationService);
// Register more Main IPC services
const launchService = accessor.get(ILaunchService);
const launchChannel = new LaunchChannel(launchService);
this.mainIpcServer.registerChannel('launch', launchChannel);
// Register more Electron IPC services
const updateService = accessor.get(IUpdateService);
const updateChannel = new UpdateChannel(updateService);
this.electronIpcServer.registerChannel('update', updateChannel);
const issueService = accessor.get(IIssueService);
const issueChannel = new IssueChannel(issueService);
this.electronIpcServer.registerChannel('issue', issueChannel);
const workspacesService = accessor.get(IWorkspacesMainService);
const workspacesChannel = appInstantiationService.createInstance(WorkspacesChannel, workspacesService);
this.electronIpcServer.registerChannel('workspaces', workspacesChannel);
const windowsService = accessor.get(IWindowsService);
const windowsChannel = new WindowsChannel(windowsService);
this.electronIpcServer.registerChannel('windows', windowsChannel);
this.sharedProcessClient.then(client => client.registerChannel('windows', windowsChannel));
const menubarService = accessor.get(IMenubarService);
const menubarChannel = new MenubarChannel(menubarService);
this.electronIpcServer.registerChannel('menubar', menubarChannel);
const urlService = accessor.get(IURLService);
const urlChannel = new URLServiceChannel(urlService);
this.electronIpcServer.registerChannel('url', urlChannel);
const storageMainService = accessor.get(IStorageMainService);
const storageChannel = this._register(new GlobalStorageDatabaseChannel(storageMainService as StorageMainService));
this.electronIpcServer.registerChannel('storage', storageChannel);
// Log level management
const logLevelChannel = new LogLevelSetterChannel(accessor.get(ILogService));
this.electronIpcServer.registerChannel('loglevel', logLevelChannel);
this.sharedProcessClient.then(client => client.registerChannel('loglevel', logLevelChannel));
其中 window 和 logLevel 频道还会被注册到 sharedProcessClient ,sharedProcessClient 是主进程与共享进程(SharedProcess)进行通信的 client,我们之后再解释 SharedProcess 的 具体功能。
之后根据 environmentService 提供的相关参数(file_uri、folder_uri)准备打开窗口,最终调用了 windowsMainService.open 方法 (windowsMainService 即前文创建的 WindowsManager),open 方法解析了参数判断打开的目录路径并调用了 doOpen 方法,这里会根据传入的参数判断将要打开的窗口及相应的工作空间(或目录),创建 CodeWindow 实例,CodeWindow 封装了一个 Electron.BrowserWindow 对象,windowsMainService 中创建CodeWindow 实例后会调用其 load 方法正式加载窗口,实际是调用 browserWindow.loadURL 加载一个 HTML 文件,在这里是加载了 vs/code/electron-browser/workbench/workbench.html ,这是整个 Workbench 的入口,内容也很简单
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' https: data: vscode-remote:; media-src 'none'; child-src 'self'; object-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https:; font-src 'self' https:;">
</head>
<body class="vs-dark" aria-label="">
</body>
<!-- Startup via workbench.js -->
<script src="workbench.js"></script>
</html>
加载了一个 workbench.js 文件,这个文件负责加载真正的 Workbench 模块并调用其 main 方法初始化主界面
// src/vs/code/electron-browser/workbench/workbench.js
bootstrapWindow.load([
'vs/workbench/workbench.main',
'vs/nls!vs/workbench/workbench.main',
'vs/css!vs/workbench/workbench.main'
],
function (workbench, configuration) {
perf.mark('didLoadWorkbenchMain');
return process['lazyEnv'].then(function () {
perf.mark('main/startup');
// @ts-ignore
// 加载 Workbench 并初始化主界面
return require('vs/workbench/electron-browser/main').main(configuration);
});
}, {
removeDeveloperKeybindingsAfterLoad: true,
canModifyDOM: function (windowConfig) {
showPartsSplash(windowConfig);
},
beforeLoaderConfig: function (windowConfig, loaderConfig) {
loaderConfig.recordStats = !!windowConfig['prof-modules'];
if (loaderConfig.nodeCachedData) {
const onNodeCachedData = window['MonacoEnvironment'].onNodeCachedData = [];
loaderConfig.nodeCachedData.onData = function () {
onNodeCachedData.push(arguments);
};
}
},
beforeRequire: function () {
perf.mark('willLoadWorkbenchMain');
}
});
前文中的大量代码只是为这里最终创建主界面做铺垫,Workbench 模块主要代码都在 vs/workbench 目录下,主要负责界面元素的创建和具体业务功能的实现。
src/vs/workbench/electron-browser/main.ts 的 main 函数代码很简单
// src/vs/workbench/electron-browser/main.ts
export function main(configuration: IWindowConfiguration): Promise<void> {
const window = new CodeWindow(configuration);
return window.open();
}
要注意这里的 CodeWindow 和前面那个封装了 BrowserWindow 的 CodeWindow 并不是一个东西,这个 CodeWindow 只负责主界面渲染相关的功能,而之前的 CodeWindow 是负责整个窗口创建及生命周期管理(命名令人困惑)。PS: 最新代码中 CodeWindow 改名为 CodeRendererMain
window.open 里同样创建了依赖的一些服务,监听了 DOMContentLoaded 事件,浏览器 DOM 结构加载完成后创建 Workbench 实例并调用 workbench.startup 开始构建主界面布局、创建全局事件监听、加载设置项以及同样实例化一些依赖的服务,全部完成后会还原之前打开的编辑器,整个 Workbench 加载完成。
// src/vs/workbench/electron-browser/workbench.ts
private doStartup(): Promise<void> {
this.workbenchStarted = true;
// Logging
this.logService.trace('workbench configuration', JSON.stringify(this.configuration));
// ARIA
setARIAContainer(document.body);
// Warm up font cache information before building up too many dom elements
restoreFontInfo(this.storageService);
readFontInfo(BareFontInfo.createFromRawSettings(this.configurationService.getValue('editor'), getZoomLevel()));
this._register(this.storageService.onWillSaveState(() => {
saveFontInfo(this.storageService); // Keep font info for next startup around
}));
// Create Workbench Container
this.createWorkbench();
// Install some global actions
this.createGlobalActions();
// Services
this.initServices();
// Context Keys
this.handleContextKeys();
// Register Listeners
this.registerListeners();
// Settings
this.initSettings();
// Create Workbench and Parts
this.renderWorkbench();
// Workbench Layout
this.createWorkbenchLayout();
// Layout
this.layout();
// Driver
if (this.environmentService.driverHandle) {
registerWindowDriver(this.mainProcessClient, this.configuration.windowId, this.instantiationService).then(disposable => this._register(disposable));
}
// Handle case where workbench is not starting up properly
const timeoutHandle = setTimeout(() => {
this.logService.warn('Workbench did not finish loading in 10 seconds, that might be a problem that should be reported.');
}, 10000);
this.lifecycleService.when(LifecyclePhase.Restored).then(() => {
clearTimeout(timeoutHandle);
});
// Restore Parts
return this.restoreParts();
}
后记
VSCode 整体架构非常复杂,但同时源码非常清晰明了,也极少有第三方依赖,核心模块大都是由自身实现,包括依赖注入系统、模块加载(拦截加载器)、插件系统、语言服务、调试器前端及调试器协议等。同时界面包括文件树以及编辑器(Monaco)等长列表都实现了无限滚动(或者叫虚拟列表),整体性能表现非常卓越,虽然在安装大量插件后依然会出现卡顿甚至卡死等情况,但相比同样基于 Electron 架构的 Atom 编辑器来说表现已经非常令人满意了。
本文仅从 Workbench 创建的流程做粗略的解读,中间省去了部分代码及底层实现细节,之后逐步会从不同角度逐步深入,解读 VSCode 架构中一些值得学习的地方。
Visual Studio Code 是微软开源的一款轻量级代码编辑器,支持数十种主流语言的语法高亮、智能补全提示及 Git、Docker 集成等特性。因其自身使用 TypeScript 语言及 Electron 平台开发,对 ES/JavaScript/NodeJS 支持度较高,已经逐渐成为前端领域的主流开发工具。
前几篇文章介绍了 LSP 协议及在 Web 端在线编辑器中的集成,可以看到基于 LSP 协议,我们只需要找到对应语言的实现,就可以以非常低的成本在多个编辑器中使用语言服务器,甚至是在 Web 端。
同样在 VSCODE 中还存在一个 vscode-debug-protocol,这是一个通用的调试协议,允许在 VSCODE 的通用调试器 UI 下集成特定语言的调试器。
和 LSP 一样,vscode-debug-protocol 使用 JSONRPC 来描述请求、响应及事件,协议的具体规范可以在 debugProtocol.ts 中找到。
仍然以 Java 语言为例,在 VSCODE 中搜索并安装扩展 Debugger for Java
, 重载编辑器后即可使用 Java 调试器。
这里再简单介绍一下 Java 调试器的实现原理。
JPDA 定义了一个完整独立的体系,它由三个相对独立的层次共同组成,而且规定了它们三者之间的交互方式,或者说定义了它们通信的接口。这三个层次由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP)以及 Java 调试接口(JDI)。这三个模块把调试过程分解成几个很自然的概念:调试者(debugger)和被调试者(debuggee),以及他们中间的通信器。被调试者运行于我们想调试的 Java 虚拟机之上,它可以通过 JVMTI 这个标准接口,监控当前虚拟机的信息;调试者定义了用户可使用的调试接口,通过这些接口,用户可以对被调试虚拟机发送调试命令,同时调试者接受并显示调试结果。在调试者和被调试着之间,调试命令和调试结果,都是通过 JDWP 的通讯协议传输的。所有的命令被封装成 JDWP 命令包,通过传输层发送给被调试者,被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行。类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。而调试器开发人员就是通过 JDI 得到数据,发出指令。
JDI(Java Debug Interface)是 JPDA 三层模块中最高层的接口,定义了调试器(Debugger)所需要的一些调试接口。基于这些接口,调试器可以及时地了解目标虚拟机的状态,例如查看目标虚拟机上有哪些类和实例等。另外,调试者还可以控制目标虚拟机的执行,例如挂起和恢复目标虚拟机上的线程,设置断点等。
VirtualMachineManager virtualMachineManager = Bootstrap.virtualMachineManager();
LaunchingConnector defaultConnector = virtualMachineManager.defaultConnector();
VirtualMachine targetVM = defaultConnector.launch(arguments);
JDI 中 Mirror 接口是将目标虚拟机上的所有数据、类型、域、方法、事件、状态和资源,以及调试器发向目标虚拟机的事件请求等都映射成 Mirror 对象。例如,在目标虚拟机上,已装载的类被映射成 ReferenceType 镜像,对象实例被映射成 ObjectReference 镜像,基本类型的值(如 float 等)被映射成 PrimitiveValue(如 FloatValue 等)。被调试的目标程序的运行状态信息被映射到 StackFrame 镜像中,在调试过程中所触发的事件被映射成 Event 镜像(如 StepEvent 等),调试器发出的事件请求被映射成 EventRequest 镜像(如 StepRequest 等),被调试的目标虚拟机则被映射成 VirtualMachine 镜像。
上面提到虚拟机管理器默认使用启动型链接器进行链接,在 JDI **有三种链接器接口,分别是依附型链接器(AttachingConnector)、监听型链接器(ListeningConnector)和启动型链接器(LaunchingConnector)。而根据调试器在链接过程中扮演的角色,又分为主动链接和被动链接,例如由调试器启动目标虚拟机或当目标虚拟机已运行时调试器链接成为主动型,由于篇幅有限这里不再深入展开。
JDI 还包含了一个事件请求和处理模块,共包含了18种事件类型,分别作用于调试过程中的断点、异常、线程改变以及目标虚拟机生命周期等功能。
事实上这里的 Java 调试器是作为前几篇文章中提到的 JDT.LS 语言服务的插件。在语言服务器初始化参数中指定调试器的 jar 包绝对路径,LSP 会把调试器注册为一个插件,并且将调试器插件所支持的命令以及请求注册到语言服务的 workspace/executeCommand
请求中作为子命令。Java 调试器共支持以下几个子命令用于调试器相关的初始化配置及启动等功能,这些命令由调试器实现,通过 LSP 注册并提供给客户端调用。
// 调试器子命令调用方式
{
"jsonrpc":"2.0",
"id":10,
"method":"workspace/executeCommand",
"params":{
"command":"vscode.java.updateDebugSettings",
"arguments":[
"{\"showHex\":true,\"showStaticVariables\":true,\"showQualifiedNames\":true,\"maxStringLength\":0,\"enableHotCodeReplace\":true,\"logLevel\":\"FINER\"}"
]
}
}
vscode.java.fetchUsageData 获取调试器默认配置。
vscode.java.startDebugSession 启动调试器的 TCP 服务,返回端口号。
vscode.java.resolveClasspath 获取被调试 Java 程序的类路径。
vscode.java.resolveMainClass 获取被调试 Java 程序的 main 方法所在类,与类路径一同用于初始化调试器配置,最终会在指定的链接器中调用 .launch
时作为参数。这个参数在 VSCODE 中也可以由用户指定。
vscode.java.buildWorkspace 在启动调试之前构建被调试程序。
vscode.java.updateDebugSettings 更新调试器设置。
调试器启动之前,会先向 LSP 服务发送 vscode.java.resolveClasspath
、vscode.java.resolveMainClass
、vscode.java.buildWorkspace
等请求来构建被调试程序并获取 mainClass
、classPaths
等必要的参数。
之后客户端发送 vscode.java.startDebugSession
命令后会启动 TCPServer 等待客户端连接。
// java-debug JavaDebugServer.java
private JavaDebugServer() {
try {
this.serverSocket = new ServerSocket(0, 1);
} catch (IOException e) {
logger.log(Level.SEVERE, String.format("Failed to create Java Debug Server: %s", e.toString()), e);
}
}
在客户端也就是 Java 调试器扩展中,
查看扩展源码可以看到这段逻辑包含在 JavaDebugConfigurationProvider
中,这个类负责给 VSCODE 的 debugServices
提供上面提到的参数。当扩展被激活时,会调用 registerDebugConfigurationProvider
函数来注册这个类。
// vscode-java-debug extension.ts
vscode.debug.registerDebugConfigurationProvider("java", new JavaDebugConfigurationProvider());
VSCODE 则会调用其中的 resolveDebugConfiguration
方法借助 LSP 获取调试器初始配置。
// vscode debugConfigurationManager.ts
public resolveConfigurationByProviders(folderUri: uri | undefined, type: string | undefined, debugConfiguration: IConfig): TPromise<IConfig> {
// pipe the config through the promises sequentially. append at the end the '*' types
const providers = this.providers.filter(p => p.type === type && p.resolveDebugConfiguration)
.concat(this.providers.filter(p => p.type === '*' && p.resolveDebugConfiguration));
return providers.reduce((promise, provider) => {
return promise.then(config => {
if (config) {
return provider.resolveDebugConfiguration(folderUri, config);
} else {
return Promise.resolve(config);
}
});
}, TPromise.as(debugConfiguration));
}
此时点击 VSCODE 界面上点击启动调试,便会尝试连接调试器的 TCPServer。
// vscode rawDebugSession.ts
startSession(): TPromise<void> {
return new TPromise<void>((c, e) => {
this.socket = net.createConnection(this.port, this.host, () => {
this.connect(this.socket, <any>this.socket);
c(null);
});
this.socket.on('error', (err: any) => {
e(err);
});
this.socket.on('close', () => this._onExit.fire(0));
});
}
由于调试器并不知道客户端什么时候准备启动调试,所以需要等待连接成功后客户单发送 initialize
请求来表示自己已经准备开始调试。
// initialize 请求
{
"command":"initialize",
"seq":1,
"arguments":{
"clientID":"coding",
"clientName":"Cloud Studio",
"adapterID":"java",
"locale":"zh-cn",
"linesStartAt1":true,
"columnsStartAt1":true,
"pathFormat":"path",
"supportsVariableType":true,
"supportsVariablePaging":true,
"supportsRunInTerminalRequest":true
},
"type":"request"
}
请求成功后,客户端再发送 launch
请求,包含了以上获取到的 classaPaths
以及 mainClass
等参数,这时调试器真正开始启动被调试程序。这里 launch
对应了 JDI 链接器中的启动型号链接器,表示由调试器来启动目标虚拟机(vm)。
// lanunch 请求
{
command: "launch",
seq: 2,
type: "response",
arguments: {
args: "",
classPaths: [],
mainClass: "net.coding.demo.Application",
modulePaths: [],
request: "launch",
type: "java",
}
}
// java-debug AdvancedLaunchingConnector.java
// constructLaunchCommand 构建被调试程序启动参数
String[] cmds = constructLaunchCommand(connectionArgs, address);
Process process = Runtime.getRuntime().exec(cmds, envVars, workingDir);
VirtualMachineImpl vm;
try {
vm = (VirtualMachineImpl) listenConnector.accept(args);
} catch (IOException | IllegalConnectorArgumentsException e) {
process.destroy();
throw new VMStartException(String.format("VM did not connect within given time: %d ms", ACCEPT_TIMEOUT), process);
}
// 调用 setLaunchedProcess 将被调试程序的进程赋值给目标虚拟机,目标虚拟机监听此进程的运行信息
vm.setLaunchedProcess(process);
此时被调试程序已经正式启动,客户端可以根据协议规范来进行调试相关操作。
同样的,由于平台差异,在 Web 端无法直接监听调试器端口来进行通信,我们还需要一层 WebSocket 来转发调试器与客户端的消息。
服务端需要启动一个 WebSocket 服务,当调试器启动 TCPServer 时,客户端携带调试端口连接到服务器,服务器再作为 TCPClient 连接到调试器,然后将客户端(网页端)的请求转发到给调试器服务。
服务端实现非常简单,只需要在接收到客户端请求后按照协议规范拼接好带有 Content-Length
字段的协议字符串发送给提调试器。同样收到调试器回复或事件消息时再发送给客户端即可。
这里重点介绍一下客户端如何监听 WebSocket 消息并转化为事件机制。因为前几篇文章中提到的 LSP 相关操作本身就封装在 Monaco 编辑器中,所以实现起来相对比较简单,只要调用 monaco-languageClient
中的相关方法,编辑器就会自动发送 LSP 请求及识别回复,除了一些超出编辑器本身的操作,都由编辑器自行完成。
而调试器界面是在编辑器之外的,Monaco 编辑器也并没有自带调试器UI,所以这部分工作需要我们自己完成。
具体来说我们需要一个简单的通用调试器UI,可以照 VSCODE 界面来抄(反正都是现成的。。
之后还需要一个 WebSocket 客户端来与服务器通信,使用与服务端配套的 socket.io-client
来实现这个客户端,上面提到,客户端需要将请求以及接收到的回复/事件转化为事件订阅机制,因为这样更方便与 UI 同步。
我们使用 React + Redux 实现客户端界面,同时使用 Redux-Saga 作为异步方案来实现 WebSocket 的事件转化机制。这里不详细介绍 Redux-Saga 的用法,有兴趣的可以自行查看官方文档;
首先将 WebSocket 封装为一个单例模式,这样方便给 Saga 作为 API 来调用且避免被多次实例化。
class WebSocketApi {
constructor() {
this._instance = null;
// 请求时携带的唯一自增 ID
this.sequence = 1;
// 缓存请求的回调函数
this.pendingRequests = new Map();
// 缓存事件的处理函数(由 Saga 在注册时提供,这里实现应为一个 generator 函数)
this.eventCallback = new Map();
}
static getInstance() {
if (!this._instance) {
this._instance = new WebSocketApi();
}
return this._instance;
}
}
然后需要有一个供 Saga 调用发送请求的方法 sendRequest,在调试协议中每个请求都会有相应的回复,所以我们还需要把这个请求 ID 缓存起来,并提供一个接收到回复的处理函数。(这个回复的处理函数由 WebSocketApi 自行实现,给 Saga 调用再封装为 Promise 的形式)
// 供 Saga 调用 这一层实现代码比较简单,就不再多说了。
sendRequest = (command, args) => {
return new Promise((resolve, reject) => {
this.internalSend(command, args, (response) => {
if (response.success) {
resolve(response);
} else {
reject(response);
}
});
});
}
internalSend = (command, args, cb) => {
const request = {
command,
seq: this.sequence++,
};
if (args && Object.keys(args).length > 0) {
request.arguments = args;
}
this._internalSend('request', request);
if (cb) {
// store callback for this request
this.pendingRequests.set(request.seq, cb);
}
}
_internalSend = (type, message) => {
message.type = type;
if (this.ws) {
this.ws.send(JSON.stringify(message));
}
}
接下来是接收到调试器事件的机制,这里的事件是指前文中提到的 JDI 中的事件模块,调试器会把这些事件发送给客户端。
connect = (port) => {
this.ws = createWebSocket(port);
this.ws.on('message', this.handleMessage);
return new Promise((resolve, reject) => {
this.ws.on('connect', () => resolve(true));
});
}
handleMessage = (data) => {
const message = JSON.parse(data);
switch (message.type) {
case 'event':
this.onDapEvent(message);
break;
}
}
onDapEvent = (event) => {
const eventCb = this.eventCallback.get(event.event);
if (eventCb) {
try {
store.runSaga(eventCb, event);
} catch (e) {
console.log(e.message);
}
}
}
可以看到上面代码中接收到事件类型的消息时,从 eventCallback
中获取到 Saga 提供的事件处理函数,而使用 store.runSaga
来调用。这是因为这些事件处理函数都是 Saga 或者说 generator 函数的形式存在的,而这里的 store.runSaga 实际上就是 redux-saga 中的 sagaMiddleware.run
函数。我们知道 Saga 本身应该是由 redux 的 action 来驱动的,而我们想接收到调试器的事件时来运行 Saga ,所以借助 sagaMiddleware.run 来实现了 Saga 的外部调用。
我们可以这样注册这些外部调用的 Saga
// 发送 startDebugSession 并成功返回后连接 WebSocket
const success = yield call(webSocketApi.connect, port);
if (success) {
// 发送初始化配置
yield put(debugInitialize(initializeParams));
// 调用注册 saga 事件
yield fork(registerEventCallback);
}
// 注册事件
function* registerEventCallback() {
try {
webSocketApi.registerEventCallback('initialized', initializedEventSaga);
webSocketApi.registerEventCallback('stopped', stoppedEventSaga);
webSocketApi.registerEventCallback('output', outputEventSaga);
webSocketApi.registerEventCallback('thread', threadEventSaga);
webSocketApi.registerEventCallback('continued', continuedEventSaga);
}
}
// stopped 事件的 saga 实现
function* stoppedEventSaga(params) {
try {
const { body } = params;
// set button state.
yield put(setStoppedStatus(true));
// set stoppedThread
yield put(setStoppedThread(body.threadId));
// set stoppedDetails
yield put(updateStoppedDetails(body));
yield put(fetchThreads());
const stackParams = {
threadId: body.threadId,
startFrame: 0,
levels: 20,
};
const response = yield call(webSocketApi.sendRequest, 'stackTrace', stackParams);
if (response.success) {
const {
body: { stackFrames },
} = response;
for (const sf of stackFrames) {
yield put(updateStackFreams(body.threadId, sf));
}
if (stackFrames.length > 0) {
// The request returns the variable scopes for a given stackframe ID.
yield put(fetchVariableScopesByFrameID(stackFrames[0].id));
}
// @TODO UI change
}
} catch (e) {
//
}
}
通过这种机制,我们可以在接收到指定事件之后借助 redux-saga 强大的异步任务调度能力来执行相应的逻辑,同时还可以调用同步的 action 来对 UI以及编辑器 做相应的更新。
调试是日常开发中非常重要的一部分,了解常用编辑器/IDE 的调试原理有助于我们更好的使用调试功能
。这篇文章内容较长,首先介绍了 VSCODE 中调试协议的概念,进而以 Java 为例解析了 VSCODE 中是如何启动调试器,以及简单介绍了一下 Java 调试器的实现原理。最后介绍了在线编辑器调试实现的思路,同时借助 redux-saga 实现了一个简单的事件机制来实现 WebSocket 消息的转化处理。
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.