Markup 和 CMarkup 对象

Windows 为了实现浏览器功能代码的复用,将浏览器内部 DOM 接口\DHTML接口使用 COM 方式实现,这样HTML页面的内容就可以方便的被其他各个模块所调用,如 浏览器的javascript、操作浏览器组件的C++等。其主要的实现均存在于 mshtml.dll 中。
其中 Markup 是一系列接口和对象的集合,主要为用户提供访问和修改HTML页面内容的功能。Markup 在IE 浏览器中被封装成一个类,叫做 CMarkup。

Markup

理解 Markup 首先需要理清如下的几个概念

Tags vs Elements

一个html 标签在浏览器内部的表示形式被称为 Element,理解 tag 和 element 的概念尤其重要。HTML 页面内容包含有 tag,如<B></B>、<A></A>等,在使用浏览器访问HTML 页面时,浏览器的 parser 读到 <B></B>等标签,并且根据 tag 的不同创建不同的对象。这些可以操作的对象称为 Element。Markup 所能够操作的也正是这些对象

举例来说,有如下页面

<P>First<P>Second

浏览器页面 parser 解析这些语句时则会变成下面的样子

<HTML><HEAD><TITLE></TITLE></HEAD><BODY>
<P>First</P><P>Second</P></BODY></HTML>

换而言之,paser 将 HTML 页面中的内容转变成了 element,并且添加了一些元素以保证页面结构的完整性。

另一个需要理解的概念是 stream 和 tree。
以如下页面举例

My <B>dog</B> has fleas.

上述页面会被解析成为如下的树结构

              ROOT
                |
          +-----+------+
          |     |      |
         "My"   B  "has fleas."
                |
              "dog"

而对于上述文档的操作看起来就像是在对树进行操作,比如添加或者删除叶节点。
然而随着功能的加强,页面的内容从 ie4.0 开始变得不再是上图中那样简单的树结构了。
如下一个例子

Where do <B>you <I>want to</B> go</I> today?

在这个页面中 <B> 标签和 <I> 标签相互嵌套,这样一来页面便无法被简单的表示成为树结构,此时 markup 便应运而生了。
Markup 将页面看作是一个 stream。页面中的内容均由markup pointer 进行索引,对于页面内容的操作也是按照markup pointer 指定的范围进行。以上面的页面为例,操作重叠的tag 时使用两个markup pointer ,一个指向tag 的开头另一个指向tag 的结尾,这种方式当然也可以表示之前的树结构,换句话说 stream 是 tree 的超集

合法的和不合法的页面

一般的浏览器都具有容错性,就像上面举过的例子一样,浏览器的 parser 会在解析过程中为页面添加必要的结构以努力构成一个合法页面。一个合法页面至少要包含一个 html、一个head、一个 TITLE 和一个 body。

markup 为用户提供接口,使用户可以在页面解析完成、或者尚未完成时修改页面内容。

IMarkupServices

MarkupContainer

Container 顾名思义即页面Element 的容器,也是Markup 操作的容器,MarkupContainer 用于把创建的Element 对象和页面中的 text 内容联系起来。在页面解析完成之后,系统会默认创建一个主 Container,其后每一次页面内容的操作都需要指定一个 Container,具体的流操作均在这个 Container 上进行。
举例来说,下面的代码想要向一个页面中插入一个元素

int Insert(
    MSHTML::IHTMLDocument2Ptr pDoc2,
    ....)
{
    HRESULT              hr = S_OK;
    //IHTMLDocument2 *   pDoc2;
    IMarkupServices  *   pMS;
    IMarkupContainer *   pMpContainer;
    IMarkupPointer   *   pPtr1, * pPtr2;
    
    pDoc2->QueryInterface( IID_IMarkupContainer, (void **) & pMpContainer);
    pDoc2->QueryInterface( IID_IMarkupServices, (void **) & pMS );

    // need two pointers for marking
    pMS->CreateMarkupPointer( & pPtr1 );
    // beginning and ending position.
    pMS->CreateMarkupPointer( & pPtr2 ); 

    //
    // Set gravity of this pointer so that when the replacement text
    // is inserted it will float to be after it.
    //
    pPtr1->SetGravity( POINTER_GRAVITY_Right ); // Right gravity set
    pPtr2->SetGravity( POINTER_GRAVITY_Left );


    pPtr1->MoveToContainer( pMpContainer, TRUE );
    pPtr2->MoveToContainer( pMpContainer, TRUE );
    
    ......
    Insert()
}

对页面的插入操作首先需要通过页面对象获取对应的 Container 接口,接着使用 markup pointer 遍历到 Container 中的指定位置,这样才能执行操作。

MarkupPointer

MarkupPointer 并不是 MarkupContainer 的一部分。MarkupPointer 的主要功能是用来指示tag节点在文档中的位置。因此 pointer 可以看作是用于在 Container 中进行索引的迭代器。
举例来说

My <B>d[p1]og</B> has fleas.

在这个页面中,MarkupPointer 出现在[p1]所示的位置上,但它并不会在页面内容中添加任何东西,或者对页面内容进行任何修改。
MarkupPointer 可以被至于页面的这些位置:element的开始、element的结束、或者text之中。由于MarkupPointer本身不包含内容,因此如果两个 MarkupPointer 指向了同一个位置便会难以区分。

通过 Markup ,用户便可以操作页面中的内容,其主要提供了以下一些功能

放置Markup Pointers

markup pointer 被创建后处于 unpositioned 状态,表示它还没有被放置到页面中的任何位置。微软提供了三个函数用来为markup pointer 指定位置

  • MoveAdjacentToElement
  • MoveToContainer
  • MoveToPointer

MoveAdjacentToElement函数有两个参数,Element和一个枚举类型常量,他们协同指定markup pointer的位置。函数原型如下

HRESULT MoveAdjacentToElement(
    IHTMLElement *elementTarget,
    ELEMENT_ADJACENCY
);

    enum ELEMENT_ADJACENCY {
         ELEMENT_ADJ_BeforeBegin
         ELEMENT_ADJ_AfterBegin
         ELEMENT_ADJ_BeforeEnd
         ELEMENT_ADJ_AfterEnd
    };

MoveToContainer函数也有两个参数,MarkupContainer 和一个Bool 类型用以指定 markup pointer 应该放在 container 的开始还是结尾。函数原型如下

HRESULT MoveToContainer(
    IMarkupContainer *containerTarget,
    BOOL fAtStart
);

MoveToPointer函数只有一个参数,另一个markup pointer。函数功能即把当前 pointer 指定到参数 pointer 的位置。函数原型如下

HRESULT MoveToPointer(
    IMarkupPointer *pointerTarget
);

这个函数一般用于在markup pointer执行功能的时候,保存当前的位置

比较pointer 的位置

两个 markup pointer 的位置关系可以使用下面的函数进行比较


HRESULT IsEqualTo(
    IMarkupPointer *compareTo,
    BOOL *fResult
);

HRESULT IsLeftOf(
    IMarkupPointer *compareTo,
    BOOL *fResult
);

HRESULT IsLeftOfOrEqualTo(
    IMarkupPointer *compareTo,
    BOOL *fResult
);

HRESULT IsRightOf(
    IMarkupPointer *compareTo,
    BOOL *fResult
);

HRESULT IsRightOfOrEqualTo(
    IMarkupPointer *compareTo,
    BOOL *fResult
);
Navigating the Pointer

一旦一个 markup pointer 被放置在一个 markup containter 中。用户便可以使用这个 pointer 来检查周围的页面内容,或者遍历这块内容。用户只能使用windows 提供的两个函数完成这些功能,Left检查pointer 的左边是什么,Right 检查pointer 的右边是什么

HRESULT Left(
    BOOL fMove,
    MARKUP_CONTEXT_TYPE pContextType,
    IHTMLElement **ppElement,
    long *plCch,
    OLE_CHAR *pch
);

HRESULT Right(
    BOOL fMove,
    MARKUP_CONTEXT_TYPE pContextType,
    IHTMLElement **ppElement,
    long *plCch,
    OLE_CHAR *pch
);
  • 第一个参数指定指针是否可移动,若不可移动,则函数仅仅会返回指针周围内容的描述;否则,函数在返回周围内容描述的同时还会移动过去。
  • 第二个参数为返回值,返回pointer周围的内容类型。
Value Are Example
CONTEXT_TYPE_None pointer左边或者右边没有东西 [p1]<HTML></HTML>[p2]
CONTEXT_TYPE_Text pointer左边或者右边是一个text tex[p]t
CONTEXT_TYPE_EnterScope 如果是Left,则point左边是一个End tag;如果是Right,pointer的右边是一个Begin tag 。 </B>[p]<B>
CONTEXT_TYPE_ExitScope 如果是Left,则point左边是一个Begin tag;如果是Right,pointer的右边是一个End tag 。 <B>[p]</B>
CONTEXT_TYPE_NoScope pointer的左边或者右边不是一个可以成对的标签 <BR>[p]<BR>
  • 第三个参数返回 pointer 左边或者右边的element
  • 第四个参数用来限定读取的text范围,同时也用来返回获取的text 的大小
  • 第五个参数返回pointer 左边或者右边的 text

下面以具体的页面举例说明

[p1]Where [p2]<I>do </I>[p3]<B>you <BR>[p4]want</B> to go today[p5]?

对于页面上的五个pointer 分别调用left,right结果如下表

Ptr Derection Type Element cch in cch out Text
p1 left None - - - -
p1 right Text - 2 2 Wh
p1 right Text - -1 6 -
p1 right Text - 345 6 Where
p2 left Text - NULL - -
p2 right EnterScope I - - -
p3 left ExitScope I - - -
p4 left NoScope BR - - -
p5 left Text I 100 12 NULL

CurrentScope函数可以得到Pointer 当前指向的Element。函数原型如下

HRESULT CurrentScope(
    IHTMLElement **ppElementCurrent
);

上述例子中,p1返回值是 NULL;p4返回值是B,因为BR不是一个可以成对的标签

Pointer Gravity

通常情况下,一个 document 被修改之后,document 中的markup Pointer还会保留在之前未修改时的位置。
举例来说

abc[p1]defg[p2]hij
abc[p1]deXYZfg[p2]hij

当第一个页面被修改为第二个页面之后,虽然页面的内容发生了改变,但是pointer 的相对位置仍然保持不变。
但如果页面的修改发生在 point 指向的位置,如上例中,向c、d之间插入一个Z,p 的位置就会出现二义性。

abcZ[p1]de  or  abc[p1]Zde

这时就需要引用另一个重要的概念gravity,每一个pointer都有一个 gravity 值标识着其左偏或右偏。仍以上述页面为例

abc[p1,right]defg[p2,left]hij 

分别在p1,p2的位置插入一对<B>标签。这时由于gravity的存在,页面会变成如下

abc<B>[p1,right]defg[p2,left]</B>hij 

默认情况下 pointer 的gravity 值是 left。用户可以通过 windows 提供的函数来查看或者修改 pointer 的 gravity 值

enum POINTER_GRAVITY {
    POINTER_GRAVITY_Left,
    POINTER_GRAVITY_Right
};

HRESULT Gravity(
    POINTER_GRAVITY *pGravityOut
);

HRESULT SetGravity(
    POINTER_GRAVITY newGravity
);
Pointer Cling

有如下例子

[p2]ab[p1]cdxy

当bc 段被移动到 xy之间时p1的位置也出现了二义性,是应该随着bc移动,还是应该继续保持在原位呢

[p2]a[p1]dxbcy or [p2]adxb[p1]cy

这就需要 cling 的存在,如果p1指定了cling 属性,那么页面操作之后就会成为右边所示的情况,否则就会出现左边所示的情况

cling 和 gravity 可以协同作用,比如下面的例子

a[p1]bcxy

b移动到x、y之间,如果p1指定了 cling属性,并且gravity 值为 right,那么p1便会跟随b一起到xy之间。这种情况下如果b被删除,那么p1也会跟着从content 中移除,但并不会销毁,因为p1还有可能重新被使用
cling相关的函数,函数原型如下

HRESULT Cling(
    BOOL *pClingOut
);

HRESULT SetCling(
    BOOL NewCling
);
创建新Element

动态创建新节点的操作也是通过 markup 来完成的,CreateElement 函数原型如下

enum ELEMENT_TAG_ID {
    TAGTADID_A,
    TAGTADID_ACRONYM,
        ..
    TAGTADID_WBR,
    TAGTADID_XMP
};

HRESULT CreateElement(
    TAG_ID tagID,
    OLECHAR *pchAttrs,
    IHTMLElement **ppNewElement
);

第二个参数是属性串,可以在 Element创建时就加入属性。
用户也可以通过从一个已有 element 克隆,来得到新的 element

插入新 Element

新 element 成功创建之后,如果想加入document 中,还需要通过markup 将element插入。 函数原型如下

HRESULT InsertElement(
    IHTMLElement *pElementInsertThis,
    IMarkupPointer *pPointerStart,
    IMarkupPointer *pPointerFinish
);

第二参数指示这个element 的begin tag 插入到哪里;第三个参数指示这个 element 的end tag应该插入到哪里;这两个位置必须在同一个 markup Container 中。
举例来说,调用函数将 <B> 标签插入下面的页面中

My [pstart]dog[pend] has fleas.

默认情况下结果将如下面所示,如果 pointer 的 gravity 改变,情况也会改变

My [pstart]<B>dog[pend]</B> has fleas.
移除Element

移除 element 并不需要markup pointer ,只需要传递给函数要删除的 element 就可以。函数原型如下

HRESULT RemoveElement(
    IHTMLElement *pElementRemoveThis
);

element 被从 document 中移除之后并不会被删除,他随时可以被重新插入

插入 Text

在 document 中插入 text ,函数原型如下

HRESULT InsertText(
    OLECHAR *pch,
    long cch,
    IMarkupPointer *pPointerTarget
);

注意到,插入text 只需要一个 markup pointer 来指定位置

移除内容

用户可以移除在同一个container 中一段连续的内容,函数原型如下

HRESULT Remove(
    IMarkupPointer *pPointerSourceStart,
    IMarkupPointer *pPointerSourceFinish
);

两个参数用来指定remove操作的范围,所有在这两个点之间的内容都会被移除。但是有一点例外,即两个 pointer 没有完全包含的 element 不会被移除。举例来说

     <--------- i -----------> <---------- u ----------->
    a<I>b<B>c[pstart]d<S>e</I>f<U>g</S>h[pend]hi</B>j</U>kl
                      <----- s ------->         

remove 操作传入 pstart、pend 两个参数,结果页面被修改为下面的情况

 <------- i --------><------- u -------->
a<I>b<B>c[pstart]</I><U>[pend]hi</B>j</U>kl

<U> 和 <I> 并未被移除。

替换内容

插入和移除操作何以合成 Replace 操作

int MarkupSvc::RemoveNReplace(
    MSHTML::IHTMLDocument2Ptr pDoc2,
    _bstr_t bstrinputfrom, _bstr_t bstrinputto)
{
    HRESULT              hr = S_OK;
    //IHTMLDocument2 *   pDoc2;
    IMarkupServices  *   pMS;
    IMarkupContainer *   pMarkup;
    IMarkupPointer   *   pPtr1, * pPtr2;
    TCHAR            *   pstrFrom = _T( bstrinputfrom );
    TCHAR            *   pstrTo = _T( bstrinputto );
    
    pDoc2->QueryInterface( IID_IMarkupContainer, (void **) & pMarkup );
    pDoc2->QueryInterface( IID_IMarkupServices, (void **) & pMS );

    // need two pointers for marking
    pMS->CreateMarkupPointer( & pPtr1 );
    // beginning and ending position of text.
    pMS->CreateMarkupPointer( & pPtr2 ); 

    //
    // Set gravity of this pointer so that when the replacement text
    // is inserted it will float to be after it.
    //
    pPtr1->SetGravity( POINTER_GRAVITY_Right ); // Right gravity set

    //
    // Start the search at the beginning of the primary container
    //

    pPtr1->MoveToContainer( pMarkup, TRUE );

    for ( ; ; )
    {
        hr = pPtr1->FindText( (unsigned short *) pstrFrom, 0, pPtr2, NULL );

        if (hr == S_FALSE) // did not find the text
            break;

        // found it, removing..
        pMS->Remove( pPtr1, pPtr2 );
        
        //inserting new text
        pMS->InsertText( (unsigned short *) pstrTo, -1, pPtr1 );
    }
    if (hr == S_FALSE) return FALSE;
    else return(TRUE);
}
移动内容

用户可以使用 Move 移动一段页面内容,函数原型如下

HRESULT Move(
    IMarkupPointer *pPointerSourceStart,
    IMarkupPointer *pPointerSourceFinish,
    IMarkupPointer *pPointerTarget
);

函数前两个参数和 remove 类似,函数会将这一整段内容移动到目的 pointer 中。那些与pointer 范围有重叠的 element,即并不完全包含在 pointers 之间的 element 会在目的处创建一个拷贝。
举例来说


X[pdest]Y

 <--------- i -----------> <---------- u ----------->
a<I>b<B>c[pstart]d<S>e</I>f<U>g</S>h[pend]hi</B>j</U>kl
                  <----- s ------->      

操作之后页面变成

X[pdest]<I'>d<S>e</I'>f<U'>g</S>h</U'>Y

 <------- i --------><------- u -------->
a<I>b<B>c[pstart]</I><U>[pend]hi</B>j</U>kl

可以看到完全包含在pointers 中的<S>标签被移动到dest 位置,而与 pointers 区域重叠的 <U>、<I>标签在目标位置创建一个备份。

以上内容翻译自微软提供的官方 Markup Serivce 文档。

CMarkup

CMarkup 其本质上是对Markup Service 的封装,在 IE/EDGE 中方便 js 引擎在操作页面时调用。简单来说 CMarkup 可以看作是 Markup Service 中的 MarkupContainer。以下是 IE8 中 CMarkup 的部分结构,可以看出其关联了与页面相关的许多重要的元素。不仅如此所有的页面元素都保存一个指向 CMarkup 的指针,在对页面元素进行访问时,均需要通过 CMarkup 来进行。

Class CMarkup{
   +0xA0    WindowedMarkupContext
   +0x40    CDocument
   +0x108  COmWindowProxy
   +0x50   CHtmlCtx
   +0x54   CProgSink
   +0x5C  CSecurityContext
   +0x8c   CAPStatr
   +0xc0   CSecurityContext
   +0xc8   CStyleSheetArray
   +0xcc   TagArray
   +0xd0   ComWindowProxy
   +0xdc  obj_name_space
   +0xf4   CHtmlElemeCtxStream
   +0x124  uri
   +0x158  CTimeManager
   +0x16c  CMSPerformanceData
   +0x140  CTreePos
}

以 DOM 节点固有属性 nextSibling 举例,该属性用于返回其父节点的 childNodes 列表中紧跟在其后的节点。通过 js 访问节点的该属性,IE 8 内部使用 CElement::get_nextSibling 函数来实现,对该函数进行逆向后部分代码如下。

HRESULT CElement::GetNextSiblingHelper(CElement *this, CElement **nextSibling)
{
  CMarkupPointer * markupPointer;
  CDoc* cDoc;
  HRESULT result;

  cDoc = CElement::Doc(this);
  CMarkupPointer::CMarkupPointer(markupPointer, cDoc);   // 创建 MarkupPointer
  
  result = markupPointer->MoveAdjacentToElement( this, ELEMENT_ADJ_AfterEnd);    // 放置 MarkupPointer
    if ( result == S_OK )
    {
      cDoc = CElement::Doc(this);
      result = sub_74D4A0B3(cDoc , markupPointer, &nextSibling);       // 通过 MarkupPoint 获取 Element
    }

  result = CBase::SetErrorInfo(markupPointer, result);
  CMarkupPointer::~CMarkupPointer(markupPointer);
  return result;
}

函数的主要逻辑即,首先新建一个 MarkupPointer 对象,接着将该 MarkupPointer 放置于目标节点的 ELEMENT_ADJ_AfterEnd 位置,而后通过该 MarkupPointer 来检查周围的内容,这里使用的函数其实是 CMarkupPointer::There ,其函数为 Left() 和 Right() 的合并。

同样的 previousSiblingfirstChildlastChild 的内部实现流程也类似,通过 CMarkupPointer::MoveAdjacentToElement 将 CMarkupPointer 放置在节点对象的不同位置,再通过 CMarkupPointer::There 取出对应的节点信息即可。

childNodes 节点属性则是通过 CMarkupPointer 遍历对应 Element 节点而实现,在 IE 8 中其主要的功能函数为 CElement::DOMEnumerateChildren ,该函数逆向后主要功能代码如下

CElement::DOMEnumerateChildren(CElement children[])
{
     cDoc = CElement::Doc(this);
     CMarkupPointer::CMarkupPointer(markupPtrBegin, cDoc);   
     CMarkupPointer::CMarkupPointer(markupPtrEnd, cDoc);    
     ......
     result = markupPtrBegin->MoveAdjacentToElement( this, ELEMENT_ADJ_AfterBegin);    // 放置 MarkupPointer
     result = markupPtrEnd->MoveAdjacentToElement( this, ELEMENT_ADJ_AfterEnd);    // 放置 MarkupPointer
     do{
        ......
        child = markupPointer->There()
        children[i++] = child;
        result = markupPtrBegin->MoveAdjacentToElement( child, ELEMENT_ADJ_AfterBegin);    // 放置 MarkupPointer
        ......
        }while( !markupPtrBegin->isLeftOf(markupPtrEnd) )
    ......
}

通过两个 CMarkupPointer 指针分别指向 Element 的开始和结尾,从 Element 的开始位置依次遍历 ,其间所有的节点均为 Element 的子节点。

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

推荐阅读更多精彩内容

  • [TOC] 一、DOM 流简介 DOM (Document Object Model)作为现代浏览器的基础,其设计...
    o_0xF2B8F2B8阅读 1,019评论 0 0
  • 课程介绍 先修课:概率统计,程序设计实习,集合论与图论 后续课:算法分析与设计,编译原理,操作系统,数据库概论,人...
    ShellyWhen阅读 2,244评论 0 3
  • 问答题47 /72 常见浏览器兼容性问题与解决方案? 参考答案 (1)浏览器兼容问题一:不同浏览器的标签默认的外补...
    _Yfling阅读 13,725评论 1 92
  • 1.几种基本数据类型?复杂数据类型?值类型和引用数据类型?堆栈数据结构? 基本数据类型:Undefined、Nul...
    极乐君阅读 5,496评论 0 106
  • 是什么时候发现原来自己是个没有秘密的人? 小学的时候喜欢ck先生,好像很多人知道吧?有一次去检查眼保...
    张笑忧阅读 318评论 0 1